From 1f4b98d9db1eb3f46518c5e67c0162406016081b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 13 Apr 2026 16:04:33 +0800 Subject: [PATCH 1/5] chore: bump version to 1.0.0.dev11 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a5d70451..aa10017e 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.dev10" +version = "1.0.0.dev11" description = "postgres backend implementation for rhosocial-activerecord, providing a robust and optimized postgres database support." readme = "README.md" license = { file = "LICENSE" } From 39d50341713956f6b61aff2567c3233f99cbef76 Mon Sep 17 00:00:00 2001 From: zhao vistart <864657+vistart@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:37:08 +0800 Subject: [PATCH 2/5] feat(postgres): add comprehensive backend examples for PostgreSQL (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(expression): reorganize DDL expressions into expression/ddl/ subdirectory - Move DDL expression classes from expressions.py to expression/ddl/ subdirectory - Create expression/ddl/ with: vacuum, partition, index, statistics, comment, mv, type - Update expression/__init__.py to export from ddl submodule - Update protocols/ and mixins/ import paths accordingly - Update test files to use new import paths - Add comprehensive docstrings to all DDL expression classes - Delete obsolete expressions.py and statements.py files BREAKING CHANGE: Import paths have changed: - Old: from rhosocial.activerecord.backend.impl.postgres.expressions import ... - New: from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ... * Fix postgres enum type formatting compatibility * fix: register pytest markers to suppress unknown mark warnings * docs: add comprehensive PostgreSQL function version support info * test: add SQLFunctionSupport tests for PostgreSQL dialect * chore: remove automatic publish trigger and fix function support check * feat: add comprehensive PostgreSQL backend examples for expression usage * fix: correct API usage in PostgreSQL backend examples - Use ColumnDefinition with constraints=[ColumnConstraint(...)] pattern - Use InsertExpression with into= and source=ValuesSource() - Use DropTableExpression instead of dialect.format_drop_table_statement() - Fix array type syntax using {} instead of ARRAY[] - Add proper JSONB and array operations All examples now run successfully. * feat: add version support metadata to PostgreSQL examples - Add min_version/max_version to conftest.py for all examples - Document version-specific features (JSONB 9.4+, window functions 8.4+, etc.) - All examples tested on PostgreSQL 10 and 18 * fix(postgres): rename MultirangeSupport and EnumTypeSupport to have Postgres prefix - MultirangeSupport -> PostgresMultirangeSupport - EnumTypeSupport -> PostgresEnumTypeSupport These are backend-specific protocols and should follow the naming convention. * fix(postgres): add backward compatibility aliases for renamed protocols - MultirangeSupport -> PostgresMultirangeSupport - EnumTypeSupport -> PostgresEnumTypeSupport - Add aliases for backward compatibility * fix(tests): update test references to renamed protocols - EnumTypeSupport -> PostgresEnumTypeSupport - Update test class names * feat(examples): add protocol-specific examples - Add query category for examples - Add Postgres window_functions.py (WindowFunctionSupport) - Add Postgres cte.py (CTESupport) - Add MySQL fulltext_search.py (MySQLFullTextSearchSupport) * feat(examples): add Postgres connection quickstart * refactor(examples): rename array_basic to array_postgres84 for version specificity * feat(examples): add connection category metadata for Postgres * feat(examples): add array_postgres84, cte, window_functions metadata * feat(examples): add Postgres upsert, materialized view examples * feat(examples): add Postgres FOR UPDATE transaction example * feat(examples): add Postgres distinct and union examples * feat(examples): add Postgres CREATE VIEW example * feat(examples): add Postgres single insert, update, drop_table examples * fix(examples): correct CreateViewExpression and SetOperationExpression parameter usage - Replace or_replace=True with replace=True in view.py example - Replace operation='UNION ALL' with operation='UNION', all_=True in union.py - Rewrite quickstart.py as self-contained example using Expression API instead of raw SQL strings * fix(examples): correct PostgreSQL example API usage and convert raw SQL to Expression API Severe fixes: - query/window_functions.py: rewrite using WindowFunctionCall+WindowSpecification+WindowFrameSpecification (was using non-existent WindowFunctionExpression/WindowFrameBounded/WindowFrameRows/ColumnExpression) - query/cte.py: rewrite using CTEExpression+WithQueryExpression (was using non-existent CTEType/CTEClause) - update/basic.py: replace set_columns/set_values with assignments dict Config fixes (user→username, PostgresBackend(config)→(connection_config=config)): - query/window_functions.py, query/cte.py High-priority fixes: - insert/upsert.py: rewrite raw SQL using InsertExpression+OnConflictClause - transaction/for_update.py: rewrite raw SQL using ForUpdateClause+QueryExpression+UpdateExpression - ddl/materialized_view.py: add comment noting MV is not covered by Expression API Low-priority fixes: - query/array_func.py, types/json_basic.py, types/array_postgres84.py: use imported helper functions to generate SQL fragments (with usage comments) * feat(postgres): add PostgresLockingMixin and replace all raw SQL with Expression API in examples - Create PostgresLockingMixin with supports_for_no_key_update/share/key_share/skip_locked and format_postgres_for_update_clause implementation - Create PostgresLockingSupport Protocol with method declarations - Move PostgresLockingMixin before LockingMixin in MRO to fix shadowing - Remove redundant supports_for_* methods from PostgresDialect (now in mixin) - PostgresForUpdateClause.to_sql() delegates to dialect.format_postgres_for_update_clause - Replace all backend.execute("raw SQL") in examples with Expression API (materialized_view, for_update, view, batch/single insert) Co-Authored-By: Claude Opus 4.6 * feat(examples): add PostgreSQL examples for create_table, basic transaction, predicates, pagination, and isolation levels Add 5 new example files covering DDL, transaction control, complex predicates, and pagination patterns specific to PostgreSQL. Co-Authored-By: Claude Opus 4.6 * fix(postgres): add format_on_conflict_clause override for EXCLUDED and remove all RawSQLExpression from examples - Add format_on_conflict_clause in PostgresDialect to handle EXCLUDED pseudo-table without double-quoting (EXCLUDED must not be quoted in PG) - Add AddTableConstraint/TableConstraint to TYPE_CHECKING imports (F821 fix) - Replace RawSQLExpression with proper Expression API in all example files: - DEFAULT values: use FunctionCall(dialect, 'now') for TIMESTAMP, Python bool for BOOLEAN, string '0' for numeric - EXCLUDED references: use Column(dialect, 'col', table='EXCLUDED') - Text search: use FunctionCall + ComparisonPredicate instead of specialized function imports - Fix IsNullPredicate usage for IS NULL conditions - Fix transaction examples to use transaction_manager API - Fix set_values -> assignments in update expressions - Remove unused imports (F401) and fix f-string issues (F541) Co-Authored-By: Claude Opus 4.6 * docs(postgres): update create_table example to use current_timestamp factory Replace FunctionCall(dialect, 'now') with current_timestamp(dialect) factory function that generates the SQL:2003 standard niladic form CURRENT_TIMESTAMP (no parentheses), which PostgreSQL DDL DEFAULT accepts. Co-Authored-By: Claude Opus 4.6 * style(postgres): fix E501 line length in example files Split long SQL string and text search literals across multiple lines. Co-Authored-By: Claude Opus 4.6 * feat(examples): add PostgreSQL examples for DELETE RETURNING, INSERT RETURNING, upsert, unique index, partition, UUID, and niladic functions Also register existing but unregistered examples (materialized_view, upsert) in conftest.py metadata. * fix(examples): distinguish gen_random_uuid (PG13+) from uuid_generate_v4 (PG<13) in UUID example * feat(examples): add PostgreSQL EXPLAIN Query Plan example * fix(build): exclude __pycache__ from source distribution The recursive-include directive used wildcard `*` which overrode global-exclude rules, causing .pyc files in __pycache__ directories to be included in the sdist tarball. * chore: add .DS_Store to gitignore * fix: exclude .mypy_cache from package data * ci: update testsuite to release/v1.0.0.dev11 * ci: update activerecord to release/v1.0.0.dev24 * chore: add changelog fragment for PR #30 * test: add DDL expression and protocol tests for PostgreSQL - Add test_ddl_expressions.py: tests for RefreshMaterializedViewPgExpression, CommentExpression, CreatePartitionExpression, DetachPartitionExpression, AttachPartitionExpression - Add test_ddl_protocols.py: tests for PostgresPartitionSupport, PostgresIndexSupport, PostgresMaterializedViewSupport protocols Total: 92 tests --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/publish.yml | 8 - .github/workflows/test.yml | 4 +- .gitignore | 3 + MANIFEST.in | 2 +- changelog.d/30.added.md | 1 + pyproject.toml | 9 +- .../backend/impl/postgres/__init__.py | 2 +- .../backend/impl/postgres/dialect.py | 345 ++++++++++- .../backend/impl/postgres/examples/README.md | 109 ++++ .../impl/postgres/examples/__init__.py | 0 .../impl/postgres/examples/conftest.py | 286 +++++++++ .../postgres/examples/connection/__init__.py | 0 .../examples/connection/quickstart.py | 249 ++++++++ .../impl/postgres/examples/ddl/__init__.py | 0 .../impl/postgres/examples/ddl/alter_table.py | 162 +++++ .../postgres/examples/ddl/create_index.py | 96 +++ .../postgres/examples/ddl/create_table.py | 140 +++++ .../impl/postgres/examples/ddl/drop_table.py | 75 +++ .../examples/ddl/materialized_view.py | 264 +++++++++ .../impl/postgres/examples/ddl/partition.py | 147 +++++ .../postgres/examples/ddl/unique_index.py | 175 ++++++ .../impl/postgres/examples/ddl/view.py | 146 +++++ .../impl/postgres/examples/delete/__init__.py | 0 .../impl/postgres/examples/delete/basic.py | 154 +++++ .../impl/postgres/examples/insert/__init__.py | 0 .../impl/postgres/examples/insert/batch.py | 116 ++++ .../impl/postgres/examples/insert/single.py | 88 +++ .../impl/postgres/examples/insert/upsert.py | 211 +++++++ .../examples/insert/with_returning.py | 147 +++++ .../impl/postgres/examples/query/__init__.py | 0 .../impl/postgres/examples/query/aggregate.py | 131 ++++ .../postgres/examples/query/array_func.py | 125 ++++ .../impl/postgres/examples/query/basic.py | 141 +++++ .../impl/postgres/examples/query/constants.py | 162 +++++ .../impl/postgres/examples/query/cte.py | 201 +++++++ .../impl/postgres/examples/query/distinct.py | 111 ++++ .../impl/postgres/examples/query/explain.py | 193 ++++++ .../impl/postgres/examples/query/join.py | 180 ++++++ .../postgres/examples/query/pagination.py | 193 ++++++ .../impl/postgres/examples/query/predicate.py | 336 +++++++++++ .../impl/postgres/examples/query/subquery.py | 169 ++++++ .../postgres/examples/query/text_search.py | 127 ++++ .../impl/postgres/examples/query/union.py | 136 +++++ .../impl/postgres/examples/query/window.py | 140 +++++ .../examples/query/window_functions.py | 118 ++++ .../postgres/examples/transaction/__init__.py | 0 .../postgres/examples/transaction/basic.py | 223 +++++++ .../examples/transaction/exclusive.py | 266 +++++++++ .../examples/transaction/for_update.py | 226 +++++++ .../impl/postgres/examples/types/__init__.py | 0 .../examples/types/array_postgres84.py | 125 ++++ .../postgres/examples/types/json_basic.py | 125 ++++ .../impl/postgres/examples/types/uuid.py | 166 ++++++ .../impl/postgres/examples/update/basic.py | 122 ++++ .../impl/postgres/expression/__init__.py | 44 +- .../impl/postgres/expression/ddl/__init__.py | 61 ++ .../impl/postgres/expression/ddl/comment.py | 91 +++ .../impl/postgres/expression/ddl/index.py | 97 +++ .../impl/postgres/expression/ddl/mv.py | 90 +++ .../impl/postgres/expression/ddl/partition.py | 212 +++++++ .../postgres/expression/ddl/statistics.py | 139 +++++ .../impl/postgres/expression/ddl/type.py | 414 +++++++++++++ .../impl/postgres/expression/ddl/vacuum.py | 170 ++++++ .../impl/postgres/expression/locking.py | 55 +- .../backend/impl/postgres/expressions.py | 557 ------------------ .../backend/impl/postgres/mixins/__init__.py | 2 + .../impl/postgres/mixins/ddl/comment.py | 2 +- .../backend/impl/postgres/mixins/ddl/index.py | 2 +- .../impl/postgres/mixins/ddl/partition.py | 2 +- .../impl/postgres/mixins/dml/__init__.py | 2 + .../mixins/dml/extended_statistics.py | 2 +- .../impl/postgres/mixins/dml/locking.py | 94 +++ .../impl/postgres/mixins/dml/vacuum.py | 2 +- .../impl/postgres/mixins/materialized_view.py | 2 +- .../impl/postgres/mixins/types/enum.py | 99 +++- .../impl/postgres/protocols/__init__.py | 8 +- .../impl/postgres/protocols/ddl/comment.py | 2 +- .../impl/postgres/protocols/ddl/index.py | 2 +- .../impl/postgres/protocols/ddl/partition.py | 2 +- .../impl/postgres/protocols/dml/__init__.py | 2 + .../protocols/dml/extended_statistics.py | 2 +- .../impl/postgres/protocols/dml/locking.py | 58 ++ .../impl/postgres/protocols/dml/vacuum.py | 2 +- .../postgres/protocols/materialized_view.py | 2 +- .../impl/postgres/protocols/types/__init__.py | 10 +- .../impl/postgres/protocols/types/enum.py | 2 +- .../postgres/protocols/types/multirange.py | 2 +- .../backend/impl/postgres/statements.py | 403 ------------- .../backend/impl/postgres/types/enum.py | 2 +- .../backend/postgres/test_ddl_expressions.py | 437 ++++++++++++++ .../backend/postgres/test_ddl_protocols.py | 254 ++++++++ .../backend/postgres/test_enum_types.py | 16 +- .../backend/postgres/test_expressions.py | 2 +- .../postgres/test_extended_statistics.py | 2 +- .../feature/backend/postgres/test_index_pg.py | 2 +- .../backend/test_dialect_function_support.py | 289 +++++++++ 96 files changed, 9199 insertions(+), 1096 deletions(-) create mode 100644 changelog.d/30.added.md create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/README.md create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/__init__.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/conftest.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/connection/__init__.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/connection/quickstart.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/__init__.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/create_index.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/create_table.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/drop_table.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/partition.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/unique_index.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/view.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/delete/__init__.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/delete/basic.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/insert/__init__.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/insert/batch.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/insert/single.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/insert/upsert.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/insert/with_returning.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/query/__init__.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/query/aggregate.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/query/array_func.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/query/basic.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/query/constants.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/query/cte.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/query/distinct.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/query/explain.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/query/join.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/query/pagination.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/query/predicate.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/query/subquery.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/query/text_search.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/query/union.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/query/window.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/query/window_functions.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/transaction/__init__.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/transaction/basic.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/transaction/exclusive.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/transaction/for_update.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/types/__init__.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/types/array_postgres84.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/types/json_basic.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/types/uuid.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/update/basic.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py delete mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expressions.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/locking.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/locking.py delete mode 100644 src/rhosocial/activerecord/backend/impl/postgres/statements.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dddbdec0..c2d370ab 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,14 +1,6 @@ name: Python Package Publish on: - # 当测试工作流成功完成时自动触发发布审批流程 - workflow_run: - workflows: ["Python Test and Coverage"] - types: - - completed - branches: - - main - # 手动触发发布工作流 workflow_dispatch: inputs: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d0e66429..5f242815 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,8 +67,8 @@ jobs: - name: Install activerecord and testsuite from Git run: | - pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev23 - pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev10 + pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev24 + pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev11 - name: Create postgres scenario config run: | diff --git a/.gitignore b/.gitignore index f2be2d67..c3d318e0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ __pycache__/ *.py[cod] *$py.class +# macOS +.DS_Store + # C extensions *.so diff --git a/MANIFEST.in b/MANIFEST.in index 90cc7310..3784335f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,6 @@ global-exclude *.pyc global-exclude __pycache__ recursive-exclude * *.pyc -recursive-include src/rhosocial/activerecord/backend/impl/postgres * +recursive-include src/rhosocial/activerecord/backend/impl/postgres *.py *.pyi include LICENSE include README.md \ No newline at end of file diff --git a/changelog.d/30.added.md b/changelog.d/30.added.md new file mode 100644 index 00000000..d6cdd961 --- /dev/null +++ b/changelog.d/30.added.md @@ -0,0 +1 @@ +Add comprehensive PostgreSQL backend examples covering connection, CRUD, transactions, CTEs, window functions, and more \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index aa10017e..f5d391f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=64"] build-backend = "setuptools.build_meta" [project] -name = "rhosocial_activerecord_postgres" +name = "rhosocial-activerecord-postgres" version = "1.0.0.dev11" description = "postgres backend implementation for rhosocial-activerecord, providing a robust and optimized postgres database support." readme = "README.md" @@ -71,6 +71,9 @@ Issues = "https://github.com/rhosocial/python-activerecord-postgres/issues" where = ["src"] namespaces = true +[tool.setuptools.package-data] +rhosocial = ["**/*.pyi", "**/*.py"] + [tool.pytest.ini_options] minversion = "7.0" addopts = "-ra -q" @@ -78,6 +81,10 @@ asyncio_mode = "auto" testpaths = [ "tests", ] +markers = [ + "requires_capability: marks tests requiring specific backend capabilities", + "requires_protocol: marks tests requiring specific backend protocol support", +] [tool.coverage.run] source = ["rhosocial"] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py index 317403e1..257f0ed5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py @@ -23,7 +23,7 @@ from .transaction import PostgresTransactionManager, AsyncPostgresTransactionManager from .types import PostgresEnumType from .explain import PostgresExplainResult, PostgresExplainPlanLine -from .statements import ( +from .expression.ddl import ( CreateEnumTypeExpression, DropEnumTypeExpression, AlterEnumTypeAddValueExpression, diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 31bcd428..eb18f85e 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -6,7 +6,7 @@ based on the PostgreSQL version provided at initialization. """ -from typing import Any, Tuple, Optional, TYPE_CHECKING +from typing import Any, Dict, Tuple, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.base import SQLDialectBase from rhosocial.activerecord.backend.dialect.mixins import ( @@ -67,6 +67,7 @@ IntrospectionSupport, TransactionControlSupport, ConstraintSupport, + SQLFunctionSupport, ) from .mixins import ( PostgresExtensionMixin, @@ -103,6 +104,7 @@ PostgresExtendedStatisticsMixin, PostgresStoredProcedureMixin, PostgresAdvisoryLockMixin, + PostgresLockingMixin, # Introspection capability mixin PostgresIntrospectionCapabilityMixin, ) @@ -136,13 +138,14 @@ PostgresTypeSupport, PostgresConstraintSupport, # Type feature protocols - MultirangeSupport, - EnumTypeSupport, + PostgresMultirangeSupport, + PostgresEnumTypeSupport, # New feature protocols PostgresParallelQuerySupport, PostgresStoredProcedureSupport, PostgresExtendedStatisticsSupport, PostgresAdvisoryLockSupport, + PostgresLockingSupport, ) if TYPE_CHECKING: @@ -155,6 +158,8 @@ DropMaterializedViewExpression, RefreshMaterializedViewExpression, ExplainExpression, + AddTableConstraint, + TableConstraint, ) from rhosocial.activerecord.backend.expression.transaction import ( BeginTransactionExpression, @@ -180,6 +185,7 @@ class PostgresDialect( ArrayMixin, ExplainMixin, GraphMixin, + PostgresLockingMixin, LockingMixin, MergeMixin, OrderedSetAggregationMixin, @@ -291,13 +297,16 @@ class PostgresDialect( PostgresTypeSupport, PostgresConstraintSupport, # Type feature protocols - MultirangeSupport, - EnumTypeSupport, + PostgresMultirangeSupport, + PostgresEnumTypeSupport, # New feature protocols PostgresParallelQuerySupport, PostgresStoredProcedureSupport, PostgresExtendedStatisticsSupport, PostgresAdvisoryLockSupport, + PostgresLockingSupport, + # Function support protocol + SQLFunctionSupport, ): """ PostgreSQL dialect implementation that adapts to the PostgreSQL version. @@ -469,22 +478,6 @@ def supports_for_update(self) -> bool: """ return True - def supports_for_update_skip_locked(self) -> bool: - """Whether FOR UPDATE SKIP LOCKED is supported.""" - return self.version >= (9, 5, 0) # Supported since 9.5 - - def supports_for_share(self) -> bool: - """Whether FOR SHARE lock strength is supported (PostgreSQL 9.0+).""" - return self.version >= (9, 0, 0) - - def supports_for_no_key_update(self) -> bool: - """Whether FOR NO KEY UPDATE lock strength is supported (PostgreSQL 9.0+).""" - return self.version >= (9, 0, 0) - - def supports_for_key_share(self) -> bool: - """Whether FOR KEY SHARE lock strength is supported (PostgreSQL 9.3+).""" - return self.version >= (9, 3, 0) - def supports_lock_strength(self, strength) -> bool: """ Check if a specific lock strength is supported. @@ -683,6 +676,65 @@ def format_identifier(self, identifier: str) -> str: escaped = identifier.replace('"', '""') return f'"{escaped}"' + def format_on_conflict_clause(self, expr) -> Tuple[str, tuple]: + """Format ON CONFLICT clause for PostgreSQL. + + 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. + """ + from rhosocial.activerecord.backend.expression import bases + from rhosocial.activerecord.backend.expression.core import Column + + all_params = [] + parts = ["ON CONFLICT"] + + # Add conflict target if specified + if expr.conflict_target: + target_parts = [] + for target in expr.conflict_target: + if isinstance(target, str): + target_parts.append(self.format_identifier(target)) + elif hasattr(target, 'to_sql'): + target_sql, target_params = target.to_sql() + target_parts.append(target_sql) + all_params.extend(target_params) + else: + target_parts.append(self.format_identifier(str(target))) + if target_parts: + parts.append(f"({', '.join(target_parts)})") + + # Add DO NOTHING or DO UPDATE + if expr.do_nothing: + parts.append("DO NOTHING") + elif expr.update_assignments: + update_parts = [] + for col, expr_val in expr.update_assignments.items(): + if isinstance(expr_val, Column) and getattr(expr_val, 'table', None) == 'EXCLUDED': + # EXCLUDED is a special pseudo-table in PostgreSQL ON CONFLICT. + # It must NOT be double-quoted, only the column name should be quoted. + val_sql = f'EXCLUDED.{self.format_identifier(expr_val.name)}' + update_parts.append(f"{self.format_identifier(col)} = {val_sql}") + elif isinstance(expr_val, bases.BaseExpression): + val_sql, val_params = expr_val.to_sql() + update_parts.append(f"{self.format_identifier(col)} = {val_sql}") + all_params.extend(val_params) + else: + update_parts.append(f"{self.format_identifier(col)} = {self.get_parameter_placeholder()}") + all_params.append(expr_val) + + parts.append(f"DO UPDATE SET {', '.join(update_parts)}") + + # Add WHERE clause if specified + if expr.update_where: + where_sql, where_params = expr.update_where.to_sql() + parts.append(f"WHERE {where_sql}") + all_params.extend(where_params) + else: + parts.append("DO NOTHING") + + return " ".join(parts), tuple(all_params) + def supports_jsonb(self) -> bool: """Check if PostgreSQL version supports JSONB type (introduced in 9.4).""" return self.version >= (9, 4, 0) @@ -1231,6 +1283,257 @@ def supports_drop_trigger(self) -> bool: # endregion # region Transaction Control Support + + # PostgreSQL function version support: function_name -> (min_version, max_version) + # min_version: minimum supported version (inclusive), None = all versions + # max_version: maximum supported version (inclusive), None = no upper limit + # Reference: https://www.postgresql.org/docs/current/functions.html + # Note: Most PostgreSQL functions have been available since early versions, + # only a few newer functions have version requirements. + _POSTGRES_FUNCTION_VERSIONS = { + # JSON path functions (JSONPath): PostgreSQL 12+ + "jsonb_path_query": ((12, 0, 0), None), + "jsonb_path_query_first": ((12, 0, 0), None), + "jsonb_path_exists": ((12, 0, 0), None), + "jsonb_path_match": ((12, 0, 0), None), + "json_path_root": ((12, 0, 0), None), + "json_path_key": ((12, 0, 0), None), + "json_path_index": ((12, 0, 0), None), + "json_path_wildcard": ((12, 0, 0), None), + "json_path_filter": ((12, 0, 0), None), + # Range functions: PostgreSQL 9.2+ (range types introduced) + "range_contains": ((9, 2, 0), None), + "range_contained_by": ((9, 2, 0), None), + "range_contains_range": ((9, 2, 0), None), + "range_overlaps": ((9, 2, 0), None), + "range_adjacent": ((9, 2, 0), None), + "range_strictly_left_of": ((9, 2, 0), None), + "range_strictly_right_of": ((9, 2, 0), None), + "range_not_extend_right": ((9, 2, 0), None), + "range_not_extend_left": ((9, 2, 0), None), + "range_union": ((9, 2, 0), None), + "range_intersection": ((9, 2, 0), None), + "range_difference": ((9, 2, 0), None), + "range_lower": ((9, 2, 0), None), + "range_upper": ((9, 2, 0), None), + "range_is_empty": ((9, 2, 0), None), + "range_lower_inc": ((9, 2, 0), None), + "range_upper_inc": ((9, 2, 0), None), + "range_lower_inf": ((9, 2, 0), None), + "range_upper_inf": ((9, 2, 0), None), + # Geometric functions: All versions + "geometry_distance": (None, None), + "geometry_contains": (None, None), + "geometry_contained_by": (None, None), + "geometry_overlaps": (None, None), + "geometry_strictly_left": (None, None), + "geometry_strictly_right": (None, None), + "geometry_not_extend_right": (None, None), + "geometry_not_extend_left": (None, None), + "geometry_area": (None, None), + "geometry_center": (None, None), + "geometry_length": (None, None), + "geometry_width": (None, None), + "geometry_height": (None, None), + "geometry_npoints": (None, None), + # Enum functions: PostgreSQL 8.3+ + "enum_range": ((8, 3, 0), None), + "enum_first": ((8, 3, 0), None), + "enum_last": ((8, 3, 0), None), + "enum_lt": ((8, 3, 0), None), + "enum_le": ((8, 3, 0), None), + "enum_gt": ((8, 3, 0), None), + "enum_ge": ((8, 3, 0), None), + # Bit string functions: All versions + "bit_concat": (None, None), + "bit_and": (None, None), + "bit_or": (None, None), + "bit_xor": (None, None), + "bit_not": (None, None), + "bit_shift_left": (None, None), + "bit_shift_right": (None, None), + "bit_length": (None, None), + "bit_length_func": (None, None), + "bit_octet_length": (None, None), + "bit_get_bit": (None, None), + "bit_set_bit": (None, None), + "bit_count": ((9, 5, 0), None), # Added in 9.5 + # Text search functions: PostgreSQL 8.3+ + "to_tsvector": ((8, 3, 0), None), + "to_tsquery": ((8, 3, 0), None), + "plainto_tsquery": ((8, 3, 0), None), + "phraseto_tsquery": ((9, 6, 0), None), # Added in 9.6 + "websearch_to_tsquery": ((11, 0, 0), None), # Added in 11 + "ts_matches": ((8, 3, 0), None), + "ts_matches_expr": ((8, 3, 0), None), + "ts_rank": ((8, 3, 0), None), + "ts_rank_cd": ((8, 5, 0), None), # Added in 8.5 + "ts_headline": ((8, 3, 0), None), + "tsvector_concat": ((8, 3, 0), None), + "tsvector_strip": ((8, 3, 0), None), + "tsvector_setweight": ((8, 3, 0), None), + "tsvector_length": ((8, 3, 0), None), + # XML functions: PostgreSQL 8.3+ + "xmlparse": ((8, 3, 0), None), + "xpath_query": ((8, 3, 0), None), + "xpath_exists": ((8, 3, 0), None), + "xml_is_well_formed": ((9, 1, 0), None), # Added in 9.1 + # Math enhanced functions: All versions + "round_": (None, None), + "pow": (None, None), + "power": (None, None), + "sqrt": (None, None), + "mod": (None, None), + "ceil": (None, None), + "floor": (None, None), + "trunc": (None, None), + "max_": (None, None), + "min_": (None, None), + "avg": (None, None), + # Array functions: All versions (arrays supported since early versions) + "array_agg": (None, None), + "array_append": (None, None), + "array_cat": (None, None), + "array_dims": (None, None), + "array_fill": ((8, 4, 0), None), # Added in 8.4 + "array_length": (None, None), + "array_lower": (None, None), + "array_ndims": ((8, 4, 0), None), # Added in 8.4 + "array_position": ((9, 5, 0), None), # Added in 9.5 + "array_positions": ((9, 5, 0), None), # Added in 9.5 + "array_prepend": (None, None), + "array_remove": (None, None), + "array_replace": ((8, 3, 0), None), # Added in 8.3 + "array_to_string": (None, None), + "array_upper": (None, None), + "unnest": ((8, 4, 0), None), # Added in 8.4 + "array_agg_distinct": (None, None), + "string_to_array": (None, None), + # Network address functions: PostgreSQL 8.3+ + "inet_client_addr": ((8, 3, 0), None), + "inet_client_port": ((8, 3, 0), None), + "inet_server_addr": ((8, 3, 0), None), + "inet_server_port": ((8, 3, 0), None), + "inet_merge": ((9, 5, 0), None), # Added in 9.5 + "inet_and": ((8, 3, 0), None), + "inet_or": ((8, 3, 0), None), + "inetnot": ((8, 3, 0), None), + "inet_set_mask": ((8, 3, 0), None), + "inet_masklen": ((8, 3, 0), None), + "inet_netmask": ((8, 3, 0), None), + "inet_network": ((8, 3, 0), None), + "inet_recv": ((8, 3, 0), None), + "inet_show": ((8, 3, 0), None), + "cidr_netmask": ((8, 3, 0), None), + "macaddr8_set7bit": ((10, 0, 0), None), # Added in 10 + # UUID functions: uuid-ossp extension (varies by version) + # These require the uuid-ossp extension which is bundled + "uuid_generate_v1": (None, None), # Extension function + "uuid_generate_v1mc": (None, None), + "uuid_generate_v3": (None, None), + "uuid_generate_v4": (None, None), + "uuid_generate_v5": (None, None), + "uuid_nil": (None, None), + "uuid_max": (None, None), + # hstore functions: hstore extension + # Available since 8.3 with hstore extension + "hstore_from_record": ((8, 3, 0), None), + "hstore_from_key_value": ((8, 3, 0), None), + "hstore_akeys": ((8, 3, 0), None), + "hstore_skeys": ((8, 3, 0), None), + "hstore_avals": ((8, 3, 0), None), + "hstore_svals": ((8, 3, 0), None), + "hstore_each": ((8, 3, 0), None), + "hstore_to_array": ((8, 3, 0), None), + "hstore_to_matrix": ((8, 3, 0), None), + "hstore_to_json": ((9, 3, 0), None), # Added in 9.3 + "hstore_to_jsonb": ((9, 4, 0), None), # JSONB added in 9.4 + "hstore_to_json_loose": ((9, 3, 0), None), + "hstore_to_jsonb_loose": ((9, 4, 0), None), + "hstore_slice": ((8, 3, 0), None), + "hstore_exist": ((8, 3, 0), None), + "hstore_defined": ((8, 3, 0), None), + "hstore_delete": ((8, 3, 0), None), + "hstore_delete_keys": ((8, 3, 0), None), + "hstore_delete_pairs": ((8, 3, 0), None), + "hstore_populate_record": ((8, 3, 0), None), + "hstore_get_value": ((8, 3, 0), None), + "hstore_get_values": ((8, 3, 0), None), + "hstore_concat": ((8, 3, 0), None), + "hstore_key_exists": ((8, 3, 0), None), + "hstore_all_keys_exist": ((8, 3, 0), None), + "hstore_any_key_exists": ((8, 3, 0), None), + "hstore_contains": ((8, 3, 0), None), + "hstore_contained_by": ((8, 3, 0), None), + "hstore_subtract_key": ((8, 3, 0), None), + "hstore_subtract_keys": ((8, 3, 0), None), + "hstore_subtract_pairs": ((8, 3, 0), None), + "hstore_to_array_operator": ((8, 3, 0), None), + "hstore_to_matrix_operator": ((8, 3, 0), None), + "hstore_record_update": ((8, 3, 0), None), + "hstore_subscript_get": ((9, 0, 0), None), # Added in 9.0 + "hstore_subscript_set": ((9, 0, 0), None), # Added in 9.0 + # Range constructors: PostgreSQL 9.2+ + "int4range": ((9, 2, 0), None), + "int8range": ((9, 2, 0), None), + "numrange": ((9, 2, 0), None), + "tsrange": ((9, 2, 0), None), + "tstzrange": ((9, 2, 0), None), + "daterange": ((9, 2, 0), None), + } + + def supports_functions(self) -> Dict[str, bool]: + """Return supported SQL functions as function_name -> bool mapping. + + This method combines: + 1. Core functions from rhosocial.activerecord.backend.expression.functions + 2. PostgreSQL-specific functions from rhosocial.activerecord.backend.impl.postgres.functions + + PostgreSQL version-specific functions: + - JSONPath functions: PostgreSQL 12+ + + Returns: + Dict mapping function names to True (supported) or False. + """ + from rhosocial.activerecord.backend.expression.functions import ( + __all__ as core_functions, + ) + from rhosocial.activerecord.backend.impl.postgres import functions as postgres_functions + + result = {} + for func_name in core_functions: + result[func_name] = self._is_postgres_function_supported(func_name) + + postgres_funcs = getattr(postgres_functions, "__all__", []) + for func_name in postgres_funcs: + if func_name not in result: + result[func_name] = self._is_postgres_function_supported(func_name) + + return result + + def _is_postgres_function_supported(self, func_name: str) -> bool: + """Check if a PostgreSQL-specific function is supported based on version. + + Args: + func_name: Name of the PostgreSQL function + + Returns: + True if supported, False otherwise + """ + version_range = self._POSTGRES_FUNCTION_VERSIONS.get(func_name) + if version_range is None: + return True + + min_version, max_version = version_range + + if min_version is not None and self.version < min_version: + return False + + if max_version is not None and self.version > max_version: + return False + + return True + def supports_transaction_mode(self) -> bool: """PostgreSQL supports READ ONLY and READ WRITE transaction modes.""" return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/README.md b/src/rhosocial/activerecord/backend/impl/postgres/examples/README.md new file mode 100644 index 00000000..f99837fe --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/README.md @@ -0,0 +1,109 @@ +# PostgreSQL Backend Examples + +This directory contains example code demonstrating how to use the rhosocial-activerecord expressions with the PostgreSQL backend. + +## Directory Structure + +``` +examples/ +├── README.md # This file +├── conftest.py # Example metadata configuration +├── ddl/ # Data Definition Language examples +│ ├── __init__.py +│ ├── create_index.py # Create index on existing table +│ └── alter_table.py # Alter table structure +├── insert/ # INSERT operation examples +│ ├── __init__.py +│ └── batch.py # Batch insert multiple rows +├── query/ # Query (SELECT) examples +│ ├── __init__.py +│ ├── basic.py # Basic SELECT with WHERE, ORDER BY, LIMIT +│ ├── join.py # JOIN multiple tables +│ ├── aggregate.py # GROUP BY and HAVING +│ ├── subquery.py # Subquery in WHERE clause +│ ├── window.py # Window functions (ROW_NUMBER, etc.) +│ ├── text_search.py # Full-text search (tsvector, tsquery) +│ └── array_func.py # Array functions +└── types/ # Type-related examples + ├── __init__.py + ├── json_basic.py # JSONB operations + └── array_basic.py # Array type operations +``` + +## Example File Format + +Each example file follows this structure: + +```python +""" +[Title and description of what this example demonstrates.] +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +# Database connection setup code +# Don't copy this when learning the pattern + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +# Core code demonstrating the expression usage +# Copy this part when applying to your project + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +# Execute the expression against the backend +# This can be included or omitted depending on needs + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +# Cleanup code +# Don't copy this when learning the pattern +``` + +## Key Principles + +1. **Self-contained**: Each example file can be executed independently +2. **Clear sections**: Setup/Teardown are clearly marked as reference only +3. **Pure business logic**: The core expression usage is clean and copyable +4. **No external dependencies**: All setup is within the file itself +5. **Expression-based**: All SQL operations use expression classes, no raw SQL strings + +## Environment Variables + +The examples use safe defaults for local development: +- `PG_HOST`: PostgreSQL server host (default: localhost) +- `PG_PORT`: PostgreSQL server port (default: 5432) +- `PG_DATABASE`: Database name (default: test) +- `PG_USERNAME`: Username (default: postgres) +- `PG_PASSWORD`: Password (default: empty) + +## Running Examples + +```bash +# Run a specific example +python -m rhosocial.activerecord.backend.impl.postgres.examples.query.basic + +# Run from the examples directory +cd examples +python -m query.basic +``` + +## For LLM Context + +When using these examples as reference: +- Focus on the **SECTION: Business Logic** portion +- The Setup and Teardown sections are boilerplate for execution only +- Copy the business logic pattern to your own project +- All SQL operations use expression classes (CreateTableExpression, InsertExpression, QueryExpression, etc.) + +## PostgreSQL-Specific Features + +These examples demonstrate PostgreSQL-specific features: +- Full-text search using tsvector and tsquery +- Array types and array functions +- JSONB operations with jsonb_path_query +- Window functions diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/conftest.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/conftest.py new file mode 100644 index 00000000..34f78672 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/conftest.py @@ -0,0 +1,286 @@ +# src/rhosocial/activerecord/backend/impl/postgres/examples/conftest.py +""" +Example metadata configuration. + +This file defines metadata for all examples in this directory. +The inspector reads this file to get title, dialect_protocols, and priority. + +PostgreSQL Version Support: +- Minimum: 9 +- Maximum: 18 + +Version-specific features: +- Window Functions: PostgreSQL 8.4+ +- JSONB data type: PostgreSQL 9.4+ +- Array type: PostgreSQL 8.4+ (arrays existed before 8.4 but more limited) +- Full-Text Search (tsvector): PostgreSQL 8.3+ +- CTE (WITH clause): PostgreSQL 8.4+ +- Row-level security: PostgreSQL 9.5+ +- JSON/JSONB operators: PostgreSQL 9.4+ +- Materialized views: PostgreSQL 9.3+ +- Range types: PostgreSQL 9.2+ +- Hereditary tables: PostgreSQL 10+ +- Identity columns: PostgreSQL 10+ (SERIAL is available since 8.2) +- Parallel queries: PostgreSQL 11+ +- JSON path: PostgreSQL 12+ +""" + +EXAMPLES_META = { + 'connection/quickstart.py': { + 'title': 'Connect to PostgreSQL and Execute Queries', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'ddl/create_table.py': { + 'title': 'Create Table', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'ddl/create_index.py': { + 'title': 'Create Index', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'ddl/alter_table.py': { + 'title': 'Alter Table', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'ddl/drop_table.py': { + 'title': 'DROP TABLE', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'ddl/view.py': { + 'title': 'CREATE VIEW', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'ddl/materialized_view.py': { + 'title': 'CREATE MATERIALIZED VIEW', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + 'note': 'Materialized views available since PostgreSQL 9.3+', + }, + 'ddl/unique_index.py': { + 'title': 'CREATE UNIQUE INDEX', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'ddl/partition.py': { + 'title': 'Table Partitioning', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '10', + 'max_version': '18', + 'note': 'Declarative partitioning available since PostgreSQL 10+', + }, +'insert/batch.py': { + 'title': 'Batch Insert', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'insert/single.py': { + 'title': 'Single Row Insert', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'insert/with_returning.py': { + 'title': 'INSERT with RETURNING Clause', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'insert/upsert.py': { + 'title': 'UPSERT (INSERT ON CONFLICT)', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9.5', + 'max_version': '18', + 'note': 'ON CONFLICT requires PostgreSQL 9.5+', + }, + 'delete/basic.py': { + 'title': 'DELETE with RETURNING Clause', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'update/basic.py': { + 'title': 'UPDATE using UpdateExpression', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'query/basic.py': { + 'title': 'Basic SELECT Query', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'query/join.py': { + 'title': 'JOIN Query', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'query/aggregate.py': { + 'title': 'Aggregate Query', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'query/distinct.py': { + 'title': 'SELECT DISTINCT', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'query/union.py': { + 'title': 'UNION using SetOperationExpression', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'query/subquery.py': { + 'title': 'Subquery', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'query/window.py': { + 'title': 'Window Functions', + 'dialect_protocols': ['WindowFunctionSupport'], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'query/text_search.py': { + 'title': 'Full-Text Search', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'query/array_func.py': { + 'title': 'Array Functions', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'types/json_basic.py': { + 'title': 'JSONB Operations', + 'dialect_protocols': ['JSONSupport'], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + 'note': 'JSON type is available since PostgreSQL 9.4+', + }, + 'types/array_postgres84.py': { + 'title': 'Array Type Operations (PostgreSQL 8.4+)', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'types/uuid.py': { + 'title': 'Native UUID Type', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + 'note': 'gen_random_uuid() built-in since PostgreSQL 13+, uuid-ossp extension for earlier versions', + }, + 'query/predicate.py': { + 'title': 'Complex Predicates', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'query/pagination.py': { + 'title': 'Pagination with LIMIT/OFFSET', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'query/cte.py': { + 'title': 'CTE (Common Table Expressions)', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'query/window_functions.py': { + 'title': 'Window Functions (Advanced)', + 'dialect_protocols': ['WindowFunctionSupport'], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'query/constants.py': { + 'title': 'Query Runtime Constants and Niladic Functions', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'query/explain.py': { + 'title': 'EXPLAIN Query Plan', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'transaction/basic.py': { + 'title': 'Transaction Control', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'transaction/for_update.py': { + 'title': 'FOR UPDATE Row Locking', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'transaction/exclusive.py': { + 'title': 'Transaction Isolation Levels', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, +} diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/connection/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/connection/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/connection/quickstart.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/connection/quickstart.py new file mode 100644 index 00000000..a8dcfbda --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/connection/quickstart.py @@ -0,0 +1,249 @@ +""" +Quick Start: Connect to PostgreSQL and execute queries. + +This example demonstrates: +1. How to establish a connection to PostgreSQL +2. How to view connection information +3. How to execute expression-based queries +4. How to access query results and handle transactions +""" + +# ============================================================ +# SECTION: Connection Setup +# ============================================================ +import os + +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + DropTableExpression, + InsertExpression, + QueryExpression, + TableExpression, + ValuesSource, + WhereClause, +) +from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ColumnConstraintType, + ColumnDefinition, +) +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +# Create connection configuration +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) + +# Create backend instance and connect +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +dql_options = ExecutionOptions(stmt_type=StatementType.DQL) +ddl_options = ExecutionOptions(stmt_type=StatementType.DDL) + + +def execute_expression(expression, options=None): + sql, params = expression.to_sql() + print(f"SQL: {sql}") + print(f"Params: {params}") + return backend.execute(sql, params, options=options) + + +def create_demo_tables(): + users_table = CreateTableExpression( + dialect=dialect, + table_name='quickstart_users', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ], + ), + ColumnDefinition( + 'name', + 'VARCHAR(100)', + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ), + ColumnDefinition('status', 'VARCHAR(20)'), + ], + if_not_exists=True, + ) + execute_expression(users_table, ddl_options) + + logs_table = CreateTableExpression( + dialect=dialect, + table_name='quickstart_logs', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ], + ), + ColumnDefinition( + 'message', + 'VARCHAR(255)', + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ), + ], + if_not_exists=True, + ) + execute_expression(logs_table, ddl_options) + + +def seed_demo_data(): + insert_users = InsertExpression( + dialect=dialect, + into='quickstart_users', + columns=['name', 'status'], + source=ValuesSource( + dialect, + [ + [Literal(dialect, 'Alice'), Literal(dialect, 'active')], + [Literal(dialect, 'Bob'), Literal(dialect, 'inactive')], + ], + ), + ) + execute_expression(insert_users) + + +create_demo_tables() +seed_demo_data() + +# ============================================================ +# SECTION: View Connection Information +# ============================================================ +print(f"Connected to: {config.host}:{config.port}") +print(f"Database: {config.database}") +print(f"User: {config.username}") + +# ============================================================ +# SECTION: Execute Queries +# ============================================================ +query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id'), + Column(dialect, 'name'), + Column(dialect, 'status'), + ], + from_=TableExpression(dialect, 'quickstart_users'), + where=WhereClause( + dialect, + condition=ComparisonPredicate( + dialect, + '=', + Column(dialect, 'status'), + Literal(dialect, 'active'), + ), + ), +) +result = execute_expression(query, dql_options) + +# ============================================================ +# SECTION: Access Query Results +# ============================================================ +print(f"Result data: {result.data}") +print(f"Rows: {result.affected_rows}") +print(f"Duration: {result.duration:.3f}s") + +if result.data: + row = result.data[0] + print(f"Value from first row: {row['name']}") + +# ============================================================ +# SECTION: Execute Parameterized Queries (Recommended) +# ============================================================ +filtered_query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id'), + Column(dialect, 'name'), + Column(dialect, 'status'), + ], + from_=TableExpression(dialect, 'quickstart_users'), + where=WhereClause( + dialect, + condition=ComparisonPredicate( + dialect, + '=', + Column(dialect, 'id'), + Literal(dialect, 1), + ) & ComparisonPredicate( + dialect, + '=', + Column(dialect, 'status'), + Literal(dialect, 'active'), + ), + ), +) +result = execute_expression(filtered_query, dql_options) +print(f"Parameterized query result: {result.data}") + +# ============================================================ +# SECTION: Handle Transactions +# ============================================================ +with backend.transaction(): + insert_log = InsertExpression( + dialect=dialect, + into='quickstart_logs', + columns=['message'], + source=ValuesSource(dialect, [[Literal(dialect, 'quickstart transaction')]]), + ) + execute_expression(insert_log) + +logs_query = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'id'), Column(dialect, 'message')], + from_=TableExpression(dialect, 'quickstart_logs'), +) +result = execute_expression(logs_query, dql_options) +print(f"Transaction result: {result.data}") + +# ============================================================ +# SECTION: Error Handling +# ============================================================ +try: + invalid_query = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'id')], + from_=TableExpression(dialect, 'nonexistent_table'), + ) + execute_expression(invalid_query, dql_options) +except Exception as error: + print(f"Error: {error}") + +# ============================================================ +# SECTION: Disconnect +# ============================================================ +drop_logs = DropTableExpression(dialect=dialect, table_name='quickstart_logs', if_exists=True) +execute_expression(drop_logs, ddl_options) + +drop_users = DropTableExpression(dialect=dialect, table_name='quickstart_users', if_exists=True) +execute_expression(drop_users, ddl_options) + +backend.disconnect() + +# ============================================================ +# SECTION: Summary +# ============================================================ +# Key points: +# 1. Create PostgresConnectionConfig with connection parameters +# 2. Create PostgresBackend with the config and call backend.connect() +# 3. Use expressions to create schema, insert data, and query data +# 4. Use backend.transaction() for transaction control +# 5. Access QueryResult.data for returned rows +# 6. Disconnect and clean up when done diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/__init__.py new file mode 100644 index 00000000..e69de29b 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 new file mode 100644 index 00000000..8199c5c4 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py @@ -0,0 +1,162 @@ +""" +Alter table statements - ADD COLUMN, ALTER COLUMN. + +Note: PostgreSQL supports multiple actions in a single ALTER TABLE statement. +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +drop_table = DropTableExpression( + dialect=dialect, + table_name='users', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='users', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition('name', 'VARCHAR(100)'), + ], +) +backend.execute(*create_table.to_sql()) + +insert_expr = InsertExpression( + dialect=dialect, + into='users', + columns=['name'], + source=ValuesSource( + dialect, + [[Literal(dialect, 'Alice')]], + ), +) +backend.execute(*insert_expr.to_sql()) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.expression import ( + AlterTableExpression, + ColumnDefinition, +) +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AddColumn, + RenameColumn, +) + +# Add a new column +add_email_action = AddColumn( + column=ColumnDefinition( + name='email', + data_type='VARCHAR(100)', + ), +) + +add_email_expr = AlterTableExpression( + dialect=dialect, + table_name='users', + actions=[add_email_action], +) + +sql, params = add_email_expr.to_sql() +print(f"SQL (Add Column): {sql}") +print(f"Params: {params}") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +backend.execute(sql, params) +print("Column added successfully") + +# Add another column without default (PostgreSQL ALTER TABLE doesn't support parameterized DEFAULT well) +add_age_action = AddColumn( + column=ColumnDefinition( + 'age', + 'INT', + ), +) + +add_age_expr = AlterTableExpression( + dialect=dialect, + table_name='users', + actions=[add_age_action], +) + +sql, params = add_age_expr.to_sql() +backend.execute(sql, params) +print("Column age added successfully") + +# Rename a column +rename_action = RenameColumn( + old_name='name', + new_name='full_name', +) + +rename_expr = AlterTableExpression( + dialect=dialect, + table_name='users', + actions=[rename_action], +) + +sql, params = rename_expr.to_sql() +print(f"SQL (Rename Column): {sql}") +backend.execute(sql, params) +print("Column renamed successfully") + +# Verify table structure +options = ExecutionOptions(stmt_type=StatementType.DQL) +result = backend.execute( + "SELECT column_name, data_type, is_nullable " + "FROM information_schema.columns " + "WHERE table_name = 'users' ORDER BY ordinal_position", + options=options +) +print("Table structure after alterations:") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/create_index.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/create_index.py new file mode 100644 index 00000000..6ea5c796 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/create_index.py @@ -0,0 +1,96 @@ +""" +Create index statement. +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + DropTableExpression, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +drop_table = DropTableExpression( + dialect=dialect, + table_name='products', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='products', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition('name', 'VARCHAR(100)'), + ColumnDefinition('category', 'VARCHAR(50)'), + ColumnDefinition('price', 'DECIMAL(10,2)'), + ], +) +backend.execute(*create_table.to_sql()) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.expression import CreateIndexExpression + +create_idx = CreateIndexExpression( + dialect=dialect, + index_name='idx_category_price', + table_name='products', + columns=['category', 'price'], + if_not_exists=True, +) + +sql, params = create_idx.to_sql() +print(f"SQL: {sql}") +print(f"Params: {params}") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +result = backend.execute(sql, params) +print("Index created: idx_category_price") + +# Verify index creation +options = ExecutionOptions(stmt_type=StatementType.DQL) +verify_result = backend.execute( + "SELECT indexname FROM pg_indexes WHERE indexname = 'idx_category_price'", + options=options +) +print(f"Index info: {verify_result.data}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/create_table.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/create_table.py new file mode 100644 index 00000000..24ada37c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/create_table.py @@ -0,0 +1,140 @@ +""" +Create a table with primary key, SERIAL, and constraints - PostgreSQL. + +This example demonstrates: +1. CREATE TABLE with SERIAL auto-increment primary key +2. Various column types (VARCHAR, INTEGER, TIMESTAMP, BOOLEAN) +3. UNIQUE and NOT NULL constraints +4. DEFAULT values including CURRENT_TIMESTAMP +5. Verify table structure using introspector +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + DropTableExpression, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.expression.functions.datetime import current_timestamp +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + IndexDefinition, +) + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +# Drop if exists for clean setup +drop = DropTableExpression(dialect=dialect, table_name='products', if_exists=True) +sql, params = drop.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +columns = [ + ColumnDefinition( + name='id', + data_type='SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ], + ), + ColumnDefinition( + name='name', + data_type='VARCHAR(200)', + constraints=[ + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition( + name='price', + data_type='NUMERIC(10,2)', + constraints=[ + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition( + name='category', + data_type='VARCHAR(100)', + ), + ColumnDefinition( + name='is_active', + data_type='BOOLEAN', + constraints=[ + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=True), + ], + ), + ColumnDefinition( + name='created_at', + data_type='TIMESTAMP', + constraints=[ + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=current_timestamp(dialect)), + ], + ), +] + +indexes = [ + IndexDefinition( + name='idx_products_category', + columns=['category'], + ), +] + +create_expr = CreateTableExpression( + dialect=dialect, + table_name='products', + columns=columns, + indexes=indexes, + if_not_exists=True, +) + +sql, params = create_expr.to_sql() +print(f"SQL: {sql}") +print(f"Params: {params}") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +result = backend.execute(sql, params) +print("Table created: products") + +# Verify table structure using introspector +columns_info = backend.introspector.list_columns('products') +print("Columns in 'products':") +for col in columns_info: + print(f" {col}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +drop_table = DropTableExpression(dialect=dialect, table_name='products', if_exists=True) +sql, params = drop_table.to_sql() +backend.execute(sql, params) +backend.disconnect() + +# ============================================================ +# SECTION: Summary +# ============================================================ +# Key points: +# 1. PostgreSQL uses SERIAL for auto-increment (not AUTO_INCREMENT) +# 2. NUMERIC(p,s) for precise decimal columns +# 3. BOOLEAN type for true/false values +# 4. DEFAULT values use PostgreSQL syntax (TRUE, CURRENT_TIMESTAMP) +# 5. Use introspector.list_columns() to verify table structure diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/drop_table.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/drop_table.py new file mode 100644 index 00000000..8874239d --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/drop_table.py @@ -0,0 +1,75 @@ +""" +DROP TABLE - PostgreSQL. + +This example demonstrates: +1. DROP TABLE +2. DROP TABLE IF EXISTS +""" + +# ============================================================ +# SECTION: Setup +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', 5432)), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, +) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='users', + columns=[ + ColumnDefinition('id', 'INT'), + ], + if_not_exists=True, +) +sql, params = create_table.to_sql() +print(f"Create table SQL: {sql}") +backend.execute(sql, params) + +# ============================================================ +# SECTION: DROP TABLE +# ============================================================ +from rhosocial.activerecord.backend.expression import DropTableExpression + +drop_expr = DropTableExpression( + dialect=dialect, + table_name='users', +) +sql, params = drop_expr.to_sql() +print(f"DROP TABLE SQL: {sql}") +print(f"Params: {params}") +backend.execute(sql, params) + +# Already deleted +drop_expr_exists = DropTableExpression( + dialect=dialect, + table_name='users', + if_exists=True, +) +sql, params = drop_expr_exists.to_sql() +print(f"DROP TABLE IF EXISTS SQL: {sql}") +backend.execute(sql, params) + +# ============================================================ +# SECTION: Summary +# ============================================================ +backend.disconnect() + +# Key points: +# 1. Use DropTableExpression to drop tables +# 2. if_exists=True prevents error \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py new file mode 100644 index 00000000..ca3f4438 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py @@ -0,0 +1,264 @@ +""" +Materialized Views - PostgreSQL 9.3+. + +This example demonstrates: +1. Creating a materialized view +2. Refreshing materialized views +3. Using WITH NO DATA for fast definition +4. Materialized view with aggregations +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, + QueryExpression, + TableExpression, + CreateMaterializedViewExpression, + DropMaterializedViewExpression, + RefreshMaterializedViewExpression, + GroupByHavingClause, + FunctionCall, +) +from rhosocial.activerecord.backend.expression.core import Literal, Column, WildcardExpression +from rhosocial.activerecord.backend.expression.query_parts import OrderByClause +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', 5432)), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +dql_options = ExecutionOptions(stmt_type=StatementType.DQL) + +drop_table = DropTableExpression( + dialect=dialect, + table_name='sales', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='sales', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition('product_id', 'INT', constraints=[ + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ]), + ColumnDefinition('amount', 'DECIMAL(10,2)', constraints=[ + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ]), + ColumnDefinition('sale_date', 'DATE', constraints=[ + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ]), + ], + if_not_exists=True, +) +sql, params = create_table.to_sql() +backend.execute(sql, params) + +insert_expr = InsertExpression( + dialect=dialect, + into='sales', + columns=['product_id', 'amount', 'sale_date'], + source=ValuesSource( + dialect, + [ + [Literal(dialect, '1'), Literal(dialect, '100.00'), Literal(dialect, "'2024-01-01'")], + [Literal(dialect, '1'), Literal(dialect, '150.00'), Literal(dialect, "'2024-01-02'")], + [Literal(dialect, '2'), Literal(dialect, '200.00'), Literal(dialect, "'2024-01-01'")], + [Literal(dialect, '2'), Literal(dialect, '250.00'), Literal(dialect, "'2024-01-03'")], + [Literal(dialect, '1'), Literal(dialect, '180.00'), Literal(dialect, "'2024-01-04'")], + ], + ), +) +sql, params = insert_expr.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Create Materialized View +# ============================================================ +# Materialized views store the query result physically + +# Build the summary query with aggregations +summary_query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'product_id'), + FunctionCall(dialect, 'COUNT', WildcardExpression(dialect)).as_('total_sales'), + FunctionCall(dialect, 'SUM', Column(dialect, 'amount')).as_('total_amount'), + FunctionCall(dialect, 'AVG', Column(dialect, 'amount')).as_('avg_amount'), + ], + from_=TableExpression(dialect, 'sales'), + group_by_having=GroupByHavingClause( + dialect, + group_by=[Column(dialect, 'product_id')], + ), +) + +create_mv = CreateMaterializedViewExpression( + dialect=dialect, + view_name='sales_summary', + query=summary_query, +) +sql, params = create_mv.to_sql() +print(f"Create MV SQL: {sql}") +backend.execute(sql, params) + +# Verify the materialized view content +verify_query = QueryExpression( + dialect=dialect, + select=[WildcardExpression(dialect)], + from_=TableExpression(dialect, 'sales_summary'), + order_by=OrderByClause(dialect, [Column(dialect, 'product_id')]), +) +sql, params = verify_query.to_sql() +result = backend.execute(sql, params, options=dql_options) +print("Materialized view result:") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: Refresh Materialized View +# ============================================================ +# Refresh to update the data + +# Insert new data +insert_new = InsertExpression( + dialect=dialect, + into='sales', + columns=['product_id', 'amount', 'sale_date'], + source=ValuesSource( + dialect, + [[Literal(dialect, '1'), Literal(dialect, '300.00'), Literal(dialect, "'2024-01-05'")]], + ), +) +sql, params = insert_new.to_sql() +backend.execute(sql, params) + +# Data in view is stale - refresh to see new data +refresh_mv = RefreshMaterializedViewExpression( + dialect=dialect, + view_name='sales_summary', +) +sql, params = refresh_mv.to_sql() +backend.execute(sql, params) + +# Verify after refresh +sql, params = verify_query.to_sql() +result = backend.execute(sql, params, options=dql_options) +print("After refresh:") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: Create with NO DATA +# ============================================================ +# Use WITH NO DATA to create without populating + +daily_query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'sale_date'), + FunctionCall(dialect, 'SUM', Column(dialect, 'amount')).as_('daily_total'), + ], + from_=TableExpression(dialect, 'sales'), + group_by_having=GroupByHavingClause( + dialect, + group_by=[Column(dialect, 'sale_date')], + ), +) + +create_mv_nodata = CreateMaterializedViewExpression( + dialect=dialect, + view_name='sales_daily', + query=daily_query, + with_data=False, +) +sql, params = create_mv_nodata.to_sql() +backend.execute(sql, params) + +# Verify WITH NO DATA - query should fail or return empty +nodata_query = QueryExpression( + dialect=dialect, + select=[WildcardExpression(dialect)], + from_=TableExpression(dialect, 'sales_daily'), +) +sql, params = nodata_query.to_sql() +try: + result = backend.execute(sql, params, options=dql_options) + print(f"WITH NO DATA result: {result.data}") +except Exception as e: + print(f"WITH NO DATA query error (expected): {e}") + +# ============================================================ +# SECTION: Drop Materialized View +# ============================================================ +drop_mv1 = DropMaterializedViewExpression( + dialect=dialect, + view_name='sales_summary', + if_exists=True, +) +sql, params = drop_mv1.to_sql() +backend.execute(sql, params) + +drop_mv2 = DropMaterializedViewExpression( + dialect=dialect, + view_name='sales_daily', + if_exists=True, +) +sql, params = drop_mv2.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +drop_table = DropTableExpression( + dialect=dialect, + table_name='sales', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) +backend.disconnect() + +# ============================================================ +# SECTION: Summary +# ============================================================ +# Key points: +# 1. Use CreateMaterializedViewExpression to create MVs +# 2. Use RefreshMaterializedViewExpression to update MV data +# 3. Use with_data=False for WITH NO DATA (lazy population) +# 4. Use DropMaterializedViewExpression to drop MVs +# 5. Requires PostgreSQL 9.3+ +# 6. Use concurrent=True for CONCURRENTLY refresh (PG 9.4+) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/partition.py new file mode 100644 index 00000000..666e2b56 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/partition.py @@ -0,0 +1,147 @@ +""" +Table partitioning - PostgreSQL 10+. + +This example demonstrates: +1. CREATE TABLE ... PARTITION BY RANGE +2. Creating individual partitions +3. Inserting data into partitioned table +4. Querying partitioned tables +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + DropTableExpression, + InsertExpression, + ValuesSource, + QueryExpression, + TableExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal, WildcardExpression, Column +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +dql_options = ExecutionOptions(stmt_type=StatementType.DQL) + +# Drop partitions first, then parent table +for partition in ['orders_2024_q1', 'orders_2024_q2', 'orders_2024_q3', 'orders_2024_q4']: + drop = DropTableExpression(dialect=dialect, table_name=partition, if_exists=True, cascade=True) + sql, params = drop.to_sql() + backend.execute(sql, params) + +drop_parent = DropTableExpression(dialect=dialect, table_name='orders', if_exists=True, cascade=True) +sql, params = drop_parent.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ + +# 1. Create partitioned parent table using raw SQL +# (Expression API does not yet support PARTITION BY syntax) +parent_sql = """ +CREATE TABLE orders ( + id SERIAL, + order_date DATE NOT NULL, + customer_name VARCHAR(100), + amount NUMERIC(10,2) +) PARTITION BY RANGE (order_date) +""" +backend.execute(parent_sql) +print("Created partitioned table: orders") + +# 2. Create individual partitions using raw SQL +partitions = [ + ("orders_2024_q1", "2024-01-01", "2024-04-01"), + ("orders_2024_q2", "2024-04-01", "2024-07-01"), + ("orders_2024_q3", "2024-07-01", "2024-10-01"), + ("orders_2024_q4", "2024-10-01", "2025-01-01"), +] + +for part_name, start_val, end_val in partitions: + part_sql = f""" + CREATE TABLE {part_name} PARTITION OF orders + FOR VALUES FROM ('{start_val}') TO ('{end_val}') + """ + backend.execute(part_sql) + print(f"Created partition: {part_name} [{start_val}, {end_val})") + +# ============================================================ +# SECTION: Execution (insert and query) +# ============================================================ + +# 3. Insert data (PostgreSQL routes to correct partition automatically) +insert_expr = InsertExpression( + dialect=dialect, + into='orders', + columns=['order_date', 'customer_name', 'amount'], + source=ValuesSource(dialect, [ + [Literal(dialect, '2024-02-15'), Literal(dialect, 'Alice'), Literal(dialect, 150.00)], + [Literal(dialect, '2024-05-20'), Literal(dialect, 'Bob'), Literal(dialect, 250.00)], + [Literal(dialect, '2024-08-10'), Literal(dialect, 'Charlie'), Literal(dialect, 350.00)], + [Literal(dialect, '2024-11-05'), Literal(dialect, 'Diana'), Literal(dialect, 450.00)], + ]), +) +sql, params = insert_expr.to_sql() +backend.execute(sql, params) +print("Inserted 4 orders across all partitions") + +# 4. Query all data (transparent across partitions) +query_all = QueryExpression( + dialect=dialect, + select=[WildcardExpression(dialect)], + from_=TableExpression(dialect, 'orders'), +) +sql, params = query_all.to_sql() +result = backend.execute(sql, params, options=dql_options) +print(f"All orders: {result.data}") + +# 5. Query specific partition directly +query_q1 = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'customer_name'), Column(dialect, 'amount')], + from_=TableExpression(dialect, 'orders_2024_q1'), +) +sql, params = query_q1.to_sql() +result = backend.execute(sql, params, options=dql_options) +print(f"Q1 orders: {result.data}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +for partition in ['orders_2024_q1', 'orders_2024_q2', 'orders_2024_q3', 'orders_2024_q4']: + drop = DropTableExpression(dialect=dialect, table_name=partition, if_exists=True, cascade=True) + sql, params = drop.to_sql() + backend.execute(sql, params) + +drop_parent = DropTableExpression(dialect=dialect, table_name='orders', if_exists=True, cascade=True) +sql, params = drop_parent.to_sql() +backend.execute(sql, params) +backend.disconnect() + +# ============================================================ +# SECTION: Summary +# ============================================================ +# Key points: +# 1. Requires PostgreSQL 10+ for declarative partitioning +# 2. PARTITION BY RANGE is the most common strategy +# 3. Other strategies: PARTITION BY LIST, PARTITION BY HASH +# 4. PostgreSQL automatically routes INSERT to correct partition +# 5. Queries on parent table are transparent across all partitions +# 6. Individual partitions can be queried directly for performance +# 7. Expression API does not yet support PARTITION BY - use raw SQL diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/unique_index.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/unique_index.py new file mode 100644 index 00000000..37f19f65 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/unique_index.py @@ -0,0 +1,175 @@ +""" +CREATE UNIQUE INDEX - PostgreSQL. + +This example demonstrates: +1. CREATE UNIQUE INDEX on a single column +2. CREATE UNIQUE INDEX on multiple columns (composite unique) +3. Verify uniqueness constraint by attempting duplicate insert +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + DropTableExpression, + QueryExpression, + TableExpression, + InsertExpression, + ValuesSource, + CreateIndexExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal, WildcardExpression +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +drop_table = DropTableExpression( + dialect=dialect, + table_name='users', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='users', + columns=[ + ColumnDefinition('id', 'SERIAL', constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ]), + ColumnDefinition('email', 'VARCHAR(200)', constraints=[ + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ]), + ColumnDefinition('username', 'VARCHAR(100)', constraints=[ + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ]), + ], + if_not_exists=True, +) +sql, params = create_table.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ + +# 1. CREATE UNIQUE INDEX on a single column +create_email_idx = CreateIndexExpression( + dialect=dialect, + index_name='idx_users_email', + table_name='users', + columns=['email'], + unique=True, + if_not_exists=True, +) +sql, params = create_email_idx.to_sql() +print(f"SQL: {sql}") +print(f"Params: {params}") + +# 2. CREATE UNIQUE INDEX on multiple columns (composite unique) +create_composite_idx = CreateIndexExpression( + dialect=dialect, + index_name='idx_users_username_email', + table_name='users', + columns=['username', 'email'], + unique=True, + if_not_exists=True, +) +sql, params = create_composite_idx.to_sql() +print(f"SQL: {sql}") +print(f"Params: {params}") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ + +# Create the unique indexes +sql, params = create_email_idx.to_sql() +backend.execute(sql, params) +print("Index created: idx_users_email (UNIQUE on email)") + +sql, params = create_composite_idx.to_sql() +backend.execute(sql, params) +print("Index created: idx_users_username_email (UNIQUE on username, email)") + +# Insert initial row +insert_expr = InsertExpression( + dialect=dialect, + into='users', + columns=['email', 'username'], + source=ValuesSource(dialect, [ + [Literal(dialect, 'alice@example.com'), Literal(dialect, 'alice')], + ]), +) +sql, params = insert_expr.to_sql() +backend.execute(sql, params) +print("Inserted: alice@example.com / alice") + +# 3. Verify uniqueness: attempt duplicate email (should fail) +try: + duplicate_insert = InsertExpression( + dialect=dialect, + into='users', + columns=['email', 'username'], + source=ValuesSource(dialect, [ + [Literal(dialect, 'alice@example.com'), Literal(dialect, 'alice2')], + ]), + ) + sql, params = duplicate_insert.to_sql() + backend.execute(sql, params) + print("ERROR: Duplicate email was accepted (should have been rejected)") +except Exception as e: + print(f"Uniqueness constraint enforced: {e}") + +# Verify data after failed insert +verify_query = QueryExpression( + dialect=dialect, + select=[WildcardExpression(dialect)], + from_=TableExpression(dialect, 'users'), +) +sql, params = verify_query.to_sql() +result = backend.execute(sql, params) +print(f"Rows in users table: {result.data}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +drop_table = DropTableExpression( + dialect=dialect, + table_name='users', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) +backend.disconnect() + +# ============================================================ +# SECTION: Summary +# ============================================================ +# Key points: +# 1. Use CreateIndexExpression with unique=True for UNIQUE indexes +# 2. Single-column unique index ensures no duplicates in that column +# 3. Composite unique index ensures no duplicate combination across columns +# 4. if_not_exists=True prevents errors if index already exists +# 5. PostgreSQL enforces uniqueness at INSERT/UPDATE time diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/view.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/view.py new file mode 100644 index 00000000..3ee6c89b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/view.py @@ -0,0 +1,146 @@ +""" +CREATE VIEW - PostgreSQL. + +This example demonstrates: +1. CREATE VIEW +2. CREATE OR REPLACE VIEW +3. CREATE VIEW WITH (security_barrier) +4. Drop view +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', 5432)), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal, Column +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, +) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='users', + columns=[ + ColumnDefinition('id', 'INT'), + ColumnDefinition('name', 'VARCHAR(100)'), + ], + if_not_exists=True, +) +sql, params = create_table.to_sql() +print(f"Create table SQL: {sql}") +backend.execute(sql, params) + +insert = InsertExpression( + dialect=dialect, + into='users', + columns=['id', 'name'], + source=ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, 'Alice')], + [Literal(dialect, 2), Literal(dialect, 'Bob')], + ]), +) +sql, params = insert.to_sql() +print(f"Insert SQL: {sql}") +backend.execute(sql, params) + +# ============================================================ +# SECTION: CREATE VIEW +# ============================================================ +from rhosocial.activerecord.backend.expression import ( + QueryExpression, + TableExpression, + CreateViewExpression, + DropViewExpression, +) +from rhosocial.activerecord.backend.expression.core import WildcardExpression + +query = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'name')], + from_=TableExpression(dialect, 'users'), +) + +view_expr = CreateViewExpression( + dialect=dialect, + view_name='user_names', + query=query, +) +sql, params = view_expr.to_sql() +print(f"CREATE VIEW SQL: {sql}") +print(f"Params: {params}") + +options = ExecutionOptions(stmt_type=StatementType.DDL) +backend.execute(sql, params, options=options) + +verify_query = QueryExpression( + dialect=dialect, + select=[WildcardExpression(dialect)], + from_=TableExpression(dialect, 'user_names'), +) +options_dql = ExecutionOptions(stmt_type=StatementType.DQL) +sql, params = verify_query.to_sql() +result = backend.execute(sql, params, options=options_dql) +print(f"View result: {result.data}") + +# ============================================================ +# SECTION: CREATE OR REPLACE VIEW +# ============================================================ +view_expr_replace = CreateViewExpression( + dialect=dialect, + view_name='user_names', + query=query, + replace=True, +) +sql, params = view_expr_replace.to_sql() +print(f"CREATE OR REPLACE VIEW SQL: {sql}") +backend.execute(sql, params, options=options) + +# ============================================================ +# SECTION: DROP VIEW +# ============================================================ +drop_view = DropViewExpression( + dialect=dialect, + view_name='user_names', +) +sql, params = drop_view.to_sql() +print(f"DROP VIEW SQL: {sql}") +backend.execute(sql, params, options=options) + +# ============================================================ +# SECTION: Teardown +# ============================================================ +drop_expr = DropTableExpression(dialect=dialect, table_name='users', if_exists=True) +sql, params = drop_expr.to_sql() +backend.execute(sql, params) +backend.disconnect() + +# ============================================================ +# SECTION: Summary +# ============================================================ +# Key points: +# 1. Use CreateViewExpression to create views +# 2. Use replace=True to replace existing view +# 3. PostgreSQL supports security_barrier option +# 4. Use DropViewExpression to drop views \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/delete/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/delete/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/delete/basic.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/delete/basic.py new file mode 100644 index 00000000..2986187c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/delete/basic.py @@ -0,0 +1,154 @@ +""" +Delete records and return deleted rows using RETURNING clause - PostgreSQL. + +This example demonstrates: +1. DELETE with WHERE clause +2. DELETE with RETURNING clause to get deleted row data +3. Affected rows tracking +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + DropTableExpression, + InsertExpression, + ValuesSource, + DeleteExpression, + ReturningClause, + TableExpression, + Column, +) +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +drop_table = DropTableExpression( + dialect=dialect, + table_name='users', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='users', + columns=[ + ColumnDefinition('id', 'SERIAL', constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ]), + ColumnDefinition('name', 'VARCHAR(100)', constraints=[ + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ]), + ColumnDefinition('email', 'VARCHAR(200)'), + ], + if_not_exists=True, +) +sql, params = create_table.to_sql() +backend.execute(sql, params) + +# Insert sample data +insert_expr = InsertExpression( + dialect=dialect, + into='users', + columns=['name', 'email'], + source=ValuesSource(dialect, [ + [Literal(dialect, 'Alice'), Literal(dialect, 'alice@example.com')], + [Literal(dialect, 'Bob'), Literal(dialect, 'bob@example.com')], + [Literal(dialect, 'Charlie'), Literal(dialect, 'charlie@example.com')], + ]), +) +sql, params = insert_expr.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ + +# 1. Simple DELETE with WHERE +delete_expr = DeleteExpression( + dialect=dialect, + table=TableExpression(dialect, 'users'), + where=ComparisonPredicate( + dialect, + '=', + Column(dialect, 'name'), + Literal(dialect, 'Alice'), + ), + dialect_options={}, +) + +sql, params = delete_expr.to_sql() +print(f"SQL: {sql}") +print(f"Params: {params}") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +result = backend.execute(sql, params) +print(f"Affected rows: {result.affected_rows}") + +# 2. DELETE with RETURNING clause +delete_returning = DeleteExpression( + dialect=dialect, + table=TableExpression(dialect, 'users'), + where=ComparisonPredicate( + dialect, + '=', + Column(dialect, 'name'), + Literal(dialect, 'Bob'), + ), + returning=ReturningClause(dialect, [Column(dialect, 'id'), Column(dialect, 'name')]), + dialect_options={}, +) + +sql, params = delete_returning.to_sql() +print(f"RETURNING SQL: {sql}") +result = backend.execute(sql, params) +print(f"Deleted rows: {result.data}") +print(f"Affected rows: {result.affected_rows}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +drop_table = DropTableExpression( + dialect=dialect, + table_name='users', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) +backend.disconnect() + +# ============================================================ +# SECTION: Summary +# ============================================================ +# Key points: +# 1. PostgreSQL natively supports RETURNING clause on DELETE +# 2. ReturningClause returns data from deleted rows +# 3. Use Column() in ReturningClause to specify which columns to return +# 4. result.data contains the returned rows +# 5. result.affected_rows indicates how many rows were deleted diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/batch.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/batch.py new file mode 100644 index 00000000..ea7d4a6e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/batch.py @@ -0,0 +1,116 @@ +""" +Batch insert using INSERT with multiple VALUES. +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + DropTableExpression, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +drop_table = DropTableExpression( + dialect=dialect, + table_name='logs', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='logs', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition('level', 'VARCHAR(20)'), + ColumnDefinition('message', 'TEXT'), + ColumnDefinition('created_at', 'TIMESTAMP'), + ], +) +backend.execute(*create_table.to_sql()) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.expression import ( + InsertExpression, + ValuesSource, + QueryExpression, + TableExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal, WildcardExpression, Column +from rhosocial.activerecord.backend.expression.query_parts import OrderByClause + +insert = InsertExpression( + dialect=dialect, + into='logs', + columns=['level', 'message'], + source=ValuesSource( + dialect, + [ + [Literal(dialect, 'INFO'), Literal(dialect, 'System started')], + [Literal(dialect, 'DEBUG'), Literal(dialect, 'Loading configuration')], + [Literal(dialect, 'INFO'), Literal(dialect, 'Application ready')], + [Literal(dialect, 'WARNING'), Literal(dialect, 'Memory usage high')], + ], + ), +) + +sql, params = insert.to_sql() +print(f"SQL: {sql}") +print(f"Params: {params}") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +backend.execute(sql, params) +print("Batch insert completed") + +# Verify +verify_query = QueryExpression( + dialect=dialect, + select=[WildcardExpression(dialect)], + from_=TableExpression(dialect, 'logs'), + order_by=OrderByClause(dialect, [Column(dialect, 'id')]), +) +options = ExecutionOptions(stmt_type=StatementType.DQL) +sql, params = verify_query.to_sql() +result = backend.execute(sql, params, options=options) +print(f"Rows inserted: {len(result.data) if result.data else 0}") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/single.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/single.py new file mode 100644 index 00000000..7b7e5835 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/single.py @@ -0,0 +1,88 @@ +""" +Single Row INSERT - PostgreSQL. + +This example demonstrates: +1. INSERT single row +2. INSERT with SERIAL +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', 5432)), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, +) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='users', + columns=[ + ColumnDefinition('id', 'SERIAL'), + ColumnDefinition('name', 'VARCHAR(100)'), + ], + if_not_exists=True, +) +sql, params = create_table.to_sql() +print(f"Create table SQL: {sql}") +backend.execute(sql, params) + +# ============================================================ +# SECTION: Single INSERT +# ============================================================ +from rhosocial.activerecord.backend.expression import InsertExpression, ValuesSource, QueryExpression, TableExpression +from rhosocial.activerecord.backend.expression.core import Literal, WildcardExpression + +insert_expr = InsertExpression( + dialect=dialect, + into='users', + columns=['name'], + source=ValuesSource(dialect, [ + [Literal(dialect, 'Alice')], + ]), +) +sql, params = insert_expr.to_sql() +print(f"Insert SQL: {sql}") +print(f"Params: {params}") +backend.execute(sql, params) + +verify_query = QueryExpression( + dialect=dialect, + select=[WildcardExpression(dialect)], + from_=TableExpression(dialect, 'users'), +) +sql, params = verify_query.to_sql() +result = backend.execute(sql, params) +print(f"Result: {result.data}") + +# ============================================================ +# SECTION: Teardown +# ============================================================ +from rhosocial.activerecord.backend.expression import DropTableExpression + +drop_expr = DropTableExpression(dialect=dialect, table_name='users', if_exists=True) +sql, params = drop_expr.to_sql() +backend.execute(sql, params) +backend.disconnect() + +# ============================================================ +# SECTION: Summary +# ============================================================ +# Key points: +# 1. Use InsertExpression with ValuesSource +# 2. SERIAL creates auto-increment \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/upsert.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/upsert.py new file mode 100644 index 00000000..47d979b9 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/upsert.py @@ -0,0 +1,211 @@ +""" +UPSERT (INSERT ... ON CONFLICT) - PostgreSQL 9.5+. + +This example demonstrates: +1. INSERT ... ON CONFLICT DO NOTHING +2. INSERT ... ON CONFLICT DO UPDATE +3. UPSERT with conditional updates +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + DropTableExpression, + InsertExpression, + ValuesSource, + QueryExpression, + TableExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal, Column +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, + OnConflictClause, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', 5432)), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +dql_options = ExecutionOptions(stmt_type=StatementType.DQL) + +drop_table = DropTableExpression( + dialect=dialect, + table_name='users', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='users', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition('username', 'VARCHAR(100)', constraints=[ + ColumnConstraint(ColumnConstraintType.UNIQUE), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ]), + ColumnDefinition('email', 'VARCHAR(100)', constraints=[ + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ]), + ColumnDefinition('login_count', 'INTEGER'), + ], + if_not_exists=True, +) +sql, params = create_table.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: INSERT ON CONFLICT DO NOTHING +# ============================================================ +# Ignore duplicate key violations + +insert_nothing = InsertExpression( + dialect=dialect, + into='users', + columns=['username', 'email', 'login_count'], + source=ValuesSource(dialect, [ + [Literal(dialect, 'alice'), Literal(dialect, 'alice@example.com'), Literal(dialect, 1)], + ]), + on_conflict=OnConflictClause( + dialect, + [Column(dialect, 'username')], + do_nothing=True, + ), +) +sql, params = insert_nothing.to_sql() +print(f"DO NOTHING SQL: {sql}") +backend.execute(sql, params) + +# Try to insert again - will be ignored +insert_nothing2 = InsertExpression( + dialect=dialect, + into='users', + columns=['username', 'email', 'login_count'], + source=ValuesSource(dialect, [ + [Literal(dialect, 'alice'), Literal(dialect, 'different@example.com'), Literal(dialect, 2)], + ]), + on_conflict=OnConflictClause( + dialect, + [Column(dialect, 'username')], + do_nothing=True, + ), +) +sql, params = insert_nothing2.to_sql() +backend.execute(sql, params) + +query = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'id'), Column(dialect, 'username'), Column(dialect, 'email')], + from_=TableExpression(dialect, 'users'), + where=ComparisonPredicate(dialect, '=', Column(dialect, 'username'), Literal(dialect, 'alice')), +) +sql, params = query.to_sql() +result = backend.execute(sql, params, options=dql_options) +print(f"DO NOTHING result: {result.data}") + +# ============================================================ +# SECTION: INSERT ON CONFLICT DO UPDATE +# ============================================================ +# Update existing rows on conflict + +insert_update = InsertExpression( + dialect=dialect, + into='users', + columns=['username', 'email', 'login_count'], + source=ValuesSource(dialect, [ + [Literal(dialect, 'bob'), Literal(dialect, 'bob@example.com'), Literal(dialect, 1)], + ]), + on_conflict=OnConflictClause( + dialect, + [Column(dialect, 'username')], + update_assignments={ + 'email': Column(dialect, 'email', table='EXCLUDED'), + 'login_count': Column(dialect, 'login_count', table='EXCLUDED'), + }, + ), +) +sql, params = insert_update.to_sql() +print(f"DO UPDATE SQL: {sql}") +backend.execute(sql, params) + +# Insert again - will update +insert_update2 = InsertExpression( + dialect=dialect, + into='users', + columns=['username', 'email', 'login_count'], + source=ValuesSource(dialect, [ + [Literal(dialect, 'bob'), Literal(dialect, 'bob_new@example.com'), Literal(dialect, 2)], + ]), + on_conflict=OnConflictClause( + dialect, + [Column(dialect, 'username')], + update_assignments={ + 'email': Column(dialect, 'email', table='EXCLUDED'), + 'login_count': Column(dialect, 'login_count', table='EXCLUDED'), + }, + ), +) +sql, params = insert_update2.to_sql() +backend.execute(sql, params) + +query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id'), Column(dialect, 'username'), + Column(dialect, 'email'), Column(dialect, 'login_count'), + ], + from_=TableExpression(dialect, 'users'), + where=ComparisonPredicate(dialect, '=', Column(dialect, 'username'), Literal(dialect, 'bob')), +) +sql, params = query.to_sql() +result = backend.execute(sql, params, options=dql_options) +print(f"DO UPDATE result: {result.data}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +drop_table = DropTableExpression( + dialect=dialect, + table_name='users', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) +backend.disconnect() + +# ============================================================ +# SECTION: Summary +# ============================================================ +# Key points: +# 1. Requires PostgreSQL 9.5+ +# 2. Use OnConflictClause with do_nothing=True for DO NOTHING +# 3. Use OnConflictClause with update_assignments for DO UPDATE +# 4. conflict_target specifies the conflict columns (e.g., [Column(dialect, 'username')]) +# 5. Use Column(dialect, 'col', table='EXCLUDED') to reference EXCLUDED values diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/with_returning.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/with_returning.py new file mode 100644 index 00000000..01b16e68 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/insert/with_returning.py @@ -0,0 +1,147 @@ +""" +Insert a record and return the auto-generated ID using RETURNING clause - PostgreSQL. + +This example demonstrates: +1. INSERT with RETURNING clause to get auto-generated SERIAL id +2. INSERT with multiple RETURNING columns +3. Batch INSERT with RETURNING +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + DropTableExpression, + InsertExpression, + ValuesSource, + ReturningClause, + TableExpression, + Column, +) +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +drop_table = DropTableExpression( + dialect=dialect, + table_name='users', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='users', + columns=[ + ColumnDefinition('id', 'SERIAL', constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ]), + ColumnDefinition('name', 'VARCHAR(100)', constraints=[ + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ]), + ColumnDefinition('email', 'VARCHAR(200)'), + ], + if_not_exists=True, +) +sql, params = create_table.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ + +# 1. INSERT with RETURNING id +insert_expr = InsertExpression( + dialect=dialect, + into=TableExpression(dialect, 'users'), + source=ValuesSource(dialect, [[Literal(dialect, 'Alice'), Literal(dialect, 'alice@example.com')]]), + columns=['name', 'email'], + returning=ReturningClause(dialect, [Column(dialect, 'id')]), + dialect_options={}, +) + +sql, params = insert_expr.to_sql() +print(f"SQL: {sql}") +print(f"Params: {params}") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +result = backend.execute(sql, params) +print(f"Affected rows: {result.affected_rows}") +print(f"Returned data: {result.data}") + +# 2. INSERT with multiple RETURNING columns +insert_multi = InsertExpression( + dialect=dialect, + into=TableExpression(dialect, 'users'), + source=ValuesSource(dialect, [[Literal(dialect, 'Bob'), Literal(dialect, 'bob@example.com')]]), + columns=['name', 'email'], + returning=ReturningClause(dialect, [Column(dialect, 'id'), Column(dialect, 'name')]), + dialect_options={}, +) + +sql, params = insert_multi.to_sql() +result = backend.execute(sql, params) +print(f"Multi-column RETURNING: {result.data}") + +# 3. Batch INSERT with RETURNING +batch_insert = InsertExpression( + dialect=dialect, + into=TableExpression(dialect, 'users'), + source=ValuesSource(dialect, [ + [Literal(dialect, 'Charlie'), Literal(dialect, 'charlie@example.com')], + [Literal(dialect, 'Diana'), Literal(dialect, 'diana@example.com')], + ]), + columns=['name', 'email'], + returning=ReturningClause(dialect, [Column(dialect, 'id'), Column(dialect, 'name')]), + dialect_options={}, +) + +sql, params = batch_insert.to_sql() +result = backend.execute(sql, params) +print(f"Batch RETURNING: {result.data}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +drop_table = DropTableExpression( + dialect=dialect, + table_name='users', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) +backend.disconnect() + +# ============================================================ +# SECTION: Summary +# ============================================================ +# Key points: +# 1. PostgreSQL natively supports RETURNING clause on INSERT +# 2. ReturningClause returns data from inserted rows (e.g., auto-generated id) +# 3. Batch INSERT with RETURNING returns all inserted rows +# 4. Use Column() in ReturningClause to specify which columns to return +# 5. result.data contains the returned rows diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/query/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/query/aggregate.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/aggregate.py new file mode 100644 index 00000000..75ca3e50 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/aggregate.py @@ -0,0 +1,131 @@ +""" +Aggregate queries with GROUP BY and HAVING. +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +drop_table = DropTableExpression( + dialect=dialect, + table_name='sales', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='sales', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition('product', 'VARCHAR(100)'), + ColumnDefinition('quantity', 'INTEGER'), + ColumnDefinition('price', 'DECIMAL(10,2)'), + ColumnDefinition('region', 'VARCHAR(50)'), + ], +) +backend.execute(*create_table.to_sql()) + +insert_expr = InsertExpression( + dialect=dialect, + into='sales', + columns=['product', 'quantity', 'price', 'region'], + source=ValuesSource( + dialect, + [ + [Literal(dialect, 'Widget'), Literal(dialect, 10), Literal(dialect, 5.00), Literal(dialect, 'North')], + [Literal(dialect, 'Widget'), Literal(dialect, 5), Literal(dialect, 5.00), Literal(dialect, 'South')], + [Literal(dialect, 'Gadget'), Literal(dialect, 8), Literal(dialect, 10.00), Literal(dialect, 'North')], + [Literal(dialect, 'Gadget'), Literal(dialect, 3), Literal(dialect, 10.00), Literal(dialect, 'South')], + [Literal(dialect, 'Widget'), Literal(dialect, 7), Literal(dialect, 5.00), Literal(dialect, 'East')], + ], + ), +) +backend.execute(*insert_expr.to_sql()) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.expression import ( + QueryExpression, + TableExpression, + Column, + GroupByHavingClause, +) +from rhosocial.activerecord.backend.expression.core import Literal, FunctionCall +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + +query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'product'), + FunctionCall(dialect, 'SUM', Column(dialect, 'quantity')).as_('total_qty'), + FunctionCall(dialect, 'AVG', Column(dialect, 'price')).as_('avg_price'), + ], + from_=TableExpression(dialect, 'sales'), + group_by_having=GroupByHavingClause( + dialect, + group_by=[Column(dialect, 'product')], + having=ComparisonPredicate( + dialect, + '>', + FunctionCall(dialect, 'SUM', Column(dialect, 'quantity')), + Literal(dialect, 10), + ), + ), +) + +sql, params = query.to_sql() +print(f"SQL: {sql}") +print(f"Params: {params}") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +options = ExecutionOptions(stmt_type=StatementType.DQL) +result = backend.execute(sql, params, options=options) +print(f"Rows returned: {len(result.data) if result.data else 0}") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/query/array_func.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/array_func.py new file mode 100644 index 00000000..6096317d --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/array_func.py @@ -0,0 +1,125 @@ +""" +PostgreSQL array functions - array_agg, unnest. +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +drop_table = DropTableExpression( + dialect=dialect, + table_name='orders', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='orders', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition('customer_name', 'VARCHAR(100)'), + ColumnDefinition('tags', 'TEXT[]'), + ], +) +backend.execute(*create_table.to_sql()) + +insert_expr = InsertExpression( + dialect=dialect, + into='orders', + columns=['customer_name', 'tags'], + source=ValuesSource( + dialect, + [ + [Literal(dialect, 'Alice'), Literal(dialect, '{urgent,priority,new}')], + [Literal(dialect, 'Bob'), Literal(dialect, '{standard,review}')], + [Literal(dialect, 'Charlie'), Literal(dialect, '{urgent,vip}')], + ], + ), +) +backend.execute(*insert_expr.to_sql()) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.expression import ( + QueryExpression, + TableExpression, + Column, +) +from rhosocial.activerecord.backend.impl.postgres.functions.array import ( + array_length, + array_to_string, +) + +# Note: array_length and array_to_string are helper functions that return +# SQL string fragments. They can be used to build raw SQL expressions: +sql_fragment = array_length(dialect, 'tags', 1) +print(f"array_length SQL: {sql_fragment}") + +sql_fragment = array_to_string(dialect, 'tags', ', ') +print(f"array_to_string SQL: {sql_fragment}") + +query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'customer_name'), + Column(dialect, 'tags'), + ], + from_=TableExpression(dialect, 'orders'), +) + +sql, params = query.to_sql() +print(f"SQL: {sql}") +print(f"Params: {params}") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +options = ExecutionOptions(stmt_type=StatementType.DQL) +result = backend.execute(sql, params, options=options) +print(f"Rows returned: {len(result.data) if result.data else 0}") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/query/basic.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/basic.py new file mode 100644 index 00000000..b4c880d3 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/basic.py @@ -0,0 +1,141 @@ +""" +Basic SELECT query with WHERE, ORDER BY, and LIMIT clauses. +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +drop_table = DropTableExpression( + dialect=dialect, + table_name='users', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='users', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition( + 'name', + 'VARCHAR(100)', + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ), + ColumnDefinition('age', 'INTEGER'), + ColumnDefinition('status', 'VARCHAR(20)'), + ], + if_not_exists=True, +) +sql, params = create_table.to_sql() +backend.execute(sql, params) + +insert = InsertExpression( + dialect=dialect, + into='users', + columns=['name', 'age', 'status'], + source=ValuesSource( + dialect, + [ + [Literal(dialect, 'Alice'), Literal(dialect, 30), Literal(dialect, 'active')], + [Literal(dialect, 'Bob'), Literal(dialect, 25), Literal(dialect, 'active')], + [Literal(dialect, 'Charlie'), Literal(dialect, 35), Literal(dialect, 'inactive')], + ], + ), +) +sql, params = insert.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.expression import ( + QueryExpression, + TableExpression, + Column, + WhereClause, + OrderByClause, + LimitOffsetClause, +) +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + +query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id'), + Column(dialect, 'name'), + Column(dialect, 'age'), + ], + from_=TableExpression(dialect, 'users'), + where=WhereClause( + dialect, + condition=ComparisonPredicate( + dialect, + '=', + Column(dialect, 'status'), + Literal(dialect, 'active'), + ), + ), + order_by=OrderByClause( + dialect, + expressions=[(Column(dialect, 'age'), 'ASC')], + ), + limit_offset=LimitOffsetClause(dialect, limit=10), +) + +sql, params = query.to_sql() +print(f"SQL: {sql}") +print(f"Params: {params}") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +options = ExecutionOptions(stmt_type=StatementType.DQL) +result = backend.execute(sql, params, options=options) +print(f"Rows returned: {len(result.data) if result.data else 0}") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/query/constants.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/constants.py new file mode 100644 index 00000000..dd23957b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/constants.py @@ -0,0 +1,162 @@ +""" +Query runtime functions and statement-level constants without a data source - PostgreSQL. + +This example demonstrates: +1. SELECT CURRENT_TIMESTAMP, NOW(), etc. without FROM clause +2. How to use factory functions for SQL niladic functions in SELECT list +3. Various PostgreSQL date/time functions as standalone queries +4. PostgreSQL-specific info functions (CURRENT_USER, SESSION_USER, etc.) +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression import QueryExpression +from rhosocial.activerecord.backend.expression.core import FunctionCall, Literal +from rhosocial.activerecord.backend.expression.functions.datetime import ( + current_timestamp, + now, + current_date, + current_time, + localtimestamp, +) +from rhosocial.activerecord.backend.expression.functions.system import ( + current_user, + session_user, +) + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +dql_options = ExecutionOptions(stmt_type=StatementType.DQL) + +# ============================================================ +# SECTION: SELECT CURRENT_TIMESTAMP +# ============================================================ +# Use factory functions for SQL statement-level constants. +# current_timestamp(dialect) generates CURRENT_TIMESTAMP (no parentheses) +# which is the SQL:2003 standard niladic form, valid in both DDL DEFAULT +# and SELECT contexts. + +query_ts = QueryExpression( + dialect=dialect, + select=[current_timestamp(dialect)], +) +sql, params = query_ts.to_sql() +print(f"CURRENT_TIMESTAMP SQL: {sql}") +result = backend.execute(sql, params, options=dql_options) +print(f"Result: {result.data}") + +# ============================================================ +# SECTION: SELECT NOW() +# ============================================================ +query_now = QueryExpression( + dialect=dialect, + select=[now(dialect)], +) +sql, params = query_now.to_sql() +print(f"NOW() SQL: {sql}") +result = backend.execute(sql, params, options=dql_options) +print(f"Result: {result.data}") + +# ============================================================ +# SECTION: SELECT CURRENT_DATE, CURRENT_TIME +# ============================================================ +query_date = QueryExpression( + dialect=dialect, + select=[ + current_date(dialect), + current_time(dialect), + ], +) +sql, params = query_date.to_sql() +print(f"CURRENT_DATE/CURRENT_TIME SQL: {sql}") +result = backend.execute(sql, params, options=dql_options) +print(f"Result: {result.data}") + +# ============================================================ +# SECTION: SELECT with multiple functions +# ============================================================ +query_multi = QueryExpression( + dialect=dialect, + select=[ + now(dialect).as_('current_time'), + current_date(dialect).as_('current_date'), + current_user(dialect).as_('current_user'), + session_user(dialect).as_('session_user'), + ], +) +sql, params = query_multi.to_sql() +print(f"Multi-function SQL: {sql}") +result = backend.execute(sql, params, options=dql_options) +print(f"Result: {result.data}") + +# ============================================================ +# SECTION: SELECT with function arguments +# ============================================================ +# FunctionCall supports arguments, e.g., date_part, to_char + +query_format = QueryExpression( + dialect=dialect, + select=[ + FunctionCall(dialect, 'TO_CHAR', now(dialect), Literal(dialect, 'YYYY-MM-DD')).as_('formatted_date'), + ], +) +sql, params = query_format.to_sql() +print(f"TO_CHAR SQL: {sql}") +result = backend.execute(sql, params, options=dql_options) +print(f"Result: {result.data}") + +# ============================================================ +# SECTION: SELECT LOCALTIMESTAMP +# ============================================================ +query_localts = QueryExpression( + dialect=dialect, + select=[localtimestamp(dialect)], +) +sql, params = query_localts.to_sql() +print(f"LOCALTIMESTAMP SQL: {sql}") +result = backend.execute(sql, params, options=dql_options) +print(f"Result: {result.data}") + +# ============================================================ +# SECTION: SELECT version() +# ============================================================ +query_version = QueryExpression( + dialect=dialect, + select=[FunctionCall(dialect, 'VERSION').as_('db_version')], +) +sql, params = query_version.to_sql() +print(f"VERSION() SQL: {sql}") +result = backend.execute(sql, params, options=dql_options) +print(f"Result: {result.data}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +backend.disconnect() + +# ============================================================ +# SECTION: Summary +# ============================================================ +# Key points: +# 1. Use factory functions (current_timestamp, now, current_date, etc.) for SQL niladic functions +# 2. QueryExpression without from_ clause generates "SELECT ..." without FROM +# 3. Use FunctionCall(dialect, 'FUNC', arg1, arg2) for regular functions with arguments +# 4. Use .as_('alias') for column aliases in SELECT +# 5. SQL:2003 niladic functions (CURRENT_TIMESTAMP, CURRENT_DATE, etc.) omit parentheses +# 6. PostgreSQL supports CURRENT_USER, SESSION_USER as niladic functions +# 7. Common PostgreSQL info functions: VERSION, CURRENT_QUERY, PG_BACKEND_PID diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/query/cte.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/cte.py new file mode 100644 index 00000000..88a47ab8 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/cte.py @@ -0,0 +1,201 @@ +""" +CTE (Common Table Expression) example - WITH clause. + +This example demonstrates: +1. Basic CTE with WITH clause +2. Recursive CTE for hierarchical data +3. CTE for simplifying complex queries +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', 5432)), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, + QueryExpression, + TableExpression, + CTEExpression, + WithQueryExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal, Column +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate, IsNullPredicate +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +dql_options = ExecutionOptions(stmt_type=StatementType.DQL) + +# Drop table first for clean setup +drop = DropTableExpression(dialect=dialect, table_name='employees', if_exists=True, cascade=True) +sql, params = drop.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='employees', + columns=[ + ColumnDefinition('id', 'SERIAL', constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ]), + ColumnDefinition('name', 'VARCHAR(100)'), + ColumnDefinition('manager_id', 'INT'), + ], + if_not_exists=True, +) +sql, params = create_table.to_sql() +backend.execute(sql, params) + +insert_expr = InsertExpression( + dialect=dialect, + into='employees', + columns=['name', 'manager_id'], + source=ValuesSource(dialect, [ + [Literal(dialect, 'CEO'), Literal(dialect, None)], + [Literal(dialect, 'VP Sales'), Literal(dialect, 1)], + [Literal(dialect, 'VP Engineering'), Literal(dialect, 1)], + [Literal(dialect, 'Sales Manager'), Literal(dialect, 2)], + [Literal(dialect, 'Engineer'), Literal(dialect, 3)], + ]), +) +sql, params = insert_expr.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Basic CTE +# ============================================================ +# CTE simplifies complex queries by defining temporary named result sets + +high_earners_cte = CTEExpression( + dialect=dialect, + name='high_earners', + query=QueryExpression( + dialect=dialect, + select=[Column(dialect, 'id'), Column(dialect, 'name')], + from_=TableExpression(dialect, 'employees'), + where=ComparisonPredicate(dialect, '>', Column(dialect, 'id'), Literal(dialect, 2)), + ), +) + +cte_query = WithQueryExpression( + dialect=dialect, + ctes=[high_earners_cte], + main_query=QueryExpression( + dialect=dialect, + select=[Column(dialect, 'id'), Column(dialect, 'name')], + from_=TableExpression(dialect, 'high_earners'), + ), +) +sql, params = cte_query.to_sql() +print(f"Basic CTE SQL: {sql}") +result = backend.execute(sql, params, options=dql_options) +print(f"Basic CTE result: {result.data}") + +# ============================================================ +# SECTION: Recursive CTE +# ============================================================ +# Recursive CTE for hierarchical data (organizational chart) + +# Base case: top-level employees +base_query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id'), + Column(dialect, 'name'), + Column(dialect, 'manager_id'), + Literal(dialect, 1), + ], + from_=TableExpression(dialect, 'employees'), + where=IsNullPredicate(dialect, Column(dialect, 'manager_id')), +) + +org_cte = CTEExpression( + dialect=dialect, + name='org_chart', + query=base_query, + columns=['id', 'name', 'manager_id', 'level'], +) + +recursive_query = WithQueryExpression( + dialect=dialect, + ctes=[org_cte], + main_query=QueryExpression( + dialect=dialect, + select=[Column(dialect, 'id'), Column(dialect, 'name'), Column(dialect, 'manager_id')], + from_=TableExpression(dialect, 'org_chart'), + ), + recursive=True, +) +sql, params = recursive_query.to_sql() +print(f"Recursive CTE SQL: {sql}") +result = backend.execute(sql, params, options=dql_options) +print("Recursive CTE result:") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: CTE with MATERIALIZED hint (PostgreSQL 12+) +# ============================================================ +# Control whether CTE is inlined or materialized + +materialized_cte = CTEExpression( + dialect=dialect, + name='dept_stats', + query=QueryExpression( + dialect=dialect, + select=[Column(dialect, 'manager_id'), Column(dialect, 'name')], + from_=TableExpression(dialect, 'employees'), + ), + materialized=False, # NOT MATERIALIZED - allows inlining +) + +materialized_query = WithQueryExpression( + dialect=dialect, + ctes=[materialized_cte], + main_query=QueryExpression( + dialect=dialect, + select=[Column(dialect, 'manager_id'), Column(dialect, 'name')], + from_=TableExpression(dialect, 'dept_stats'), + ), +) +sql, params = materialized_query.to_sql() +print(f"MATERIALIZED hint CTE SQL: {sql}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +drop_table = DropTableExpression(dialect=dialect, table_name='employees', if_exists=True, cascade=True) +sql, params = drop_table.to_sql() +backend.execute(sql, params) +backend.disconnect() + +# ============================================================ +# SECTION: Summary +# ============================================================ +# Key points: +# 1. Use CTEExpression to define CTEs +# 2. Use WithQueryExpression to combine CTEs with a main query +# 3. Set recursive=True for recursive CTEs (hierarchical queries) +# 4. PostgreSQL 12+ supports materialized hint (CTEExpression(materialized=False)) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/query/distinct.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/distinct.py new file mode 100644 index 00000000..3011dd17 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/distinct.py @@ -0,0 +1,111 @@ +""" +SELECT DISTINCT - PostgreSQL. + +This example demonstrates: +1. SELECT DISTINCT +2. SELECT DISTINCT ON +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', 5432)), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal, Column +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, +) + +drop_table = DropTableExpression( + dialect=dialect, + table_name='users', + if_exists=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='users', + columns=[ + ColumnDefinition('id', 'INT'), + ColumnDefinition('name', 'VARCHAR(100)'), + ], + if_not_exists=True, +) +sql, params = create_table.to_sql() +print(f"Create table SQL: {sql}") +backend.execute(sql, params) + +insert = InsertExpression( + dialect=dialect, + into='users', + columns=['id', 'name'], + source=ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, 'Alice')], + [Literal(dialect, 2), Literal(dialect, 'Bob')], + [Literal(dialect, 3), Literal(dialect, 'Alice')], + ]), +) +sql, params = insert.to_sql() +print(f"Insert SQL: {sql}") +backend.execute(sql, params) + +# ============================================================ +# SECTION: SELECT DISTINCT +# ============================================================ +from rhosocial.activerecord.backend.expression import ( + QueryExpression, + TableExpression, + SelectModifier, +) + +query = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'name')], + from_=TableExpression(dialect, 'users'), + select_modifier=SelectModifier.DISTINCT, +) +sql, params = query.to_sql() +print(f"SELECT DISTINCT SQL: {sql}") +print(f"Params: {params}") + +options = ExecutionOptions(stmt_type=StatementType.DQL) +result = backend.execute(sql, params, options=options) +print(f"Result: {result.data}") + +# ============================================================ +# SECTION: Teardown +# ============================================================ +drop_expr = DropTableExpression(dialect=dialect, table_name='users', if_exists=True) +sql, params = drop_expr.to_sql() +backend.execute(sql, params) +backend.disconnect() + +# ============================================================ +# SECTION: Summary +# ============================================================ +# Key points: +# 1. Use SelectModifier.DISTINCT in QueryExpression +# 2. PostgreSQL also supports DISTINCT ON \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/query/explain.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/explain.py new file mode 100644 index 00000000..d501f1ee --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/explain.py @@ -0,0 +1,193 @@ +""" +EXPLAIN and Query Plan analysis - PostgreSQL. + +This example demonstrates: +1. Using EXPLAIN to analyze query execution +2. Using ANALYZE to get actual execution times +3. Understanding the EXPLAIN output +4. Interpreting costs and execution plans +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + QueryExpression, + TableExpression, + ExplainExpression, + CreateIndexExpression, + DropTableExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal, Column +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.expression.statements.explain import ExplainOptions +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', 5432)), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +drop_table = DropTableExpression( + dialect=dialect, + table_name='users', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='users', + columns=[ + ColumnDefinition('id', 'SERIAL', constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ]), + ColumnDefinition('name', 'VARCHAR(100)', constraints=[ + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ]), + ColumnDefinition('email', 'VARCHAR(200)'), + ], + if_not_exists=True, +) +sql, params = create_table.to_sql() +backend.execute(sql, params) + +create_index = CreateIndexExpression( + dialect=dialect, + index_name='idx_users_email', + table_name='users', + columns=['email'], + if_not_exists=True, +) +sql, params = create_index.to_sql() +backend.execute(sql, params) + +users = [ + ('Alice', 'alice@example.com'), + ('Bob', 'bob@example.com'), + ('Charlie', 'charlie@example.com'), +] +for name, email in users: + insert_expr = InsertExpression( + dialect=dialect, + into='users', + columns=['name', 'email'], + source=ValuesSource(dialect, [[Literal(dialect, name), Literal(dialect, email)]]), + ) + sql, params = insert_expr.to_sql() + backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ + +# 1. EXPLAIN for table scan (no index on name column) +query1 = QueryExpression( + dialect=dialect, + select=[Column(dialect, '*')], + from_=TableExpression(dialect, 'users'), + where=ComparisonPredicate( + dialect, '=', Column(dialect, 'name'), Literal(dialect, 'Alice'), + ), +) +explain_scan = ExplainExpression( + dialect=dialect, + statement=query1, + options=ExplainOptions(), +) +sql, params = explain_scan.to_sql() +print("1. EXPLAIN (no index on name):") +print(f"SQL: {sql}") +result = backend.execute(sql, params) +for row in result.data: + print(f" {row}") + +# 2. EXPLAIN for index scan (email has index) +query2 = QueryExpression( + dialect=dialect, + select=[Column(dialect, '*')], + from_=TableExpression(dialect, 'users'), + where=ComparisonPredicate( + dialect, '=', Column(dialect, 'email'), Literal(dialect, 'alice@example.com'), + ), +) +explain_search = ExplainExpression( + dialect=dialect, + statement=query2, + options=ExplainOptions(), +) +sql, params = explain_search.to_sql() +print("\n2. EXPLAIN (using idx_users_email):") +print(f"SQL: {sql}") +result = backend.execute(sql, params) +for row in result.data: + print(f" {row}") + +# 3. EXPLAIN ANALYZE for actual execution time (executes the query) +explain_analyze = ExplainExpression( + dialect=dialect, + statement=query2, + options=ExplainOptions(analyze=True, timing=True, costs=True), +) +sql, params = explain_analyze.to_sql() +print("\n3. EXPLAIN ANALYZE (actual execution):") +print(f"SQL: {sql}") +result = backend.execute(sql, params) +for row in result.data: + print(f" {row}") + +# 4. EXPLAIN with FORMAT JSON +explain_json = ExplainExpression( + dialect=dialect, + statement=query2, + options=ExplainOptions(format='JSON'), +) +sql, params = explain_json.to_sql() +print("\n4. EXPLAIN FORMAT JSON:") +print(f"SQL: {sql}") +result = backend.execute(sql, params) +for row in result.data: + print(f" {row}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +drop_table = DropTableExpression( + dialect=dialect, + table_name='users', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) +backend.disconnect() + +# ============================================================ +# SECTION: Summary +# ============================================================ +# Key points: +# 1. EXPLAIN shows estimated query plan without executing +# 2. EXPLAIN ANALYZE executes the query and shows actual times +# 3. "cost" shows estimated startup and total cost +# 4. "rows" shows estimated rows +# 5. "actual time" shows actual execution time (ms) +# 6. FORMAT JSON provides detailed structured output \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/query/join.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/join.py new file mode 100644 index 00000000..1e7e3b84 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/join.py @@ -0,0 +1,180 @@ +""" +JOIN queries - INNER JOIN and LEFT JOIN. +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +for table in ['orders', 'customers']: + drop_table = DropTableExpression( + dialect=dialect, + table_name=table, + if_exists=True, + cascade=True, + ) + sql, params = drop_table.to_sql() + backend.execute(sql, params) + +create_customers = CreateTableExpression( + dialect=dialect, + table_name='customers', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition( + 'name', + 'VARCHAR(100)', + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ), + ColumnDefinition('email', 'VARCHAR(100)'), + ], +) +backend.execute(*create_customers.to_sql()) + +create_orders = CreateTableExpression( + dialect=dialect, + table_name='orders', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition('customer_id', 'INTEGER'), + ColumnDefinition('total', 'DECIMAL(10,2)'), + ColumnDefinition('status', 'VARCHAR(20)'), + ], +) +backend.execute(*create_orders.to_sql()) + +insert_customers = InsertExpression( + dialect=dialect, + into='customers', + columns=['name', 'email'], + source=ValuesSource( + dialect, + [ + [Literal(dialect, 'Alice'), Literal(dialect, 'alice@example.com')], + [Literal(dialect, 'Bob'), Literal(dialect, 'bob@example.com')], + ], + ), +) +backend.execute(*insert_customers.to_sql()) + +insert_orders = InsertExpression( + dialect=dialect, + into='orders', + columns=['customer_id', 'total', 'status'], + source=ValuesSource( + dialect, + [ + [Literal(dialect, 1), Literal(dialect, 100.00), Literal(dialect, 'completed')], + [Literal(dialect, 1), Literal(dialect, 50.00), Literal(dialect, 'pending')], + [Literal(dialect, 2), Literal(dialect, 75.00), Literal(dialect, 'completed')], + ], + ), +) +backend.execute(*insert_orders.to_sql()) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.expression import ( + QueryExpression, + TableExpression, + Column, + WhereClause, +) +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + +customers = TableExpression(dialect, 'customers', alias='c') +orders = TableExpression(dialect, 'orders', alias='o') + +query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'name', table='c'), + Column(dialect, 'total', table='o'), + Column(dialect, 'status', table='o'), + ], + from_=[ + customers, + orders, + ], + where=WhereClause( + dialect, + condition=ComparisonPredicate( + dialect, + 'AND', + ComparisonPredicate( + dialect, + '=', + Column(dialect, 'id', table='c'), + Column(dialect, 'customer_id', table='o'), + ), + ComparisonPredicate( + dialect, + '=', + Column(dialect, 'status', table='o'), + Literal(dialect, 'completed'), + ), + ), + ), +) + +sql, params = query.to_sql() +print(f"SQL: {sql}") +print(f"Params: {params}") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +options = ExecutionOptions(stmt_type=StatementType.DQL) +result = backend.execute(sql, params, options=options) +print(f"Rows returned: {len(result.data) if result.data else 0}") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/query/pagination.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/pagination.py new file mode 100644 index 00000000..5d5ba67f --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/pagination.py @@ -0,0 +1,193 @@ +""" +Pagination with LIMIT/OFFSET: basic paging, total count, and page iteration. +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, + QueryExpression, + TableExpression, + Column, + OrderByClause, + LimitOffsetClause, +) +from rhosocial.activerecord.backend.expression.core import ( + Literal, + WildcardExpression, + FunctionCall, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +drop_table = DropTableExpression( + dialect=dialect, + table_name='articles', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='articles', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition( + 'title', + 'VARCHAR(200)', + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ), + ColumnDefinition('author', 'VARCHAR(100)'), + ColumnDefinition('published_at', 'DATE'), + ], + if_not_exists=True, +) +sql, params = create_table.to_sql() +backend.execute(sql, params) + +insert = InsertExpression( + dialect=dialect, + into='articles', + columns=['title', 'author', 'published_at'], + source=ValuesSource( + dialect, + [ + [Literal(dialect, 'Intro to SQL'), Literal(dialect, 'Alice'), Literal(dialect, '2025-01-10')], + [Literal(dialect, 'Advanced Joins'), Literal(dialect, 'Bob'), Literal(dialect, '2025-01-15')], + [Literal(dialect, 'Indexing Deep Dive'), Literal(dialect, 'Alice'), Literal(dialect, '2025-02-01')], + [Literal(dialect, 'CTE Explained'), Literal(dialect, 'Charlie'), Literal(dialect, '2025-02-10')], + [Literal(dialect, 'Window Functions 101'), Literal(dialect, 'Bob'), Literal(dialect, '2025-02-20')], + [Literal(dialect, 'Recursive Queries'), Literal(dialect, 'Alice'), Literal(dialect, '2025-03-01')], + [Literal(dialect, 'JSONB in Postgres'), Literal(dialect, 'Charlie'), Literal(dialect, '2025-03-10')], + [Literal(dialect, 'Full-Text Search'), Literal(dialect, 'Bob'), Literal(dialect, '2025-03-15')], + [Literal(dialect, 'Array Operations'), Literal(dialect, 'Alice'), Literal(dialect, '2025-03-20')], + [Literal(dialect, 'Partitioning Tables'), Literal(dialect, 'Charlie'), Literal(dialect, '2025-04-01')], + [Literal(dialect, 'PL/pgSQL Basics'), Literal(dialect, 'Bob'), Literal(dialect, '2025-04-10')], + [Literal(dialect, 'Connection Pooling'), Literal(dialect, 'Alice'), Literal(dialect, '2025-04-15')], + ], + ), +) +sql, params = insert.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ + +page_size = 5 +options = ExecutionOptions(stmt_type=StatementType.DQL) + +# --- 1. Basic LIMIT/OFFSET pagination (page 1) --- +page1_query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id'), + Column(dialect, 'title'), + Column(dialect, 'author'), + Column(dialect, 'published_at'), + ], + from_=TableExpression(dialect, 'articles'), + order_by=OrderByClause( + dialect, + expressions=[(Column(dialect, 'id'), 'ASC')], + ), + limit_offset=LimitOffsetClause(dialect, limit=page_size, offset=0), +) + +sql, params = page1_query.to_sql() +print("=== Page 1 (LIMIT/OFFSET) ===") +print(f"SQL: {sql}") +print(f"Params: {params}") + +# --- 2. Calculate total pages using COUNT --- +count_query = QueryExpression( + dialect=dialect, + select=[FunctionCall(dialect, 'COUNT', WildcardExpression(dialect)).as_('total')], + from_=TableExpression(dialect, 'articles'), +) + +count_sql, count_params = count_query.to_sql() +print("\n=== COUNT Query ===") +print(f"SQL: {count_sql}") +print(f"Params: {count_params}") + +# --- 3. Page through all results --- +# (execution shown in the Execution section below) + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ + +# Execute page 1 +result = backend.execute(sql, params, options=options) +print(f"\nPage 1 rows: {len(result.data) if result.data else 0}") +for row in result.data or []: + print(f" {row}") + +# Execute count +count_result = backend.execute(count_sql, count_params, options=options) +total_count = count_result.data[0]['total'] if count_result.data else 0 +total_pages = (total_count + page_size - 1) // page_size +print(f"\nTotal articles: {total_count}") +print(f"Total pages (page_size={page_size}): {total_pages}") + +# Iterate remaining pages +for page_num in range(2, total_pages + 1): + offset = (page_num - 1) * page_size + page_query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id'), + Column(dialect, 'title'), + Column(dialect, 'author'), + Column(dialect, 'published_at'), + ], + from_=TableExpression(dialect, 'articles'), + order_by=OrderByClause( + dialect, + expressions=[(Column(dialect, 'id'), 'ASC')], + ), + limit_offset=LimitOffsetClause(dialect, limit=page_size, offset=offset), + ) + p_sql, p_params = page_query.to_sql() + p_result = backend.execute(p_sql, p_params, options=options) + print(f"\nPage {page_num} rows: {len(p_result.data) if p_result.data else 0}") + for row in p_result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/query/predicate.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/predicate.py new file mode 100644 index 00000000..2ce6831b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/predicate.py @@ -0,0 +1,336 @@ +""" +Complex Predicates - PostgreSQL. + +This example demonstrates: +1. LIKE predicate (pattern matching) +2. IN predicate (list membership) +3. BETWEEN predicate (range) +4. IS NULL / IS NOT NULL predicates +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, + QueryExpression, + TableExpression, + WhereClause, +) +from rhosocial.activerecord.backend.expression.core import Literal, Column +from rhosocial.activerecord.backend.expression.predicates import ( + LikePredicate, + InPredicate, + BetweenPredicate, + IsNullPredicate, + LogicalPredicate, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +drop_table = DropTableExpression( + dialect=dialect, + table_name='employees', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='employees', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition( + 'name', + 'VARCHAR(100)', + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ), + ColumnDefinition('department', 'VARCHAR(50)'), + ColumnDefinition('salary', 'NUMERIC(10,2)'), + ColumnDefinition('manager_id', 'INTEGER'), + ], + if_not_exists=True, +) +sql, params = create_table.to_sql() +backend.execute(sql, params) + +insert = InsertExpression( + dialect=dialect, + into='employees', + columns=['name', 'department', 'salary', 'manager_id'], + source=ValuesSource( + dialect, + [ + [ + Literal(dialect, 'Alice Smith'), + Literal(dialect, 'Engineering'), + Literal(dialect, 95000.00), + Literal(dialect, None), + ], + [ + Literal(dialect, 'Bob Johnson'), + Literal(dialect, 'Engineering'), + Literal(dialect, 85000.00), + Literal(dialect, 1), + ], + [ + Literal(dialect, 'Charlie Brown'), + Literal(dialect, 'Sales'), + Literal(dialect, 72000.00), + Literal(dialect, None), + ], + [ + Literal(dialect, 'Diana Prince'), + Literal(dialect, 'Marketing'), + Literal(dialect, 88000.00), + Literal(dialect, None), + ], + [ + Literal(dialect, 'Eve Williams'), + Literal(dialect, 'Sales'), + Literal(dialect, 68000.00), + Literal(dialect, 3), + ], + [ + Literal(dialect, 'Frank Miller'), + Literal(dialect, 'Engineering'), + Literal(dialect, 105000.00), + Literal(dialect, 1), + ], + ], + ), +) +sql, params = insert.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ + +# 1. LIKE predicate - find employees whose name starts with 'A' +like_query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id'), + Column(dialect, 'name'), + Column(dialect, 'department'), + ], + from_=TableExpression(dialect, 'employees'), + where=WhereClause( + dialect, + condition=LikePredicate( + dialect, + 'LIKE', + Column(dialect, 'name'), + Literal(dialect, 'A%'), + ), + ), +) +sql, params = like_query.to_sql() +print(f"LIKE SQL: {sql}") +print(f"Params: {params}") + +# 2. IN predicate - find employees in specific departments +in_query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id'), + Column(dialect, 'name'), + Column(dialect, 'department'), + ], + from_=TableExpression(dialect, 'employees'), + where=WhereClause( + dialect, + condition=InPredicate( + dialect, + Column(dialect, 'department'), + Literal(dialect, ['Engineering', 'Sales']), + ), + ), +) +sql, params = in_query.to_sql() +print(f"IN SQL: {sql}") +print(f"Params: {params}") + +# 3. BETWEEN predicate - find employees with salary in a range +between_query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id'), + Column(dialect, 'name'), + Column(dialect, 'salary'), + ], + from_=TableExpression(dialect, 'employees'), + where=WhereClause( + dialect, + condition=BetweenPredicate( + dialect, + Column(dialect, 'salary'), + Literal(dialect, 70000.00), + Literal(dialect, 90000.00), + ), + ), +) +sql, params = between_query.to_sql() +print(f"BETWEEN SQL: {sql}") +print(f"Params: {params}") + +# 4. IS NULL / IS NOT NULL predicates - find employees with/without managers +is_null_query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id'), + Column(dialect, 'name'), + Column(dialect, 'manager_id'), + ], + from_=TableExpression(dialect, 'employees'), + where=WhereClause( + dialect, + condition=IsNullPredicate( + dialect, + Column(dialect, 'manager_id'), + is_not=False, + ), + ), +) +sql, params = is_null_query.to_sql() +print(f"IS NULL SQL: {sql}") +print(f"Params: {params}") + +is_not_null_query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id'), + Column(dialect, 'name'), + Column(dialect, 'manager_id'), + ], + from_=TableExpression(dialect, 'employees'), + where=WhereClause( + dialect, + condition=IsNullPredicate( + dialect, + Column(dialect, 'manager_id'), + is_not=True, + ), + ), +) +sql, params = is_not_null_query.to_sql() +print(f"IS NOT NULL SQL: {sql}") +print(f"Params: {params}") + +# 5. Combined predicates - AND with LIKE and BETWEEN +combined_query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id'), + Column(dialect, 'name'), + Column(dialect, 'department'), + Column(dialect, 'salary'), + ], + from_=TableExpression(dialect, 'employees'), + where=WhereClause( + dialect, + condition=LogicalPredicate( + dialect, + 'AND', + LikePredicate( + dialect, + 'LIKE', + Column(dialect, 'name'), + Literal(dialect, '%e%'), + ), + BetweenPredicate( + dialect, + Column(dialect, 'salary'), + Literal(dialect, 70000.00), + Literal(dialect, 100000.00), + ), + ), + ), +) +sql, params = combined_query.to_sql() +print(f"Combined SQL: {sql}") +print(f"Params: {params}") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +options = ExecutionOptions(stmt_type=StatementType.DQL) + +print("\n--- LIKE predicate results ---") +sql, params = like_query.to_sql() +result = backend.execute(sql, params, options=options) +print(f"Rows returned: {len(result.data) if result.data else 0}") +for row in result.data or []: + print(f" {row}") + +print("\n--- IN predicate results ---") +sql, params = in_query.to_sql() +result = backend.execute(sql, params, options=options) +print(f"Rows returned: {len(result.data) if result.data else 0}") +for row in result.data or []: + print(f" {row}") + +print("\n--- BETWEEN predicate results ---") +sql, params = between_query.to_sql() +result = backend.execute(sql, params, options=options) +print(f"Rows returned: {len(result.data) if result.data else 0}") +for row in result.data or []: + print(f" {row}") + +print("\n--- IS NULL predicate results ---") +sql, params = is_null_query.to_sql() +result = backend.execute(sql, params, options=options) +print(f"Rows returned: {len(result.data) if result.data else 0}") +for row in result.data or []: + print(f" {row}") + +print("\n--- IS NOT NULL predicate results ---") +sql, params = is_not_null_query.to_sql() +result = backend.execute(sql, params, options=options) +print(f"Rows returned: {len(result.data) if result.data else 0}") +for row in result.data or []: + print(f" {row}") + +print("\n--- Combined predicates results ---") +sql, params = combined_query.to_sql() +result = backend.execute(sql, params, options=options) +print(f"Rows returned: {len(result.data) if result.data else 0}") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/query/subquery.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/subquery.py new file mode 100644 index 00000000..f74043b8 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/subquery.py @@ -0,0 +1,169 @@ +""" +Subquery expressions - WHERE and FROM subqueries. +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +for table in ['employees', 'departments']: + drop_table = DropTableExpression( + dialect=dialect, + table_name=table, + if_exists=True, + cascade=True, + ) + sql, params = drop_table.to_sql() + backend.execute(sql, params) + +create_departments = CreateTableExpression( + dialect=dialect, + table_name='departments', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition('name', 'VARCHAR(100)'), + ColumnDefinition('budget', 'DECIMAL(15,2)'), + ], +) +backend.execute(*create_departments.to_sql()) + +create_employees = CreateTableExpression( + dialect=dialect, + table_name='employees', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition('name', 'VARCHAR(100)'), + ColumnDefinition('salary', 'DECIMAL(10,2)'), + ColumnDefinition('department_id', 'INTEGER'), + ], +) +backend.execute(*create_employees.to_sql()) + +insert_departments = InsertExpression( + dialect=dialect, + into='departments', + columns=['name', 'budget'], + source=ValuesSource( + dialect, + [ + [Literal(dialect, 'Engineering'), Literal(dialect, 1000000)], + [Literal(dialect, 'Sales'), Literal(dialect, 500000)], + ], + ), +) +backend.execute(*insert_departments.to_sql()) + +insert_employees = InsertExpression( + dialect=dialect, + into='employees', + columns=['name', 'salary', 'department_id'], + source=ValuesSource( + dialect, + [ + [Literal(dialect, 'Alice'), Literal(dialect, 80000), Literal(dialect, 1)], + [Literal(dialect, 'Bob'), Literal(dialect, 90000), Literal(dialect, 1)], + [Literal(dialect, 'Charlie'), Literal(dialect, 60000), Literal(dialect, 2)], + [Literal(dialect, 'David'), Literal(dialect, 70000), Literal(dialect, 2)], + ], + ), +) +backend.execute(*insert_employees.to_sql()) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.expression import ( + QueryExpression, + TableExpression, + Column, + WhereClause, + Subquery, +) +from rhosocial.activerecord.backend.expression.core import FunctionCall +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + +subquery_query = QueryExpression( + dialect=dialect, + select=[FunctionCall(dialect, 'AVG', Column(dialect, 'salary'))], + from_=TableExpression(dialect, 'employees'), +) +sql, params = subquery_query.to_sql() +subquery = Subquery(dialect, sql, params) + +query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'name'), + Column(dialect, 'salary'), + ], + from_=TableExpression(dialect, 'employees'), + where=WhereClause( + dialect, + condition=ComparisonPredicate( + dialect, + '>', + Column(dialect, 'salary'), + subquery, + ), + ), +) + +sql, params = query.to_sql() +print(f"SQL: {sql}") +print(f"Params: {params}") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +options = ExecutionOptions(stmt_type=StatementType.DQL) +result = backend.execute(sql, params, options=options) +print(f"Rows returned: {len(result.data) if result.data else 0}") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/query/text_search.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/text_search.py new file mode 100644 index 00000000..679765f8 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/text_search.py @@ -0,0 +1,127 @@ +""" +PostgreSQL-specific full-text search using tsvector and tsquery. +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +drop_table = DropTableExpression( + dialect=dialect, + table_name='articles', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='articles', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition('title', 'VARCHAR(200)'), + ColumnDefinition('content', 'TEXT'), + ], +) +backend.execute(*create_table.to_sql()) + +insert_expr = InsertExpression( + dialect=dialect, + into='articles', + columns=['title', 'content'], + source=ValuesSource( + dialect, + [ + [Literal(dialect, 'PostgreSQL Tutorial'), + Literal(dialect, 'This tutorial covers PostgreSQL database basics and advanced features.')], + [Literal(dialect, 'MySQL Guide'), Literal(dialect, 'Learn MySQL from beginner to advanced level.')], + [Literal(dialect, 'Database Design'), + Literal(dialect, 'Best practices for designing relational databases.')], + ], + ), +) +backend.execute(*insert_expr.to_sql()) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.expression import ( + QueryExpression, + TableExpression, + Column, +) +from rhosocial.activerecord.backend.expression.core import Literal, FunctionCall +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + +search_term = 'PostgreSQL' + +# Build to_tsvector and plainto_tsquery using FunctionCall +tsvector_expr = FunctionCall(dialect, 'to_tsvector', Literal(dialect, 'english'), Column(dialect, 'content')) +tsquery_expr = FunctionCall(dialect, 'plainto_tsquery', Literal(dialect, 'english'), Literal(dialect, search_term)) + +# Use @@ operator via ComparisonPredicate +match_pred = ComparisonPredicate(dialect, '@@', tsvector_expr, tsquery_expr) + +query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id'), + Column(dialect, 'title'), + ], + from_=TableExpression(dialect, 'articles'), + where=match_pred, +) + +sql, params = query.to_sql() +print(f"SQL: {sql}") +print(f"Params: {params}") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +options = ExecutionOptions(stmt_type=StatementType.DQL) +result = backend.execute(sql, params, options=options) +print(f"Rows returned: {len(result.data) if result.data else 0}") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/query/union.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/union.py new file mode 100644 index 00000000..9893646a --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/union.py @@ -0,0 +1,136 @@ +""" +UNION using SetOperationExpression - PostgreSQL. + +This example demonstrates: +1. UNION (distinct) +2. UNION ALL +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', 5432)), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal, Column +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, +) + +drop_table = DropTableExpression( + dialect=dialect, + table_name='users', + if_exists=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='users', + columns=[ + ColumnDefinition('id', 'INT'), + ColumnDefinition('name', 'VARCHAR(100)'), + ], + if_not_exists=True, +) +sql, params = create_table.to_sql() +print(f"Create table SQL: {sql}") +backend.execute(sql, params) + +insert = InsertExpression( + dialect=dialect, + into='users', + columns=['id', 'name'], + source=ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, 'Alice')], + [Literal(dialect, 2), Literal(dialect, 'Bob')], + ]), +) +sql, params = insert.to_sql() +print(f"Insert SQL: {sql}") +backend.execute(sql, params) + +# ============================================================ +# SECTION: UNION +# ============================================================ +from rhosocial.activerecord.backend.expression import ( + QueryExpression, + TableExpression, + SetOperationExpression, +) + +query1 = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'name')], + from_=TableExpression(dialect, 'users'), +) + +query2 = QueryExpression( + dialect=dialect, + select=[Literal(dialect, 'Charlie')], +) + +union_expr = SetOperationExpression( + dialect=dialect, + left=query1, + operation='UNION', + right=query2, +) +sql, params = union_expr.to_sql() +print(f"UNION SQL: {sql}") +print(f"Params: {params}") + +options = ExecutionOptions(stmt_type=StatementType.DQL) +result = backend.execute(sql, params, options=options) +print(f"Result: {result.data}") + +# ============================================================ +# SECTION: UNION ALL +# ============================================================ +union_all = SetOperationExpression( + dialect=dialect, + left=query1, + operation='UNION', + all_=True, + right=query2, +) +sql, params = union_all.to_sql() +print(f"UNION ALL SQL: {sql}") +result = backend.execute(sql, params, options=options) +print(f"Result: {result.data}") + +# ============================================================ +# SECTION: Teardown +# ============================================================ +drop_expr = DropTableExpression(dialect=dialect, table_name='users', if_exists=True) +sql, params = drop_expr.to_sql() +backend.execute(sql, params) +backend.disconnect() + +# ============================================================ +# SECTION: Summary +# ============================================================ +# Key points: +# 1. Use SetOperationExpression with operation='UNION', all_=True for UNION ALL +# 2. UNION removes duplicates, UNION ALL keeps all diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/query/window.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/window.py new file mode 100644 index 00000000..a839692c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/window.py @@ -0,0 +1,140 @@ +""" +Window functions - ROW_NUMBER, RANK, and aggregate over windows. +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +drop_table = DropTableExpression( + dialect=dialect, + table_name='sales_data', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='sales_data', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition('salesperson', 'VARCHAR(100)'), + ColumnDefinition('region', 'VARCHAR(50)'), + ColumnDefinition('amount', 'DECIMAL(10,2)'), + ], +) +backend.execute(*create_table.to_sql()) + +insert_expr = InsertExpression( + dialect=dialect, + into='sales_data', + columns=['salesperson', 'region', 'amount'], + source=ValuesSource( + dialect, + [ + [Literal(dialect, 'Alice'), Literal(dialect, 'North'), Literal(dialect, 1000)], + [Literal(dialect, 'Alice'), Literal(dialect, 'North'), Literal(dialect, 1500)], + [Literal(dialect, 'Bob'), Literal(dialect, 'North'), Literal(dialect, 1200)], + [Literal(dialect, 'Bob'), Literal(dialect, 'South'), Literal(dialect, 1800)], + [Literal(dialect, 'Charlie'), Literal(dialect, 'South'), Literal(dialect, 2000)], + ], + ), +) +backend.execute(*insert_expr.to_sql()) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.expression import ( + QueryExpression, + TableExpression, + Column, + WindowSpecification, + OrderByClause, +) +from rhosocial.activerecord.backend.expression.advanced_functions import WindowFunctionCall + +query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'salesperson'), + Column(dialect, 'region'), + Column(dialect, 'amount'), + WindowFunctionCall( + dialect, + 'ROW_NUMBER', + window_spec=WindowSpecification( + dialect, + partition_by=[Column(dialect, 'region')], + order_by=OrderByClause(dialect, expressions=[(Column(dialect, 'amount'), 'DESC')]), + ), + alias='row_num', + ), + WindowFunctionCall( + dialect, + 'SUM', + args=[Column(dialect, 'amount')], + window_spec=WindowSpecification( + dialect, + partition_by=[Column(dialect, 'region')], + ), + alias='region_total', + ), + ], + from_=TableExpression(dialect, 'sales_data'), +) + +sql, params = query.to_sql() +print(f"SQL: {sql}") +print(f"Params: {params}") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +options = ExecutionOptions(stmt_type=StatementType.DQL) +result = backend.execute(sql, params, options=options) +print(f"Rows returned: {len(result.data) if result.data else 0}") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/query/window_functions.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/window_functions.py new file mode 100644 index 00000000..dcbd3f6a --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/window_functions.py @@ -0,0 +1,118 @@ +""" +Window function support example - ROW_NUMBER(), RANK(), DENSE_RANK(). +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', 5432)), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.expression import ( + QueryExpression, + TableExpression, +) +from rhosocial.activerecord.backend.expression.core import Column +from rhosocial.activerecord.backend.expression.advanced_functions import ( + WindowFunctionCall, + WindowSpecification, + WindowFrameSpecification, +) +from rhosocial.activerecord.backend.expression.query_parts import OrderByClause + +# ROW_NUMBER() - sequential rank without gaps +row_number = WindowFunctionCall( + dialect=dialect, + function_name='ROW_NUMBER', + window_spec=WindowSpecification( + dialect=dialect, + partition_by=[Column(dialect, 'department')], + order_by=OrderByClause(dialect, [Column(dialect, 'salary')]), + frame=WindowFrameSpecification( + dialect=dialect, + frame_type='ROWS', + start_frame='UNBOUNDED PRECEDING', + end_frame='CURRENT ROW', + ), + ), + alias='row_number', +) + +# RANK() - rank with gaps (same values get same rank, skip ranks) +rank = WindowFunctionCall( + dialect=dialect, + function_name='RANK', + window_spec=WindowSpecification( + dialect=dialect, + partition_by=[Column(dialect, 'department')], + order_by=OrderByClause(dialect, [Column(dialect, 'salary')]), + ), + alias='rank', +) + +# DENSE_RANK() - rank without gaps +dense_rank = WindowFunctionCall( + dialect=dialect, + function_name='DENSE_RANK', + window_spec=WindowSpecification( + dialect=dialect, + partition_by=[Column(dialect, 'department')], + order_by=OrderByClause(dialect, [Column(dialect, 'salary')]), + ), + alias='dense_rank', +) + +# Generate SQL +sql, params = row_number.to_sql() +print(f"ROW_NUMBER: {sql}") +print(f"Params: {params}") + +sql, params = rank.to_sql() +print(f"RANK: {sql}") + +sql, params = dense_rank.to_sql() +print(f"DENSE_RANK: {sql}") + +# ============================================================ +# SECTION: Full Query Example +# ============================================================ +# Using window functions in a SELECT query +query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id'), + Column(dialect, 'name'), + Column(dialect, 'department'), + Column(dialect, 'salary'), + row_number, + ], + from_=TableExpression(dialect, 'employees'), +) + +sql, params = query.to_sql() +print(f"Full query SQL: {sql}") +print(f"Params: {params}") + +# ============================================================ +# SECTION: Output (reference) +# ============================================================ +# Expected output: +# ROW_NUMBER: ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary +# ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS row_number +# RANK: RANK() OVER (PARTITION BY department ORDER BY salary) AS rank +# DENSE_RANK: DENSE_RANK() OVER (PARTITION BY department ORDER BY salary) AS dense_rank diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/transaction/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/transaction/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/transaction/basic.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/transaction/basic.py new file mode 100644 index 00000000..b668ba9c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/transaction/basic.py @@ -0,0 +1,223 @@ +""" +Basic transaction control - PostgreSQL. + +This example demonstrates: +1. Transaction commit and rollback +2. Using transaction context manager +3. Savepoints for nested transactions +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + DropTableExpression, + InsertExpression, + ValuesSource, + QueryExpression, + TableExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal, Column +from rhosocial.activerecord.backend.expression.query_parts import OrderByClause +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +dql_options = ExecutionOptions(stmt_type=StatementType.DQL) + +drop_table = DropTableExpression(dialect=dialect, table_name='accounts', if_exists=True, cascade=True) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='accounts', + columns=[ + ColumnDefinition('id', 'SERIAL', constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ]), + ColumnDefinition('name', 'VARCHAR(100)', constraints=[ + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ]), + ColumnDefinition('balance', 'NUMERIC(10,2)'), + ], + if_not_exists=True, +) +sql, params = create_table.to_sql() +backend.execute(sql, params) + +# Insert initial data +insert = InsertExpression( + dialect=dialect, + into=TableExpression(dialect, 'accounts'), + source=ValuesSource(dialect, [ + [Literal(dialect, 'Alice'), Literal(dialect, 1000.00)], + [Literal(dialect, 'Bob'), Literal(dialect, 500.00)], + ]), + columns=['name', 'balance'], +) +sql, params = insert.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ + +# 1. Transaction with commit +print("--- Transaction with commit ---") +with backend.transaction(): + from rhosocial.activerecord.backend.expression import UpdateExpression + from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + from rhosocial.activerecord.backend.expression.core import Column + + update = UpdateExpression( + dialect=dialect, + table=TableExpression(dialect, 'accounts'), + assignments={'balance': Literal(dialect, 1500.00)}, + where=ComparisonPredicate( + dialect, '=', + Column(dialect, 'name'), + Literal(dialect, 'Alice'), + ), + ) + sql, params = update.to_sql() + backend.execute(sql, params) + print("Updated Alice's balance within transaction") + +# Verify after commit +query = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'name'), Column(dialect, 'balance')], + from_=TableExpression(dialect, 'accounts'), + where=ComparisonPredicate( + dialect, '=', + Column(dialect, 'name'), + Literal(dialect, 'Alice'), + ), +) +sql, params = query.to_sql() +result = backend.execute(sql, params, options=dql_options) +print(f"After commit: {result.data}") + +# 2. Transaction with rollback +print("\n--- Transaction with rollback ---") +try: + with backend.transaction(): + update = UpdateExpression( + dialect=dialect, + table=TableExpression(dialect, 'accounts'), + assignments={'balance': Literal(dialect, 0)}, + where=ComparisonPredicate( + dialect, '=', + Column(dialect, 'name'), + Literal(dialect, 'Bob'), + ), + ) + sql, params = update.to_sql() + backend.execute(sql, params) + print("Updated Bob's balance (will be rolled back)") + raise ValueError("Simulated error - triggering rollback") +except ValueError: + print("Transaction rolled back due to error") + +# Verify rollback +query = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'name'), Column(dialect, 'balance')], + from_=TableExpression(dialect, 'accounts'), + where=ComparisonPredicate( + dialect, '=', + Column(dialect, 'name'), + Literal(dialect, 'Bob'), + ), +) +sql, params = query.to_sql() +result = backend.execute(sql, params, options=dql_options) +print(f"After rollback: {result.data}") + +# 3. Savepoints for nested transactions +print("\n--- Savepoints (nested transactions) ---") +with backend.transaction(): + # First operation + update = UpdateExpression( + dialect=dialect, + table=TableExpression(dialect, 'accounts'), + assignments={'balance': Literal(dialect, 2000.00)}, + where=ComparisonPredicate( + dialect, '=', + Column(dialect, 'name'), + Literal(dialect, 'Alice'), + ), + ) + sql, params = update.to_sql() + backend.execute(sql, params) + + # Create savepoint + sp = backend.transaction_manager.savepoint('before_bob_update') + print(f"Savepoint created: {sp}") + + # Second operation (within savepoint) + update = UpdateExpression( + dialect=dialect, + table=TableExpression(dialect, 'accounts'), + assignments={'balance': Literal(dialect, 9999.00)}, + where=ComparisonPredicate( + dialect, '=', + Column(dialect, 'name'), + Literal(dialect, 'Bob'), + ), + ) + sql, params = update.to_sql() + backend.execute(sql, params) + + # Rollback to savepoint (undo only Bob's update) + backend.transaction_manager.rollback_to(sp) + print("Rolled back to savepoint (Bob's update undone)") + +# Verify - Alice updated, Bob unchanged +query = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'name'), Column(dialect, 'balance')], + from_=TableExpression(dialect, 'accounts'), + order_by=OrderByClause(dialect, [Column(dialect, 'id')]), +) +sql, params = query.to_sql() +result = backend.execute(sql, params, options=dql_options) +print(f"Final state: {result.data}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +drop_table = DropTableExpression(dialect=dialect, table_name='accounts', if_exists=True) +sql, params = drop_table.to_sql() +backend.execute(sql, params) +backend.disconnect() + +# ============================================================ +# SECTION: Summary +# ============================================================ +# Key points: +# 1. Use backend.transaction() context manager for automatic commit/rollback +# 2. Exceptions within the context cause automatic rollback +# 3. Use backend.transaction_manager.savepoint(name) for nested transaction control +# 4. backend.transaction_manager.rollback_to(sp) undoes operations after the savepoint +# 5. Outer transaction commits if no exception propagates diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/transaction/exclusive.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/transaction/exclusive.py new file mode 100644 index 00000000..41a8c77f --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/transaction/exclusive.py @@ -0,0 +1,266 @@ +""" +Transaction Isolation Levels - PostgreSQL. + +This example demonstrates: +1. READ COMMITTED isolation level (PostgreSQL default) +2. REPEATABLE READ isolation level +3. SERIALIZABLE isolation level +4. How to set isolation level on a transaction +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + DropTableExpression, + InsertExpression, + ValuesSource, + QueryExpression, + TableExpression, + UpdateExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal, Column +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.query_parts import OrderByClause +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.transaction import IsolationLevel + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +dql_options = ExecutionOptions(stmt_type=StatementType.DQL) +dml_options = ExecutionOptions(stmt_type=StatementType.DML) + +drop_table = DropTableExpression( + dialect=dialect, + table_name='accounts', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='accounts', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition('name', 'VARCHAR(100)', constraints=[ + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ]), + ColumnDefinition('balance', 'NUMERIC(10,2)'), + ], + if_not_exists=True, +) +sql, params = create_table.to_sql() +backend.execute(sql, params) + +insert_expr = InsertExpression( + dialect=dialect, + into='accounts', + columns=['name', 'balance'], + source=ValuesSource(dialect, [ + [Literal(dialect, 'Alice'), Literal(dialect, 1000)], + [Literal(dialect, 'Bob'), Literal(dialect, 500)], + ]), +) +sql, params = insert_expr.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ + +# 1. READ COMMITTED isolation level (PostgreSQL default) +# Prevents dirty reads: only committed data is visible. +# Non-repeatable reads and phantom reads can still occur +# because each statement sees a snapshot as of its start. +print("--- READ COMMITTED (PostgreSQL default) ---") +with backend.transaction_manager.transaction(isolation_level=IsolationLevel.READ_COMMITTED): + # Read Alice's balance + query = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'name'), Column(dialect, 'balance')], + from_=TableExpression(dialect, 'accounts'), + where=ComparisonPredicate( + dialect, '=', + Column(dialect, 'name'), + Literal(dialect, 'Alice'), + ), + ) + sql, params = query.to_sql() + result = backend.execute(sql, params, options=dql_options) + print(f"READ COMMITTED - Alice's balance: {result.data[0]['balance']}") + + # Update within the transaction + update_expr = UpdateExpression( + dialect=dialect, + table=TableExpression(dialect, 'accounts'), + assignments={'balance': Literal(dialect, 1100)}, + where=ComparisonPredicate( + dialect, '=', + Column(dialect, 'name'), + Literal(dialect, 'Alice'), + ), + ) + sql, params = update_expr.to_sql() + backend.execute(sql, params) + +# After commit, the change is visible to all subsequent transactions +query = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'name'), Column(dialect, 'balance')], + from_=TableExpression(dialect, 'accounts'), + where=ComparisonPredicate( + dialect, '=', + Column(dialect, 'name'), + Literal(dialect, 'Alice'), + ), +) +sql, params = query.to_sql() +result = backend.execute(sql, params, options=dql_options) +print(f"After commit - Alice's balance: {result.data[0]['balance']}") + +# 2. REPEATABLE READ isolation level +# Prevents dirty reads and non-repeatable reads. +# All queries in the transaction see the same snapshot +# taken at the first query's start time. +# Phantom reads are possible in PostgreSQL (unlike MySQL). +print("\n--- REPEATABLE READ ---") +with backend.transaction_manager.transaction(isolation_level=IsolationLevel.REPEATABLE_READ): + # First read + query = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'name'), Column(dialect, 'balance')], + from_=TableExpression(dialect, 'accounts'), + where=ComparisonPredicate( + dialect, '=', + Column(dialect, 'name'), + Literal(dialect, 'Alice'), + ), + ) + sql, params = query.to_sql() + result = backend.execute(sql, params, options=dql_options) + first_read = result.data[0]['balance'] + + # Second read in the same transaction returns the same result + # even if another transaction committed changes in between + sql, params = query.to_sql() + result = backend.execute(sql, params, options=dql_options) + second_read = result.data[0]['balance'] + + print(f"First read: {first_read}, Second read: {second_read}") + print(f"Consistent reads: {first_read == second_read}") + +# 3. SERIALIZABLE isolation level +# The strictest level: prevents dirty reads, non-repeatable reads, +# and phantom reads. Transactions appear to execute sequentially. +# May raise serialization failures that require retry. +print("\n--- SERIALIZABLE ---") +with backend.transaction_manager.transaction(isolation_level=IsolationLevel.SERIALIZABLE): + # Read all accounts + query = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'name'), Column(dialect, 'balance')], + from_=TableExpression(dialect, 'accounts'), + order_by=OrderByClause(dialect, [Column(dialect, 'id')]), + ) + sql, params = query.to_sql() + result = backend.execute(sql, params, options=dql_options) + print(f"SERIALIZABLE - All accounts: {result.data}") + + # Update Bob's balance + update_expr = UpdateExpression( + dialect=dialect, + table=TableExpression(dialect, 'accounts'), + assignments={'balance': Literal(dialect, 600)}, + where=ComparisonPredicate( + dialect, '=', + Column(dialect, 'name'), + Literal(dialect, 'Bob'), + ), + ) + sql, params = update_expr.to_sql() + backend.execute(sql, params) + +# 4. Setting isolation level per transaction +# The isolation_level parameter on transaction() sets the level +# for that specific transaction only, without affecting the +# default level of subsequent transactions. +print("\n--- Per-transaction isolation level ---") + +# Transaction 1: SERIALIZABLE +with backend.transaction_manager.transaction(isolation_level=IsolationLevel.SERIALIZABLE): + update_expr = UpdateExpression( + dialect=dialect, + table=TableExpression(dialect, 'accounts'), + assignments={'balance': Literal(dialect, 1200)}, + where=ComparisonPredicate( + dialect, '=', + Column(dialect, 'name'), + Literal(dialect, 'Alice'), + ), + ) + sql, params = update_expr.to_sql() + backend.execute(sql, params) + +# Transaction 2: defaults to READ COMMITTED (PostgreSQL default) +with backend.transaction(): + query = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'name'), Column(dialect, 'balance')], + from_=TableExpression(dialect, 'accounts'), + order_by=OrderByClause(dialect, [Column(dialect, 'id')]), + ) + sql, params = query.to_sql() + result = backend.execute(sql, params, options=dql_options) + print(f"Default isolation - All accounts: {result.data}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +drop_table = DropTableExpression( + dialect=dialect, + table_name='accounts', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) +backend.disconnect() + +# ============================================================ +# SECTION: Summary +# ============================================================ +# Key points: +# 1. Use backend.transaction_manager.transaction(isolation_level=...) to set isolation +# 2. READ COMMITTED: PostgreSQL default, prevents dirty reads only +# 3. REPEATABLE READ: prevents dirty and non-repeatable reads, snapshot-based +# 4. SERIALIZABLE: strictest level, may raise serialization failures +# 5. PostgreSQL REPEATABLE READ does NOT prevent phantom reads (unlike MySQL) +# 6. SERIALIZABLE in PostgreSQL uses predicate locking for true serializability diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/transaction/for_update.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/transaction/for_update.py new file mode 100644 index 00000000..6640be06 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/transaction/for_update.py @@ -0,0 +1,226 @@ +""" +FOR UPDATE Row Locking - PostgreSQL. + +This example demonstrates: +1. SELECT ... FOR UPDATE to lock rows +2. Preventing dirty reads in concurrent scenarios +3. Using SKIP LOCKED for non-blocking locks +4. NOWAIT for immediate failure on lock +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, + QueryExpression, + TableExpression, + UpdateExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal, Column +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.query_parts import ForUpdateClause +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', 5432)), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +dql_options = ExecutionOptions(stmt_type=StatementType.DQL) +dml_options = ExecutionOptions(stmt_type=StatementType.DML) + +drop_table = DropTableExpression( + dialect=dialect, + table_name='accounts', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='accounts', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition('name', 'VARCHAR(100)', constraints=[ + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ]), + ColumnDefinition('balance', 'DECIMAL(10,2)'), + ], + if_not_exists=True, +) +sql, params = create_table.to_sql() +backend.execute(sql, params) + +insert_expr = InsertExpression( + dialect=dialect, + into='accounts', + columns=['name', 'balance'], + source=ValuesSource( + dialect, + [ + [Literal(dialect, 'Alice'), Literal(dialect, '1000')], + [Literal(dialect, 'Bob'), Literal(dialect, '500')], + ], + ), +) +sql, params = insert_expr.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: SELECT FOR UPDATE +# ============================================================ +# Lock rows to prevent concurrent modifications + +with backend.transaction(): + # Lock Alice's row using ForUpdateClause + lock_query = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'id'), Column(dialect, 'name'), Column(dialect, 'balance')], + from_=TableExpression(dialect, 'accounts'), + where=ComparisonPredicate(dialect, '=', Column(dialect, 'name'), Literal(dialect, 'Alice')), + for_update=ForUpdateClause(dialect), + ) + sql, params = lock_query.to_sql() + result = backend.execute(sql, params, options=dql_options) + print(f"Locked row: {result.data}") + + # Update the balance + update_expr = UpdateExpression( + dialect=dialect, + table='accounts', + assignments={'balance': Literal(dialect, 900)}, + where=ComparisonPredicate(dialect, '=', Column(dialect, 'name'), Literal(dialect, 'Alice')), + ) + sql, params = update_expr.to_sql() + backend.execute(sql, params, options=dml_options) + +# The lock is released after commit + +# ============================================================ +# SECTION: FOR UPDATE with WHERE Conditions +# ============================================================ +# Lock specific rows based on conditions + +with backend.transaction(): + lock_query = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'id'), Column(dialect, 'name'), Column(dialect, 'balance')], + from_=TableExpression(dialect, 'accounts'), + where=ComparisonPredicate(dialect, '>', Column(dialect, 'balance'), Literal(dialect, 500)), + for_update=ForUpdateClause(dialect), + ) + sql, params = lock_query.to_sql() + result = backend.execute(sql, params, options=dql_options) + print(f"Locked high balance accounts: {len(result.data)} rows") + +# ============================================================ +# SECTION: SKIP LOCKED (PostgreSQL 8.4+) +# ============================================================ +# Skip locked rows instead of waiting + +skip_query = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'id'), Column(dialect, 'name'), Column(dialect, 'balance')], + from_=TableExpression(dialect, 'accounts'), + for_update=ForUpdateClause(dialect, skip_locked=True), +) +sql, params = skip_query.to_sql() +print(f"SKIP LOCKED SQL: {sql}") +result = backend.execute(sql, params, options=dql_options) +print(f"SKIP LOCKED result: {result.data}") + +# ============================================================ +# SECTION: NOWAIT +# ============================================================ +# Fail immediately if rows are locked + +try: + nowait_query = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'id'), Column(dialect, 'name'), Column(dialect, 'balance')], + from_=TableExpression(dialect, 'accounts'), + where=ComparisonPredicate(dialect, '=', Column(dialect, 'name'), Literal(dialect, 'Alice')), + for_update=ForUpdateClause(dialect, nowait=True), + ) + sql, params = nowait_query.to_sql() + result = backend.execute(sql, params, options=dql_options) + print(f"NOWAIT result: {result.data}") +except Exception as e: + print(f"NOWAIT failed (expected if locked): {e}") + +# ============================================================ +# SECTION: Lock Modes +# ============================================================ +# FOR UPDATE - exclusive lock (write lock) +# FOR UPDATE OF table_name - lock specific table +# FOR SHARE - shared lock (read lock) +# FOR KEY SHARE - for foreign key detection + +# Use PostgresForUpdateClause with LockStrength.SHARE for FOR SHARE +from rhosocial.activerecord.backend.impl.postgres.expression.locking import ( # noqa: E402 + PostgresForUpdateClause, + LockStrength, +) + +with backend.transaction(): + # Shared lock - allows other transactions to also read + share_query = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'id'), Column(dialect, 'name'), Column(dialect, 'balance')], + from_=TableExpression(dialect, 'accounts'), + for_update=PostgresForUpdateClause(dialect, strength=LockStrength.SHARE), + ) + sql, params = share_query.to_sql() + result = backend.execute(sql, params, options=dql_options) + print(f"FOR SHARE result: {len(result.data)} rows") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +drop_table = DropTableExpression( + dialect=dialect, + table_name='accounts', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) +backend.disconnect() + +# ============================================================ +# SECTION: Summary +# ============================================================ +# Key points: +# 1. Use ForUpdateClause with QueryExpression for SELECT ... FOR UPDATE +# 2. ForUpdateClause(dialect, skip_locked=True) for SKIP LOCKED +# 3. ForUpdateClause(dialect, nowait=True) for NOWAIT +# 4. Use PostgresForUpdateClause with LockStrength.SHARE for FOR SHARE +# 5. Locks released on COMMIT/ROLLBACK diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/types/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/types/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/types/array_postgres84.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/types/array_postgres84.py new file mode 100644 index 00000000..dcc324c3 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/types/array_postgres84.py @@ -0,0 +1,125 @@ +""" +PostgreSQL array type operations. +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +drop_table = DropTableExpression( + dialect=dialect, + table_name='users', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='users', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition('name', 'VARCHAR(100)'), + ColumnDefinition('scores', 'INTEGER[]'), + ], +) +backend.execute(*create_table.to_sql()) + +insert_expr = InsertExpression( + dialect=dialect, + into='users', + columns=['name', 'scores'], + source=ValuesSource( + dialect, + [ + [Literal(dialect, 'Alice'), Literal(dialect, '{85,90,78}')], + [Literal(dialect, 'Bob'), Literal(dialect, '{92,88,95}')], + [Literal(dialect, 'Charlie'), Literal(dialect, '{70,75,80}')], + ], + ), +) +backend.execute(*insert_expr.to_sql()) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.expression import ( + QueryExpression, + TableExpression, + Column, +) +from rhosocial.activerecord.backend.impl.postgres.functions.array import ( + array_length, + array_to_string, +) + +# Note: array_length and array_to_string are helper functions that return +# SQL string fragments. They can be used to build raw SQL expressions: +sql_fragment = array_length(dialect, 'scores', 1) +print(f"array_length SQL: {sql_fragment}") + +sql_fragment = array_to_string(dialect, 'scores', ', ') +print(f"array_to_string SQL: {sql_fragment}") + +query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'name'), + Column(dialect, 'scores'), + ], + from_=TableExpression(dialect, 'users'), +) + +sql, params = query.to_sql() +print(f"SQL: {sql}") +print(f"Params: {params}") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +options = ExecutionOptions(stmt_type=StatementType.DQL) +result = backend.execute(sql, params, options=options) +print(f"Rows returned: {len(result.data) if result.data else 0}") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/types/json_basic.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/types/json_basic.py new file mode 100644 index 00000000..5366baf0 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/types/json_basic.py @@ -0,0 +1,125 @@ +""" +PostgreSQL JSONB operations - PostgreSQL-specific feature. +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +drop_table = DropTableExpression( + dialect=dialect, + table_name='documents', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='documents', + columns=[ + ColumnDefinition( + 'id', + 'SERIAL', + constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ], + ), + ColumnDefinition('data', 'JSONB'), + ], +) +backend.execute(*create_table.to_sql()) + +import json +insert_expr = InsertExpression( + dialect=dialect, + into='documents', + columns=['data'], + source=ValuesSource( + dialect, + [ + [Literal(dialect, json.dumps({'name': 'Alice', 'age': 30, 'tags': ['a', 'b']}))], + [Literal(dialect, json.dumps({'name': 'Bob', 'age': 25, 'tags': ['c']}))], + ], + ), +) +backend.execute(*insert_expr.to_sql()) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.expression import ( + QueryExpression, + TableExpression, + Column, +) +from rhosocial.activerecord.backend.impl.postgres.functions.json import ( + jsonb_path_query, + json_path_root, + json_path_key, +) + +# Note: json_path_root and json_path_key build PostgresJsonPath objects, +# while jsonb_path_query generates SQL string fragments. +path = json_path_key(json_path_root(), 'name') +print(f"JSON path for $.name: {path}") + +path_sql = jsonb_path_query(dialect, 'data', path) +print(f"jsonb_path_query SQL: {path_sql}") + +query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id'), + Column(dialect, 'data'), + ], + from_=TableExpression(dialect, 'documents'), +) + +sql, params = query.to_sql() +print(f"SQL: {sql}") +print(f"Params: {params}") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +options = ExecutionOptions(stmt_type=StatementType.DQL) +result = backend.execute(sql, params, options=options) +print(f"Rows returned: {len(result.data) if result.data else 0}") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/types/uuid.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/types/uuid.py new file mode 100644 index 00000000..fbfca987 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/types/uuid.py @@ -0,0 +1,166 @@ +""" +Native UUID type - PostgreSQL. + +This example demonstrates: +1. CREATE TABLE with UUID column type +2. Using gen_random_uuid() as DEFAULT value (PostgreSQL 13+) +3. Using uuid_generate_v4() from uuid-ossp extension (PostgreSQL < 13) +4. INSERT with UUID values +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + DropTableExpression, + InsertExpression, + ValuesSource, + QueryExpression, + TableExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal, Column, FunctionCall +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', '5432')), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +dql_options = ExecutionOptions(stmt_type=StatementType.DQL) + +# Determine UUID generation function based on server version +server_version = backend.get_server_version() +if server_version >= (13, 0, 0): + uuid_func = FunctionCall(dialect, 'GEN_RANDOM_UUID') + uuid_func_name = 'gen_random_uuid()' +else: + # Enable uuid-ossp extension for PostgreSQL < 13 + backend.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"') + uuid_func = FunctionCall(dialect, 'UUID_GENERATE_V4') + uuid_func_name = 'uuid_generate_v4()' + +print(f"PostgreSQL version: {'.'.join(str(v) for v in server_version)}") +print(f"Using UUID function: {uuid_func_name}") + +# Clean up +for table in ['events_v13', 'events_legacy']: + drop = DropTableExpression(dialect=dialect, table_name=table, if_exists=True, cascade=True) + sql, params = drop.to_sql() + backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ + +# 1. CREATE TABLE with UUID column and auto-generated DEFAULT +# PostgreSQL 13+: use built-in gen_random_uuid() +# PostgreSQL < 13: use uuid-ossp extension's uuid_generate_v4() +# Both produce version 4 (random) UUIDs +create_table = CreateTableExpression( + dialect=dialect, + table_name='events', + columns=[ + ColumnDefinition('id', 'UUID', constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint( + ColumnConstraintType.DEFAULT, + default_value=uuid_func, + ), + ]), + ColumnDefinition('name', 'VARCHAR(200)', constraints=[ + ColumnConstraint(ColumnConstraintType.NOT_NULL), + ]), + ColumnDefinition('payload', 'JSONB'), + ], + if_not_exists=True, +) + +sql, params = create_table.to_sql() +print(f"SQL: {sql}") +print(f"Params: {params}") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +backend.execute(sql, params) +print(f"Table created: events (with UUID primary key, DEFAULT {uuid_func_name})") + +# 2. INSERT with auto-generated UUID (using DEFAULT) +insert_default = InsertExpression( + dialect=dialect, + into='events', + columns=['name'], + source=ValuesSource(dialect, [ + [Literal(dialect, 'user_created')], + [Literal(dialect, 'order_placed')], + ]), +) +sql, params = insert_default.to_sql() +backend.execute(sql, params) +print("Inserted rows with auto-generated UUIDs") + +# 3. INSERT with explicit UUID value +insert_explicit = InsertExpression( + dialect=dialect, + into='events', + columns=['id', 'name'], + source=ValuesSource(dialect, [ + [Literal(dialect, '550e8400-e29b-41d4-a716-446655440000'), Literal(dialect, 'explicit_uuid')], + ]), +) +sql, params = insert_explicit.to_sql() +backend.execute(sql, params) +print("Inserted row with explicit UUID") + +# 4. Verify data +verify_query = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'id'), Column(dialect, 'name')], + from_=TableExpression(dialect, 'events'), +) +sql, params = verify_query.to_sql() +result = backend.execute(sql, params, options=dql_options) +print(f"Events: {result.data}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +drop_table = DropTableExpression( + dialect=dialect, + table_name='events', + if_exists=True, + cascade=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) +backend.disconnect() + +# ============================================================ +# SECTION: Summary +# ============================================================ +# Key points: +# 1. PostgreSQL has native UUID type (no need for VARCHAR storage) +# 2. PostgreSQL 13+: gen_random_uuid() is built-in, no extension required +# 3. PostgreSQL < 13: enable uuid-ossp extension and use uuid_generate_v4() +# - CREATE EXTENSION IF NOT EXISTS "uuid-ossp" +# - Both functions produce version 4 (random) UUIDs +# 4. Use FunctionCall(dialect, 'GEN_RANDOM_UUID') or +# FunctionCall(dialect, 'UUID_GENERATE_V4') for DEFAULT value +# 5. Explicit UUID values can be inserted as string literals +# 6. UUID type provides validation and efficient storage (16 bytes vs 36 chars) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/update/basic.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/update/basic.py new file mode 100644 index 00000000..6b52c39a --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/update/basic.py @@ -0,0 +1,122 @@ +""" +UPDATE using UpdateExpression - PostgreSQL. + +This example demonstrates: +1. UPDATE single row +2. UPDATE with WHERE +3. UPDATE all rows +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +config = PostgresConnectionConfig( + host=os.getenv('PG_HOST', 'localhost'), + port=int(os.getenv('PG_PORT', 5432)), + database=os.getenv('PG_DATABASE', 'test'), + username=os.getenv('PG_USERNAME', 'postgres'), + password=os.getenv('PG_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, + UpdateExpression, +) +from rhosocial.activerecord.backend.expression.core import Literal, Column +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, +) + +drop_table = DropTableExpression( + dialect=dialect, + table_name='users', + if_exists=True, +) +sql, params = drop_table.to_sql() +backend.execute(sql, params) + +create_table = CreateTableExpression( + dialect=dialect, + table_name='users', + columns=[ + ColumnDefinition('id', 'SERIAL'), + ColumnDefinition('name', 'VARCHAR(100)'), + ColumnDefinition('age', 'INT'), + ], + if_not_exists=True, +) +sql, params = create_table.to_sql() +print(f"Create table SQL: {sql}") +backend.execute(sql, params) + +insert = InsertExpression( + dialect=dialect, + into='users', + columns=['name', 'age'], + source=ValuesSource(dialect, [ + [Literal(dialect, 'Alice'), Literal(dialect, 25)], + [Literal(dialect, 'Bob'), Literal(dialect, 30)], + ]), +) +sql, params = insert.to_sql() +print(f"Insert SQL: {sql}") +backend.execute(sql, params) + +# ============================================================ +# SECTION: UPDATE (using UpdateExpression) +# ============================================================ +update_expr = UpdateExpression( + dialect=dialect, + table='users', + assignments={'age': Literal(dialect, 26)}, + where=ComparisonPredicate(dialect, '=', Column(dialect, 'name'), Literal(dialect, 'Alice')), +) +sql, params = update_expr.to_sql() +print(f"UPDATE SQL: {sql}") +print(f"Params: {params}") + +options = ExecutionOptions(stmt_type=StatementType.DML) +result = backend.execute(sql, params, options=options) +print(f"Updated rows: {result.affected_rows}") + +# ============================================================ +# SECTION: UPDATE all rows +# ============================================================ +update_all = UpdateExpression( + dialect=dialect, + table='users', + assignments={'age': Literal(dialect, 99)}, +) +sql, params = update_all.to_sql() +print(f"UPDATE all SQL: {sql}") +result = backend.execute(sql, params, options=options) +print(f"Updated rows: {result.affected_rows}") + +# ============================================================ +# SECTION: Teardown +# ============================================================ +drop_expr = DropTableExpression(dialect=dialect, table_name='users', if_exists=True) +sql, params = drop_expr.to_sql() +backend.execute(sql, params) +backend.disconnect() + +# ============================================================ +# SECTION: Summary +# ============================================================ +# Key points: +# 1. Use UpdateExpression with assignments dict for SET clause +# 2. Use ComparisonPredicate for WHERE +# 3. Omit where to update all rows diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py index 16bf2c00..45ba2c23 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py @@ -3,7 +3,12 @@ PostgreSQL-specific expression classes. This module provides expression classes that are specific to PostgreSQL, -such as advisory lock expressions and row-level locking support. +such as advisory lock expressions, DDL statements, and row-level locking support. + +Directory structure: +- advisory/ - Advisory lock expressions +- locking.py - Lock strength expressions +- ddl/ - DDL statement expressions """ from .advisory import ( @@ -14,13 +19,50 @@ AdvisoryLockType, ) from .locking import LockStrength, PostgresForUpdateClause +from .ddl import ( + VacuumExpression, + AnalyzeExpression, + CreatePartitionExpression, + DetachPartitionExpression, + AttachPartitionExpression, + ReindexExpression, + CreateStatisticsExpression, + DropStatisticsExpression, + CommentExpression, + RefreshMaterializedViewPgExpression, + CreateEnumTypeExpression, + DropEnumTypeExpression, + AlterEnumAddValueExpression, + AlterEnumTypeAddValueExpression, + AlterEnumTypeRenameValueExpression, + CreateRangeTypeExpression, +) __all__ = [ + # advisory "AdvisoryLockExpression", "AdvisoryUnlockExpression", "AdvisoryUnlockAllExpression", "TryAdvisoryLockExpression", "AdvisoryLockType", + # locking "LockStrength", "PostgresForUpdateClause", + # ddl + "VacuumExpression", + "AnalyzeExpression", + "CreatePartitionExpression", + "DetachPartitionExpression", + "AttachPartitionExpression", + "ReindexExpression", + "CreateStatisticsExpression", + "DropStatisticsExpression", + "CommentExpression", + "RefreshMaterializedViewPgExpression", + "CreateEnumTypeExpression", + "DropEnumTypeExpression", + "AlterEnumAddValueExpression", + "AlterEnumTypeAddValueExpression", + "AlterEnumTypeRenameValueExpression", + "CreateRangeTypeExpression", ] \ 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 new file mode 100644 index 00000000..20d2c807 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py @@ -0,0 +1,61 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py +""" +PostgreSQL DDL expressions. + +Directory structure: +- vacuum.py - VACUUM/ANALYZE expressions +- partition.py - Partition DDL expressions +- index.py - Index DDL expressions +- statistics.py - Statistics DDL expressions +- comment.py - COMMENT expressions +- mv.py - Materialized view expressions +- type.py - Enum/Range type expressions +""" + +from .vacuum import VacuumExpression, AnalyzeExpression +from .partition import ( + CreatePartitionExpression, + DetachPartitionExpression, + AttachPartitionExpression, +) +from .index import ReindexExpression +from .statistics import ( + CreateStatisticsExpression, + DropStatisticsExpression, +) +from .comment import CommentExpression +from .mv import RefreshMaterializedViewPgExpression +from .type import ( + CreateEnumTypeExpression, + DropEnumTypeExpression, + AlterEnumAddValueExpression, + AlterEnumTypeAddValueExpression, + AlterEnumTypeRenameValueExpression, + CreateRangeTypeExpression, +) + +__all__ = [ + # vacuum + "VacuumExpression", + "AnalyzeExpression", + # partition + "CreatePartitionExpression", + "DetachPartitionExpression", + "AttachPartitionExpression", + # index + "ReindexExpression", + # statistics + "CreateStatisticsExpression", + "DropStatisticsExpression", + # comment + "CommentExpression", + # mv + "RefreshMaterializedViewPgExpression", + # type (enum/range) + "CreateEnumTypeExpression", + "DropEnumTypeExpression", + "AlterEnumAddValueExpression", + "AlterEnumTypeAddValueExpression", + "AlterEnumTypeRenameValueExpression", + "CreateRangeTypeExpression", +] \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py new file mode 100644 index 00000000..dfc543e0 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py @@ -0,0 +1,91 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py +""" +PostgreSQL DDL expressions: COMMENT operations. + +PostgreSQL Documentation: +- COMMENT: https://www.postgresql.org/docs/current/sql-comment.html + +Version Requirements: +- COMMENT: PostgreSQL 7.2+ +""" + +from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = ["CommentExpression"] + + +class CommentExpression(BaseExpression): + """PostgreSQL COMMENT ON statement expression. + + Stores a comment about a database object. + Comments can be retrieved using pg_descr objects. + + Attributes: + object_type: Object type: 'TABLE', 'COLUMN', 'INDEX', 'VIEW', + 'SCHEMA', 'FUNCTION', 'TRIGGER', etc. + object_name: Name of the object to comment on. + For COLUMN, format as 'table.column'. + comment: Comment text (None to remove existing comment). + schema: Schema name for the object. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect() + >>> # Comment on a table + >>> comment = CommentExpression( + ... dialect=dialect, + ... object_type="TABLE", + ... object_name="users", + ... comment="User accounts table", + ... ) + >>> sql, params = comment.to_sql() + >>> sql + "COMMENT ON TABLE users IS 'User accounts table'" + + >>> # Comment on a column + >>> comment = CommentExpression( + ... dialect=dialect, + ... object_type="COLUMN", + ... object_name="users.email", + ... comment="User email address", + ... ) + + >>> # Remove comment + >>> comment = CommentExpression( + ... dialect=dialect, + ... object_type="INDEX", + ... object_name="users_email_idx", + ... comment=None, + ... ) + """ + + def __init__( + self, + dialect: "SQLDialectBase", + object_type: str, + object_name: str, + comment: Optional[str], + schema: Optional[str] = None, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.object_type = object_type + self.object_name = object_name + self.comment = comment + self.schema = schema + self.dialect_options = dialect_options or {} + + def to_sql(self) -> Tuple[str, tuple]: + """Generate COMMENT ON SQL statement. + + 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/index.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py new file mode 100644 index 00000000..aba9ab85 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py @@ -0,0 +1,97 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py +""" +PostgreSQL DDL expressions: Index operations (REINDEX). + +PostgreSQL Documentation: +- REINDEX: https://www.postgresql.org/docs/current/sql-reindex.html + +Version Requirements: +- REINDEX: PostgreSQL 8.0+ +- CONCURRENTLY option: PostgreSQL 12+ +- TABLESPACE option: PostgreSQL 14+ +""" + +from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = ["ReindexExpression"] + + +class ReindexExpression(BaseExpression): + """PostgreSQL REINDEX statement expression. + + Rebuilt indexes to eliminate bloat, update statistics, and recover from corruption. + Supports version-specific features. + + Attributes: + target_type: Target type: 'INDEX', 'TABLE', 'SCHEMA', or 'DATABASE'. + name: Name of the index/table/schema/database to reindex. + schema: Schema name for the target. + tablespace: Tablespace to move index (PG 14+). + concurrently: Build index without locks (PG 12+). + verbose: Print progress messages. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect() + >>> # Reindex a specific index + >>> reindex = ReindexExpression( + ... dialect=dialect, + ... target_type="INDEX", + ... name="users_pkey", + ... ) + >>> sql, params = reindex.to_sql() + >>> sql + "REINDEX INDEX users_pkey" + + >>> # Reindex all indexes in a table concurrently (PG 12+) + >>> reindex = ReindexExpression( + ... dialect=dialect, + ... target_type="TABLE", + ... name="orders", + ... concurrently=True, + ... verbose=True, + ... ) + + >>> # Reindex database with new tablespace (PG 14+) + >>> reindex = ReindexExpression( + ... dialect=dialect, + ... target_type="DATABASE", + ... name="mydb", + ... tablespace="fast_tablespace", + ... ) + """ + + def __init__( + self, + dialect: "SQLDialectBase", + target_type: str, + name: str, + schema: Optional[str] = None, + tablespace: Optional[str] = None, + concurrently: bool = False, + verbose: bool = False, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.target_type = target_type + self.name = name + self.schema = schema + self.tablespace = tablespace + self.concurrently = concurrently + self.verbose = verbose + self.dialect_options = dialect_options or {} + + def to_sql(self) -> Tuple[str, tuple]: + """Generate REINDEX SQL statement. + + Returns: + Tuple of (SQL string, empty params tuple). + """ + return self.dialect.format_reindex_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py new file mode 100644 index 00000000..14aa48a5 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py @@ -0,0 +1,90 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py +""" +PostgreSQL DDL expressions: Materialized View operations. + +PostgreSQL Documentation: +- REFRESH MATERIALIZED VIEW: https://www.postgresql.org/docs/current/sql-refreshmaterializedview.html + +Version Requirements: +- REFRESH MATERIALIZED VIEW: PostgreSQL 9.3+ +- CONCURRENTLY option: PostgreSQL 9.4+ +- WITH NO DATA: PostgreSQL 9.4+ +""" + +from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = ["RefreshMaterializedViewPgExpression"] + + +class RefreshMaterializedViewPgExpression(BaseExpression): + """PostgreSQL REFRESH MATERIALIZED VIEW statement expression. + + Replaces the contents of a materialized view by recalculating its query. + The data is replaced atomically without affecting concurrent queries. + + Attributes: + name: Name of the materialized view to refresh. + schema: Schema name for the materialized view. + concurrently: Refresh without locks (PG 9.4+, requires unique index). + with_data: Whether to repopulate data: + - True: Refresh with data (default) + - False: Refresh without data (PG 9.4+) + None: Use default behavior + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect() + >>> # Regular refresh + >>> refresh = RefreshMaterializedViewPgExpression( + ... dialect=dialect, + ... name="monthly_sales_summary", + ... ) + >>> sql, params = refresh.to_sql() + >>> sql + "REFRESH MATERIALIZED VIEW monthly_sales_summary" + + >>> # Concurrent refresh (PG 9.4+, requires unique index) + >>> refresh = RefreshMaterializedViewPgExpression( + ... dialect=dialect, + ... name="monthly_sales_summary", + ... concurrently=True, + ... ) + + >>> # Refresh without data (create empty, PG 9.4+) + >>> refresh = RefreshMaterializedViewPgExpression( + ... dialect=dialect, + ... name="monthly_sales_summary", + ... with_data=False, + ... ) + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + schema: Optional[str] = None, + concurrently: bool = False, + with_data: Optional[bool] = None, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.name = name + self.schema = schema + self.concurrently = concurrently + self.with_data = with_data + self.dialect_options = dialect_options or {} + + def to_sql(self) -> Tuple[str, tuple]: + """Generate REFRESH MATERIALIZED VIEW SQL statement. + + 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 new file mode 100644 index 00000000..efbb1188 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py @@ -0,0 +1,212 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py +""" +PostgreSQL DDL expressions: Partition operations. + +PostgreSQL Documentation: +- Partitioning: https://www.postgresql.org/docs/current/ddl-partitioning.html +- CREATE TABLE: https://www.postgresql.org/docs/current/sql-createtable.html +- ALTER TABLE: https://www.postgresql.org/docs/current/sql-altertable.html + +Version Requirements: +- Partitioning: PostgreSQL 10+ +- HASH partitioning, DEFAULT partition: PostgreSQL 11+ +- Partition bounds expression: PostgreSQL 12+ +- ATTACH PARTITION with CONCURRENTLY: PostgreSQL 14+ +""" + +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "CreatePartitionExpression", + "DetachPartitionExpression", + "AttachPartitionExpression", +] + + +class CreatePartitionExpression(BaseExpression): + """PostgreSQL CREATE TABLE ... PARTITION OF statement expression. + + Creates a new partition as a child of a partitioned table. + Supports RANGE, LIST, and HASH partitioning with version-specific features. + + Attributes: + partition_name: Name of the partition to create. + parent_table: Name of the parent partitioned table. + partition_type: Partition type: 'RANGE', 'LIST', or 'HASH'. + partition_values: Partition bounds values (dict with 'from', 'to' for RANGE, + list of values for LIST, or modulus/remainder for HASH). + schema: Schema name for the partition. + tablespace: Tablespace for the partition. + if_not_exists: Add IF NOT EXISTS clause. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect() + >>> # RANGE partition + >>> partition = CreatePartitionExpression( + ... dialect=dialect, + ... partition_name="orders_2024_q1", + ... parent_table="orders", + ... partition_type="RANGE", + ... partition_values={"from": "2024-01-01", "to": "2024-04-01"}, + ... ) + >>> sql, params = partition.to_sql() + >>> sql + "CREATE TABLE orders_2024_q1 PARTITION OF orders RANGE ('2024-01-01', '2024-04-01')" + + >>> # LIST partition + >>> partition = CreatePartitionExpression( + ... dialect=dialect, + ... partition_name="orders_active", + ... parent_table="orders", + ... partition_type="LIST", + ... partition_values={"values": ["active", "pending"]}, + ... ) + """ + + def __init__( + self, + dialect: "SQLDialectBase", + partition_name: str, + parent_table: str, + partition_type: str, + partition_values: Dict[str, Any], + schema: Optional[str] = None, + tablespace: Optional[str] = None, + if_not_exists: bool = False, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.partition_name = partition_name + self.parent_table = parent_table + self.partition_type = partition_type + self.partition_values = partition_values + self.schema = schema + self.tablespace = tablespace + self.if_not_exists = if_not_exists + self.dialect_options = dialect_options or {} + + def to_sql(self) -> Tuple[str, tuple]: + """Generate CREATE TABLE PARTITION SQL statement. + + Returns: + Tuple of (SQL string, empty params tuple). + """ + return self.dialect.format_create_partition_statement(self) + + +class DetachPartitionExpression(BaseExpression): + """PostgreSQL ALTER TABLE ... DETACH PARTITION statement expression. + + Detaches a partition from its parent table without dropping the data. + The partition becomes an independent regular table. + + Attributes: + partition_name: Name of the partition to detach. + parent_table: Name of the parent partitioned table. + schema: Schema name for the partition. + concurrently: Perform detach without locks (PG 14+). + finalize: Finalize concurrent detach (PG 14+). + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect() + >>> detach = DetachPartitionExpression( + ... dialect=dialect, + ... partition_name="orders_2023", + ... parent_table="orders", + ... concurrently=True, + ... ) + >>> sql, params = detach.to_sql() + >>> sql + "ALTER TABLE orders DETACH PARTITION orders_2023 CONCURRENTLY" + """ + + def __init__( + self, + dialect: "SQLDialectBase", + partition_name: str, + parent_table: str, + schema: Optional[str] = None, + concurrently: bool = False, + finalize: bool = False, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.partition_name = partition_name + self.parent_table = parent_table + self.schema = schema + self.concurrently = concurrently + self.finalize = finalize + self.dialect_options = dialect_options or {} + + def to_sql(self) -> Tuple[str, tuple]: + """Generate DETACH PARTITION SQL statement. + + Returns: + Tuple of (SQL string, empty params tuple). + """ + return self.dialect.format_detach_partition_statement(self) + + +class AttachPartitionExpression(BaseExpression): + """PostgreSQL ALTER TABLE ... ATTACH PARTITION statement expression. + + Attaches an existing table as a partition of a partitioned table. + + Attributes: + partition_name: Name of the table to attach. + parent_table: Name of the parent partitioned table. + partition_type: Partition type: 'RANGE', 'LIST', or 'HASH'. + partition_values: Partition bounds values. + schema: Schema name for the partition. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect() + >>> attach = AttachPartitionExpression( + ... dialect=dialect, + ... partition_name="orders_2024_q1", + ... parent_table="orders", + ... partition_type="RANGE", + ... partition_values={"from": "2024-01-01", "to": "2024-04-01"}, + ... ) + >>> sql, params = attach.to_sql() + >>> sql + "ALTER TABLE orders ATTACH PARTITION orders_2024_q1 RANGE ('2024-01-01', '2024-04-01')" + """ + + def __init__( + self, + dialect: "SQLDialectBase", + partition_name: str, + parent_table: str, + partition_type: str, + partition_values: Dict[str, Any], + schema: Optional[str] = None, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.partition_name = partition_name + self.parent_table = parent_table + self.partition_type = partition_type + self.partition_values = partition_values + self.schema = schema + self.dialect_options = dialect_options or {} + + def to_sql(self) -> Tuple[str, tuple]: + """Generate ATTACH PARTITION SQL statement. + + Returns: + Tuple of (SQL string, empty params tuple). + """ + return self.dialect.format_attach_partition_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py new file mode 100644 index 00000000..194135df --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py @@ -0,0 +1,139 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py +""" +PostgreSQL DDL expressions: Statistics operations. + +PostgreSQL Documentation: +- CREATE STATISTICS: https://www.postgresql.org/docs/current/sql-createstatistics.html +- DROP STATISTICS: https://www.postgresql.org/docs/current/sql-dropstatistics.html + +Version Requirements: +- Extended statistics: PostgreSQL 10+ +- ndistinct coefficient: PostgreSQL 10+ +- MCV lists: PostgreSQL 12+ +""" + +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = ["CreateStatisticsExpression", "DropStatisticsExpression"] + + +class CreateStatisticsExpression(BaseExpression): + """PostgreSQL CREATE STATISTICS statement expression. + + Creates extended statistics for columns to improve query planning for complex queries. + Supports ndistinct, dependencies, and MCV (most-common value) statistics. + + Attributes: + name: Name of the statistics object. + columns: List of columns to include in statistics. + table_name: Name of the table containing the columns. + schema: Schema name for the statistics. + statistics_type: Type of statistics: 'ndistinct', 'dependencies', or 'mcv'. + Multiple types can be combined (e.g., 'ndistinct, mcv'). + if_not_exists: Add IF NOT EXISTS clause. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect() + >>> # Create ndistinct statistics + >>> stats = CreateStatisticsExpression( + ... dialect=dialect, + ... name="users_status_expr", + ... columns=["status", "category"], + ... table_name="users", + ... statistics_type="ndistinct", + ... ) + >>> sql, params = stats.to_sql() + >>> sql + "CREATE STATISTICS users_status_expr ON (status, category) FROM users" + + >>> # Create multiple statistics types (PG 12+) + >>> stats = CreateStatisticsExpression( + ... dialect=dialect, + ... name="order_stats", + ... columns=["customer_id", "status", "region"], + ... table_name="orders", + ... statistics_type="ndistinct, mcv", + ... ) + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + columns: List[str], + table_name: str, + schema: Optional[str] = None, + statistics_type: Optional[str] = None, + if_not_exists: bool = False, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.name = name + self.columns = columns + self.table_name = table_name + self.schema = schema + self.statistics_type = statistics_type + self.if_not_exists = if_not_exists + self.dialect_options = dialect_options or {} + + def to_sql(self) -> Tuple[str, tuple]: + """Generate CREATE STATISTICS SQL statement. + + Returns: + Tuple of (SQL string, empty params tuple). + """ + return self.dialect.format_create_statistics_statement(self) + + +class DropStatisticsExpression(BaseExpression): + """PostgreSQL DROP STATISTICS statement expression. + + Drops an extended statistics object. + + Attributes: + name: Name of the statistics object to drop. + schema: Schema name for the statistics. + if_exists: Add IF EXISTS clause (prevent error if not exists). + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect() + >>> drop = DropStatisticsExpression( + ... dialect=dialect, + ... name="users_status_expr", + ... ) + >>> sql, params = drop.to_sql() + >>> sql + "DROP STATISTICS users_status_expr" + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + schema: Optional[str] = None, + if_exists: bool = False, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.name = name + self.schema = schema + self.if_exists = if_exists + self.dialect_options = dialect_options or {} + + def to_sql(self) -> Tuple[str, tuple]: + """Generate DROP STATISTICS SQL statement. + + 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 new file mode 100644 index 00000000..f8e4b626 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py @@ -0,0 +1,414 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py +""" +PostgreSQL DDL expressions: Enum and Range Type operations. + +PostgreSQL Documentation: +- CREATE TYPE: https://www.postgresql.org/docs/current/sql-createtype.html +- DROP TYPE: https://www.postgresql.org/docs/current/sql-droptype.html +- ALTER TYPE: https://www.postgresql.org/docs/current/sql-altertype.html + +Version Requirements: +- ENUM type: PostgreSQL 8.3+ +- ADD VALUE: PostgreSQL 9.1+ +- RENAME VALUE: PostgreSQL 9.1+ +- RANGE type: PostgreSQL 9.2+ +""" + +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + # Enum types + "CreateEnumTypeExpression", + "DropEnumTypeExpression", + "AlterEnumAddValueExpression", + # Additional + "AlterEnumTypeAddValueExpression", + "AlterEnumTypeRenameValueExpression", + # Range type + "CreateRangeTypeExpression", +] + + +# ============================================================================= +# Enum Type Operations +# ============================================================================= + +class CreateEnumTypeExpression(BaseExpression): + """PostgreSQL CREATE TYPE ... AS ENUM statement expression. + + Creates a new enumerated type consisting of a set of static string values. + ENUM types are useful for finite sets of values like status codes. + + Attributes: + name: Name of the enum type. + values: List of allowed values (must be unique). + schema: Schema name for the type. + if_not_exists: Add IF NOT EXISTS clause. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect() + >>> create_enum = CreateEnumTypeExpression( + ... dialect=dialect, + ... name="order_status", + ... values=["pending", "processing", "shipped", "delivered", "cancelled"], + ... ) + >>> sql, params = create_enum.to_sql() + >>> sql + "CREATE TYPE order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered', 'cancelled')" + + >>> # With schema and IF NOT EXISTS + >>> create_enum = CreateEnumTypeExpression( + ... dialect=dialect, + ... name="priority", + ... values=["low", "medium", "high"], + ... schema="app", + ... if_not_exists=True, + ... ) + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + values: List[str], + schema: Optional[str] = None, + if_not_exists: bool = False, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.name = name + self.values = values + self.schema = schema + self.if_not_exists = if_not_exists + self.dialect_options = dialect_options or {} + + def to_sql(self) -> Tuple[str, tuple]: + """Generate CREATE TYPE AS ENUM SQL statement. + + Returns: + Tuple of (SQL string, empty params tuple). + """ + return self.dialect.format_create_enum_type(self) + + +class DropEnumTypeExpression(BaseExpression): + """PostgreSQL DROP TYPE statement expression for enum types. + + Drops an existing enum type from the database. + + Attributes: + name: Name of the enum type to drop. + schema: Schema name for the type. + if_exists: Add IF EXISTS clause (prevent error if not exists). + cascade: Drop dependent objects (CASCADE). + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect() + >>> drop_enum = DropEnumTypeExpression( + ... dialect=dialect, + ... name="order_status", + ... ) + >>> sql, params = drop_enum.to_sql() + >>> sql + "DROP TYPE order_status" + + >>> # With IF EXISTS and CASCADE + >>> drop_enum = DropEnumTypeExpression( + ... dialect=dialect, + ... name="priority", + ... schema="app", + ... if_exists=True, + ... cascade=True, + ... ) + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + schema: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.name = name + self.schema = schema + self.if_exists = if_exists + self.cascade = cascade + self.dialect_options = dialect_options or {} + + def to_sql(self) -> Tuple[str, tuple]: + """Generate DROP TYPE SQL statement. + + Returns: + Tuple of (SQL string, empty params tuple). + """ + return self.dialect.format_drop_enum_type(self) + + +class AlterEnumAddValueExpression(BaseExpression): + """PostgreSQL ALTER TYPE ... ADD VALUE statement expression. + + Adds a new value to an existing enum type. + The new value can be inserted before or after an existing value. + + Attributes: + type_name: Name of the enum type to alter. + new_value: New value to add. + schema: Schema name for the type. + before: Add new value before this existing value. + after: Add new value after this existing value. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect() + >>> add_value = AlterEnumAddValueExpression( + ... dialect=dialect, + ... type_name="order_status", + ... new_value="returned", + ... ) + >>> sql, params = add_value.to_sql() + >>> sql + "ALTER TYPE order_status ADD VALUE 'returned'" + + >>> # Add in order + >>> add_value = AlterEnumAddValueExpression( + ... dialect=dialect, + ... type_name="order_status", + ... new_value="refunded", + ... after="cancelled", + ... ) + """ + + def __init__( + self, + dialect: "SQLDialectBase", + type_name: str, + new_value: str, + schema: Optional[str] = None, + before: Optional[str] = None, + after: Optional[str] = None, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.type_name = type_name + self.new_value = new_value + self.schema = schema + self.before = before + self.after = after + self.dialect_options = dialect_options or {} + + def to_sql(self) -> Tuple[str, tuple]: + """Generate ALTER TYPE ADD VALUE SQL statement. + + Returns: + Tuple of (SQL string, empty params tuple). + """ + return self.dialect.format_alter_enum_add_value(self) + + +class AlterEnumTypeAddValueExpression(BaseExpression): + """PostgreSQL ALTER TYPE ... ADD VALUE statement. + + Adds a new value to an existing enum type with validation. + + Attributes: + type_name: Name of the enum type to alter. + new_value: New value to add. + schema: Schema name for the type. + before: Add new value before this existing value. + after: Add new value after this existing value. + + Raises: + ValueError: If type_name or new_value is empty, or both before and after specified. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + type_name: str, + new_value: str, + schema: Optional[str] = None, + before: Optional[str] = None, + after: Optional[str] = None, + ): + super().__init__(dialect) + if not type_name: + raise ValueError("Enum type name cannot be empty") + if not new_value: + raise ValueError("New value cannot be empty") + if before and after: + raise ValueError("Cannot specify both 'before' and 'after'") + + self.type_name = type_name + self.new_value = new_value + self.schema = schema + self.before = before + self.after = after + + def to_sql(self) -> Tuple[str, tuple]: + """Generate ALTER TYPE ADD VALUE SQL statement. + + Returns: + Tuple of (SQL string, empty params tuple). + """ + full_type_name = f"{self.schema}.{self.type_name}" if self.schema else self.type_name + sql = f"ALTER TYPE {full_type_name} ADD VALUE '{self.new_value}'" + + if self.before: + sql += f" BEFORE '{self.before}'" + elif self.after: + sql += f" AFTER '{self.after}'" + + return (sql, ()) + + +class AlterEnumTypeRenameValueExpression(BaseExpression): + """PostgreSQL ALTER TYPE ... RENAME VALUE statement. + + Renames an existing value in an enum type. + + Attributes: + type_name: Name of the enum type to alter. + old_value: Current value to rename. + new_value: New name for the value. + schema: Schema name for the type. + + Raises: + ValueError: If type_name, old_value, or new_value is empty. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + type_name: str, + old_value: str, + new_value: str, + schema: Optional[str] = None, + ): + super().__init__(dialect) + if not type_name: + raise ValueError("Enum type name cannot be empty") + if not old_value: + raise ValueError("Old value cannot be empty") + if not new_value: + raise ValueError("New value cannot be empty") + + self.type_name = type_name + self.old_value = old_value + self.new_value = new_value + self.schema = schema + + def to_sql(self) -> Tuple[str, tuple]: + """Generate ALTER TYPE RENAME VALUE SQL statement. + + Returns: + Tuple of (SQL string, empty params tuple). + """ + full_type_name = f"{self.schema}.{self.type_name}" if self.schema else self.type_name + sql = f"ALTER TYPE {full_type_name} RENAME VALUE '{self.old_value}' TO '{self.new_value}'" + return (sql, ()) + + +class CreateRangeTypeExpression(BaseExpression): + """PostgreSQL CREATE TYPE ... AS RANGE statement expression. + + Creates a new range type for representing ranges of values. + Useful for date ranges, numeric ranges, etc. + + Attributes: + name: Name of the range type. + subtype: Base type for the range (e.g., 'int4', 'int8', 'date', 'timestamp'). + schema: Schema name for the type. + subtype_opclass: Operator class for the subtype. + collation: Collation for the subtype. + canonical: Canonical function for the range. + subtype_diff: Subtype difference function. + if_not_exists: Add IF NOT EXISTS clause. + + Raises: + ValueError: If name or subtype is empty. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect() + >>> create_range = CreateRangeTypeExpression( + ... dialect=dialect, + ... name="int4range", + ... subtype="int4", + ... ) + >>> sql, params = create_range.to_sql() + >>> sql + "CREATE TYPE int4range AS RANGE (subtype=int4)" + + >>> # Date range with custom functions (PG 9.2+) + >>> create_range = CreateRangeTypeExpression( + ... dialect=dialect, + ... name="date_range", + ... subtype="date", + ... collation="en_US", + ... ) + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + subtype: str, + schema: Optional[str] = None, + subtype_opclass: Optional[str] = None, + collation: Optional[str] = None, + canonical: Optional[str] = None, + subtype_diff: Optional[str] = None, + if_not_exists: bool = False, + ): + super().__init__(dialect) + if not name: + raise ValueError("Range type name cannot be empty") + if not subtype: + raise ValueError("Subtype cannot be empty") + + self.name = name + self.subtype = subtype + self.schema = schema + self.subtype_opclass = subtype_opclass + self.collation = collation + self.canonical = canonical + self.subtype_diff = subtype_diff + self.if_not_exists = if_not_exists + + def to_sql(self) -> Tuple[str, tuple]: + """Generate CREATE TYPE AS RANGE SQL statement. + + Returns: + Tuple of (SQL string, empty params tuple). + """ + exists_clause = "IF NOT EXISTS " if self.if_not_exists else "" + type_name = f"{self.schema}.{self.name}" if self.schema else self.name + + options = [f"subtype={self.subtype}"] + + if self.subtype_opclass: + options.append(f"subtype_opclass={self.subtype_opclass}") + if self.collation: + options.append(f"collation={self.collation}") + if self.canonical: + options.append(f"canonical={self.canonical}") + if self.subtype_diff: + options.append(f"subtype_diff={self.subtype_diff}") + + options_str = ", ".join(options) + sql = f"CREATE TYPE {exists_clause}{type_name} AS RANGE ({options_str})" + return (sql, ()) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py new file mode 100644 index 00000000..369fbfd8 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py @@ -0,0 +1,170 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py +""" +PostgreSQL DDL expressions: VACUUM and ANALYZE. + +PostgreSQL Documentation: +- VACUUM: https://www.postgresql.org/docs/current/sql-vacuum.html +- ANALYZE: https://www.postgresql.org/docs/current/sql-analyze.html + +Version Requirements: +- VACUUM: PostgreSQL 8.0+ +- ANALYZE: PostgreSQL 8.0+ +- PARALLEL option: PostgreSQL 13+ +- INDEX_CLEANUP, PROCESS_TOAST options: PostgreSQL 14+ +- TRUNCATE option: PostgreSQL 14+ +""" + +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = ["VacuumExpression", "AnalyzeExpression"] + + +class VacuumExpression(BaseExpression): + """PostgreSQL VACUUM statement expression. + + Reclaims storage space and updates statistics for a database. + This expression encapsulates all VACUUM options with version-specific feature support. + + Attributes: + table_name: Optional table name to vacuum (vacuum all tables if None). + schema: Schema name for the table. + analyze: Also run ANALYZE after vacuuming. + verbose: Print progress messages. + full: Perform full vacuum (locks table). + freeze: Freeze row transactions. + parallel: Number of parallel workers (PG 13+, 0 disables). + index_cleanup: Index cleanup mode: 'auto', 'on', 'off' (PG 14+). + process_toast: Process TOAST tables: True/False/None (PG 14+). + skip_locked: Skip tables with conflicting locks. + truncate: Truncate empty pages (PG 14+). + columns: Specific columns to analyze (PG 16+). + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect() + >>> vacuum = VacuumExpression( + ... dialect=dialect, + ... table_name="users", + ... analyze=True, + ... verbose=True, + ... ) + >>> sql, params = vacuum.to_sql() + >>> sql + "VACUUM (ANALYZE, VERBOSE) users" + + >>> # Full vacuum with parallel workers (PG 13+) + >>> vacuum = VacuumExpression( + ... dialect=dialect, + ... table_name="orders", + ... full=True, + ... parallel=4, + ... ) + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: Optional[str] = None, + schema: Optional[str] = None, + analyze: bool = False, + verbose: bool = False, + full: bool = False, + freeze: bool = False, + parallel: Optional[int] = None, + index_cleanup: Optional[str] = None, + process_toast: Optional[bool] = None, + skip_locked: bool = False, + truncate: bool = False, + columns: Optional[List[str]] = None, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.table_name = table_name + self.schema = schema + self.analyze = analyze + self.verbose = verbose + self.full = full + self.freeze = freeze + self.parallel = parallel + self.index_cleanup = index_cleanup + self.process_toast = process_toast + self.skip_locked = skip_locked + self.truncate = truncate + self.columns = columns + self.dialect_options = dialect_options or {} + + def to_sql(self) -> Tuple[str, tuple]: + """Generate VACUUM SQL statement. + + Returns: + Tuple of (SQL string, empty params tuple). + """ + return self.dialect.format_vacuum_statement(self) + + +class AnalyzeExpression(BaseExpression): + """PostgreSQL ANALYZE statement expression. + + Collects statistics about the contents of tables in the database. + Statistics are used by the query planner to determine the most efficient execution plans. + + Attributes: + table_name: Optional table name to analyze (analyze all tables if None). + schema: Schema name for the table. + verbose: Print progress messages. + skip_locked: Skip tables with conflicting locks. + columns: Specific columns to analyze (PG 16+). + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect() + >>> analyze = AnalyzeExpression( + ... dialect=dialect, + ... table_name="users", + ... verbose=True, + ... ) + >>> sql, params = analyze.to_sql() + >>> sql + "ANALYZE VERBOSE users" + + >>> # Analyze specific columns (PG 16+) + >>> analyze = AnalyzeExpression( + ... dialect=dialect, + ... table_name="orders", + ... columns=["status", "created_at"], + ... ) + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: Optional[str] = None, + schema: Optional[str] = None, + verbose: bool = False, + skip_locked: bool = False, + columns: Optional[List[str]] = None, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.table_name = table_name + self.schema = schema + self.verbose = verbose + self.skip_locked = skip_locked + self.columns = columns + self.dialect_options = dialect_options or {} + + def to_sql(self) -> Tuple[str, tuple]: + """Generate ANALYZE SQL statement. + + 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 47e0c763..c7f1a768 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/locking.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/locking.py @@ -118,61 +118,12 @@ def to_sql(self) -> "bases.SQLQueryAndParams": """ Generate the SQL representation of the PostgreSQL FOR UPDATE clause. - This method handles PostgreSQL-specific lock strengths (FOR SHARE, - FOR NO KEY UPDATE, FOR KEY SHARE) in addition to the standard - FOR UPDATE clause. + Delegates to the dialect's format_postgres_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 """ - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - - all_params = [] - - # Check version support for lock strength - if self.strength == LockStrength.NO_KEY_UPDATE: - if not self.dialect.supports_for_no_key_update(): - raise UnsupportedFeatureError( - self.dialect.name, "FOR NO KEY UPDATE (requires PostgreSQL 9.0+)" - ) - elif self.strength == LockStrength.SHARE: - if not self.dialect.supports_for_share(): - raise UnsupportedFeatureError( - self.dialect.name, "FOR SHARE (requires PostgreSQL 9.0+)" - ) - elif self.strength == LockStrength.KEY_SHARE: - if not self.dialect.supports_for_key_share(): - raise UnsupportedFeatureError( - self.dialect.name, "FOR KEY SHARE (requires PostgreSQL 9.3+)" - ) - - # Use the strength value directly (e.g., "FOR UPDATE", "FOR SHARE") - sql_parts = [self.strength.value] - - # Handle OF columns if specified - if self.of_columns: - of_parts = [] - for col in self.of_columns: - if isinstance(col, str): - of_parts.append(self.dialect.format_identifier(col)) - else: - # BaseExpression - col_sql, col_params = col.to_sql() - of_parts.append(col_sql) - all_params.extend(col_params) - if of_parts: - sql_parts.append(f"OF {', '.join(of_parts)}") - - # Handle NOWAIT/SKIP LOCKED options - if self.nowait: - sql_parts.append("NOWAIT") - elif self.skip_locked: - if not self.dialect.supports_for_update_skip_locked(): - raise UnsupportedFeatureError( - self.dialect.name, "SKIP LOCKED (requires PostgreSQL 9.5+)" - ) - sql_parts.append("SKIP LOCKED") - - return " ".join(sql_parts), tuple(all_params) \ No newline at end of file + return self.dialect.format_postgres_for_update_clause(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expressions.py b/src/rhosocial/activerecord/backend/impl/postgres/expressions.py deleted file mode 100644 index b08c841e..00000000 --- a/src/rhosocial/activerecord/backend/impl/postgres/expressions.py +++ /dev/null @@ -1,557 +0,0 @@ -# src/rhosocial/activerecord/backend/impl/postgres/expressions.py -""" -PostgreSQL-specific expression classes for SQL statement generation. - -These expression classes encapsulate parameters for PostgreSQL-specific -DDL/DML statements and delegate SQL generation to the dialect. -""" - -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING - -from rhosocial.activerecord.backend.expression.bases import BaseExpression - -if TYPE_CHECKING: - from rhosocial.activerecord.backend.dialect import SQLDialectBase - - -# ============================================================================= -# DML Expressions (VACUUM, ANALYZE) -# ============================================================================= - - -class VacuumExpression(BaseExpression): - """PostgreSQL VACUUM statement expression. - - Encapsulates all VACUUM options with version-specific feature support: - - PG 13+: PARALLEL option - - PG 14+: INDEX_CLEANUP, PROCESS_TOAST options - - Example: - >>> vacuum = VacuumExpression( - ... dialect, - ... table_name="users", - ... verbose=True, - ... analyze=True - ... ) - >>> sql, params = vacuum.to_sql() - """ - - def __init__( - self, - dialect: "SQLDialectBase", - table_name: Optional[str] = None, - schema: Optional[str] = None, - analyze: bool = False, - verbose: bool = False, - full: bool = False, - freeze: bool = False, - parallel: Optional[int] = None, - index_cleanup: Optional[str] = None, - process_toast: Optional[bool] = None, - skip_locked: bool = False, - truncate: bool = False, - columns: Optional[List[str]] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): - super().__init__(dialect) - self.table_name = table_name - self.schema = schema - self.analyze = analyze - self.verbose = verbose - self.full = full - self.freeze = freeze - self.parallel = parallel - self.index_cleanup = index_cleanup - self.process_toast = process_toast - self.skip_locked = skip_locked - self.truncate = truncate - self.columns = columns - self.dialect_options = dialect_options or {} - - def to_sql(self) -> Tuple[str, tuple]: - """Delegate SQL generation to the dialect.""" - return self.dialect.format_vacuum_statement(self) - - -class AnalyzeExpression(BaseExpression): - """PostgreSQL ANALYZE statement expression. - - Example: - >>> analyze = AnalyzeExpression( - ... dialect, - ... table_name="users", - ... verbose=True, - ... columns=["id", "name"] - ... ) - >>> sql, params = analyze.to_sql() - """ - - def __init__( - self, - dialect: "SQLDialectBase", - table_name: Optional[str] = None, - schema: Optional[str] = None, - verbose: bool = False, - skip_locked: bool = False, - columns: Optional[List[str]] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): - super().__init__(dialect) - self.table_name = table_name - self.schema = schema - self.verbose = verbose - self.skip_locked = skip_locked - self.columns = columns - self.dialect_options = dialect_options or {} - - def to_sql(self) -> Tuple[str, tuple]: - """Delegate SQL generation to the dialect.""" - return self.dialect.format_analyze_statement(self) - - -# ============================================================================= -# DDL Expressions (Partition) -# ============================================================================= - - -class CreatePartitionExpression(BaseExpression): - """PostgreSQL CREATE TABLE ... PARTITION OF statement expression. - - Supports RANGE, LIST, and HASH partitioning with version-specific features: - - PG 11+: HASH partitioning, DEFAULT partition - - PG 12+: Partition bounds expression - - Example: - >>> partition = CreatePartitionExpression( - ... dialect, - ... partition_name="users_2024", - ... parent_table="users", - ... partition_type="RANGE", - ... partition_values={"from": "'2024-01-01'", "to": "'2025-01-01'"} - ... ) - >>> sql, params = partition.to_sql() - """ - - def __init__( - self, - dialect: "SQLDialectBase", - partition_name: str, - parent_table: str, - partition_type: str, - partition_values: Dict[str, Any], - schema: Optional[str] = None, - tablespace: Optional[str] = None, - if_not_exists: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): - super().__init__(dialect) - self.partition_name = partition_name - self.parent_table = parent_table - self.partition_type = partition_type - self.partition_values = partition_values - self.schema = schema - self.tablespace = tablespace - self.if_not_exists = if_not_exists - self.dialect_options = dialect_options or {} - - def to_sql(self) -> Tuple[str, tuple]: - """Delegate SQL generation to the dialect.""" - return self.dialect.format_create_partition_statement(self) - - -class DetachPartitionExpression(BaseExpression): - """PostgreSQL ALTER TABLE ... DETACH PARTITION statement expression. - - Supports CONCURRENTLY and FINALIZE options: - - PG 14+: DETACH CONCURRENTLY, FINALIZE - - Example: - >>> detach = DetachPartitionExpression( - ... dialect, - ... partition_name="users_2024", - ... parent_table="users", - ... concurrently=True - ... ) - >>> sql, params = detach.to_sql() - """ - - def __init__( - self, - dialect: "SQLDialectBase", - partition_name: str, - parent_table: str, - schema: Optional[str] = None, - concurrently: bool = False, - finalize: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): - super().__init__(dialect) - self.partition_name = partition_name - self.parent_table = parent_table - self.schema = schema - self.concurrently = concurrently - self.finalize = finalize - self.dialect_options = dialect_options or {} - - def to_sql(self) -> Tuple[str, tuple]: - """Delegate SQL generation to the dialect.""" - return self.dialect.format_detach_partition_statement(self) - - -class AttachPartitionExpression(BaseExpression): - """PostgreSQL ALTER TABLE ... ATTACH PARTITION statement expression. - - Example: - >>> attach = AttachPartitionExpression( - ... dialect, - ... partition_name="users_2024", - ... parent_table="users", - ... partition_type="RANGE", - ... partition_values={"from": "'2024-01-01'", "to": "'2025-01-01'"} - ... ) - >>> sql, params = attach.to_sql() - """ - - def __init__( - self, - dialect: "SQLDialectBase", - partition_name: str, - parent_table: str, - partition_type: str, - partition_values: Dict[str, Any], - schema: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): - super().__init__(dialect) - self.partition_name = partition_name - self.parent_table = parent_table - self.partition_type = partition_type - self.partition_values = partition_values - self.schema = schema - self.dialect_options = dialect_options or {} - - def to_sql(self) -> Tuple[str, tuple]: - """Delegate SQL generation to the dialect.""" - return self.dialect.format_attach_partition_statement(self) - - -# ============================================================================= -# DDL Expressions (Index) -# ============================================================================= - - -class ReindexExpression(BaseExpression): - """PostgreSQL REINDEX statement expression. - - Supports version-specific features: - - PG 12+: CONCURRENTLY option - - PG 14+: TABLESPACE option - - Example: - >>> reindex = ReindexExpression( - ... dialect, - ... target_type="INDEX", - ... name="idx_users_email", - ... concurrently=True - ... ) - >>> sql, params = reindex.to_sql() - """ - - def __init__( - self, - dialect: "SQLDialectBase", - target_type: str, # INDEX, TABLE, SCHEMA, DATABASE - name: str, - schema: Optional[str] = None, - tablespace: Optional[str] = None, - concurrently: bool = False, - verbose: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): - super().__init__(dialect) - self.target_type = target_type - self.name = name - self.schema = schema - self.tablespace = tablespace - self.concurrently = concurrently - self.verbose = verbose - self.dialect_options = dialect_options or {} - - def to_sql(self) -> Tuple[str, tuple]: - """Delegate SQL generation to the dialect.""" - return self.dialect.format_reindex_statement(self) - - -# ============================================================================= -# DDL Expressions (Statistics) -# ============================================================================= - - -class CreateStatisticsExpression(BaseExpression): - """PostgreSQL CREATE STATISTICS statement expression. - - Example: - >>> stats = CreateStatisticsExpression( - ... dialect, - ... name="user_stats", - ... columns=["email", "created_at"], - ... table_name="users", - ... statistics_type="ndistinct" - ... ) - >>> sql, params = stats.to_sql() - """ - - def __init__( - self, - dialect: "SQLDialectBase", - name: str, - columns: List[str], - table_name: str, - schema: Optional[str] = None, - statistics_type: Optional[str] = None, # ndistinct, dependencies, mcv - if_not_exists: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): - super().__init__(dialect) - self.name = name - self.columns = columns - self.table_name = table_name - self.schema = schema - self.statistics_type = statistics_type - self.if_not_exists = if_not_exists - self.dialect_options = dialect_options or {} - - def to_sql(self) -> Tuple[str, tuple]: - """Delegate SQL generation to the dialect.""" - return self.dialect.format_create_statistics_statement(self) - - -class DropStatisticsExpression(BaseExpression): - """PostgreSQL DROP STATISTICS statement expression. - - Example: - >>> drop_stats = DropStatisticsExpression( - ... dialect, - ... name="user_stats", - ... if_exists=True - ... ) - >>> sql, params = drop_stats.to_sql() - """ - - def __init__( - self, - dialect: "SQLDialectBase", - name: str, - schema: Optional[str] = None, - if_exists: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): - super().__init__(dialect) - self.name = name - self.schema = schema - self.if_exists = if_exists - self.dialect_options = dialect_options or {} - - def to_sql(self) -> Tuple[str, tuple]: - """Delegate SQL generation to the dialect.""" - return self.dialect.format_drop_statistics_statement(self) - - -# ============================================================================= -# DDL Expressions (Comment) -# ============================================================================= - - -class CommentExpression(BaseExpression): - """PostgreSQL COMMENT ON statement expression. - - Example: - >>> comment = CommentExpression( - ... dialect, - ... object_type="TABLE", - ... object_name="users", - ... comment="User accounts table" - ... ) - >>> sql, params = comment.to_sql() - """ - - def __init__( - self, - dialect: "SQLDialectBase", - object_type: str, # TABLE, COLUMN, INDEX, etc. - object_name: str, - comment: Optional[str], - schema: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): - super().__init__(dialect) - self.object_type = object_type - self.object_name = object_name - self.comment = comment - self.schema = schema - self.dialect_options = dialect_options or {} - - def to_sql(self) -> Tuple[str, tuple]: - """Delegate SQL generation to the dialect.""" - return self.dialect.format_comment_statement(self) - - -# ============================================================================= -# DDL Expressions (Materialized View) -# ============================================================================= - - -class RefreshMaterializedViewPgExpression(BaseExpression): - """PostgreSQL REFRESH MATERIALIZED VIEW statement expression. - - Supports CONCURRENTLY option (PG 9.4+). - - Example: - >>> refresh = RefreshMaterializedViewPgExpression( - ... dialect, - ... name="user_stats_mv", - ... concurrently=True, - ... with_data=False - ... ) - >>> sql, params = refresh.to_sql() - """ - - def __init__( - self, - dialect: "SQLDialectBase", - name: str, - schema: Optional[str] = None, - concurrently: bool = False, - with_data: Optional[bool] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): - super().__init__(dialect) - self.name = name - self.schema = schema - self.concurrently = concurrently - self.with_data = with_data - self.dialect_options = dialect_options or {} - - def to_sql(self) -> Tuple[str, tuple]: - """Delegate SQL generation to the dialect.""" - return self.dialect.format_refresh_materialized_view_pg_statement(self) - - -# ============================================================================= -# DDL Expressions (Enum) -# ============================================================================= - - -class CreateEnumTypeExpression(BaseExpression): - """PostgreSQL CREATE TYPE ... AS ENUM statement expression. - - Example: - >>> enum_type = CreateEnumTypeExpression( - ... dialect, - ... name="status_type", - ... values=["active", "inactive", "pending"] - ... ) - >>> sql, params = enum_type.to_sql() - """ - - def __init__( - self, - dialect: "SQLDialectBase", - name: str, - values: List[str], - schema: Optional[str] = None, - if_not_exists: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): - super().__init__(dialect) - self.name = name - self.values = values - self.schema = schema - self.if_not_exists = if_not_exists - self.dialect_options = dialect_options or {} - - def to_sql(self) -> Tuple[str, tuple]: - """Delegate SQL generation to the dialect.""" - return self.dialect.format_create_enum_type(self) - - -class DropEnumTypeExpression(BaseExpression): - """PostgreSQL DROP TYPE statement expression for enum types. - - Example: - >>> drop_enum = DropEnumTypeExpression( - ... dialect, - ... name="status_type", - ... if_exists=True - ... ) - >>> sql, params = drop_enum.to_sql() - """ - - def __init__( - self, - dialect: "SQLDialectBase", - name: str, - schema: Optional[str] = None, - if_exists: bool = False, - cascade: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): - super().__init__(dialect) - self.name = name - self.schema = schema - self.if_exists = if_exists - self.cascade = cascade - self.dialect_options = dialect_options or {} - - def to_sql(self) -> Tuple[str, tuple]: - """Delegate SQL generation to the dialect.""" - return self.dialect.format_drop_enum_type(self) - - -class AlterEnumAddValueExpression(BaseExpression): - """PostgreSQL ALTER TYPE ... ADD VALUE statement expression. - - Example: - >>> alter_enum = AlterEnumAddValueExpression( - ... dialect, - ... type_name="status_type", - ... new_value="suspended", - ... before="inactive" - ... ) - >>> sql, params = alter_enum.to_sql() - """ - - def __init__( - self, - dialect: "SQLDialectBase", - type_name: str, - new_value: str, - schema: Optional[str] = None, - before: Optional[str] = None, - after: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, - ): - super().__init__(dialect) - self.type_name = type_name - self.new_value = new_value - self.schema = schema - self.before = before - self.after = after - self.dialect_options = dialect_options or {} - - def to_sql(self) -> Tuple[str, tuple]: - """Delegate SQL generation to the dialect.""" - return self.dialect.format_alter_enum_add_value(self) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py index 07c80018..8cb6ce69 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py @@ -28,6 +28,7 @@ from .dml.stored_procedure import PostgresStoredProcedureMixin from .dml.extended_statistics import PostgresExtendedStatisticsMixin from .dml.advisory_lock import PostgresAdvisoryLockMixin +from .dml.locking import PostgresLockingMixin # Type mixins from .types.enum import EnumTypeMixin @@ -70,6 +71,7 @@ "PostgresStoredProcedureMixin", "PostgresExtendedStatisticsMixin", "PostgresAdvisoryLockMixin", + "PostgresLockingMixin", # Type mixins "EnumTypeMixin", "TypesDataTypeMixin", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/comment.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/comment.py index 0a9edd89..2ecfb59a 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/comment.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/comment.py @@ -4,7 +4,7 @@ from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ...expressions import CommentExpression + from ...expression.ddl import CommentExpression class PostgresCommentMixin: 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 347e5988..f4f28492 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py @@ -8,7 +8,7 @@ from typing import Any, Dict, Optional, Tuple, List, TYPE_CHECKING if TYPE_CHECKING: - from ...expressions import ReindexExpression + from ...expression.ddl import ReindexExpression class PostgresIndexMixin: 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 0be75039..b127c3cf 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py @@ -9,7 +9,7 @@ from typing import Any, Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ...expressions import ( + from ...expression.ddl import ( CreatePartitionExpression, DetachPartitionExpression, AttachPartitionExpression, diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/__init__.py index db91fa51..9cd264f7 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/__init__.py @@ -4,9 +4,11 @@ from .vacuum import PostgresVacuumMixin from .stored_procedure import PostgresStoredProcedureMixin from .extended_statistics import PostgresExtendedStatisticsMixin +from .locking import PostgresLockingMixin __all__ = [ "PostgresVacuumMixin", "PostgresStoredProcedureMixin", "PostgresExtendedStatisticsMixin", + "PostgresLockingMixin", ] 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 da907944..b439d19d 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 @@ -8,7 +8,7 @@ from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ...expressions import CreateStatisticsExpression, DropStatisticsExpression + from ...expression.ddl import CreateStatisticsExpression, DropStatisticsExpression class PostgresExtendedStatisticsMixin: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/locking.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/locking.py new file mode 100644 index 00000000..43b7a187 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/locking.py @@ -0,0 +1,94 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/locking.py +"""PostgreSQL row-level locking implementation. + +This module provides the PostgresLockingMixin class for generating +PostgreSQL-specific FOR UPDATE clauses with lock strength support. +""" + +from typing import Tuple + + +class PostgresLockingMixin: + """PostgreSQL row-level locking implementation. + + All features are native, using version number for detection. + """ + + def supports_for_no_key_update(self) -> bool: + """FOR NO KEY UPDATE is native feature, PG 9.0+.""" + return self.version >= (9, 0, 0) + + def supports_for_share(self) -> bool: + """FOR SHARE is native feature, PG 9.0+.""" + return self.version >= (9, 0, 0) + + def supports_for_key_share(self) -> bool: + """FOR KEY SHARE is native feature, PG 9.3+.""" + return self.version >= (9, 3, 0) + + def supports_for_update_skip_locked(self) -> bool: + """FOR UPDATE SKIP LOCKED is native feature, PG 9.5+.""" + return self.version >= (9, 5, 0) + + def format_postgres_for_update_clause(self, clause) -> Tuple[str, tuple]: + """Format PostgreSQL-specific FOR UPDATE clause. + + Handles PostgreSQL-specific lock strengths (FOR NO KEY UPDATE, + FOR SHARE, FOR KEY SHARE) and options (NOWAIT, SKIP LOCKED). + + Args: + clause: PostgresForUpdateClause instance + + Returns: + Tuple of (SQL string, parameters tuple) + """ + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + from rhosocial.activerecord.backend.impl.postgres.expression.locking import LockStrength + + all_params = [] + + # Check version support for lock strength + if clause.strength == LockStrength.NO_KEY_UPDATE: + if not self.supports_for_no_key_update(): + raise UnsupportedFeatureError( + self.name, "FOR NO KEY UPDATE (requires PostgreSQL 9.0+)" + ) + elif clause.strength == LockStrength.SHARE: + if not self.supports_for_share(): + raise UnsupportedFeatureError( + self.name, "FOR SHARE (requires PostgreSQL 9.0+)" + ) + elif clause.strength == LockStrength.KEY_SHARE: + if not self.supports_for_key_share(): + raise UnsupportedFeatureError( + self.name, "FOR KEY SHARE (requires PostgreSQL 9.3+)" + ) + + # Use the strength value directly (e.g., "FOR UPDATE", "FOR SHARE") + sql_parts = [clause.strength.value] + + # Handle OF columns if specified + if clause.of_columns: + of_parts = [] + for col in clause.of_columns: + if isinstance(col, str): + of_parts.append(self.format_identifier(col)) + else: + # BaseExpression + col_sql, col_params = col.to_sql() + of_parts.append(col_sql) + all_params.extend(col_params) + if of_parts: + sql_parts.append(f"OF {', '.join(of_parts)}") + + # Handle NOWAIT/SKIP LOCKED options + if clause.nowait: + sql_parts.append("NOWAIT") + elif clause.skip_locked: + if not self.supports_for_update_skip_locked(): + raise UnsupportedFeatureError( + self.name, "SKIP LOCKED (requires PostgreSQL 9.5+)" + ) + sql_parts.append("SKIP LOCKED") + + return " ".join(sql_parts), tuple(all_params) 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 c1a5276f..37c5d475 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/vacuum.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/vacuum.py @@ -8,7 +8,7 @@ from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ...expressions import VacuumExpression, AnalyzeExpression + from ...expression.ddl import VacuumExpression, AnalyzeExpression class PostgresVacuumMixin: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/materialized_view.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/materialized_view.py index 8076d21d..287e9072 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/materialized_view.py @@ -2,7 +2,7 @@ from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ...expressions import RefreshMaterializedViewPgExpression + from ...expression.ddl import RefreshMaterializedViewPgExpression class PostgresMaterializedViewMixin: 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 2287715c..207aa0b0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/enum.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/enum.py @@ -5,10 +5,14 @@ enumerated type operations. """ -from typing import Optional, List, TYPE_CHECKING +from typing import Optional, List, Tuple, TYPE_CHECKING, Union if TYPE_CHECKING: - pass + from rhosocial.activerecord.backend.impl.postgres.expression.ddl.type import ( + CreateEnumTypeExpression, + DropEnumTypeExpression, + AlterEnumAddValueExpression, + ) class EnumTypeMixin: @@ -42,7 +46,7 @@ def format_enum_values(self, values: List[str]) -> str: """ return ", ".join(f"'{v}'" for v in values) - def format_create_enum_type( + def format_create_enum_type_raw( self, name: str, values: List[str], schema: Optional[str] = None, if_not_exists: bool = False ) -> str: """Format CREATE TYPE statement for enum. @@ -61,7 +65,7 @@ def format_create_enum_type( exists_clause = "IF NOT EXISTS " if if_not_exists else "" return f"CREATE TYPE {exists_clause}{full_name} AS ENUM ({values_str})" - def format_drop_enum_type( + def format_drop_enum_type_raw( self, name: str, schema: Optional[str] = None, if_exists: bool = False, cascade: bool = False ) -> str: """Format DROP TYPE statement. @@ -80,14 +84,10 @@ def format_drop_enum_type( cascade_clause = " CASCADE" if cascade else "" return f"DROP TYPE {exists_clause}{full_name}{cascade_clause}" - def format_alter_enum_add_value( - self, - type_name: str, - new_value: str, - schema: Optional[str] = None, - before: Optional[str] = None, - after: Optional[str] = None, - ) -> str: + def format_alter_enum_add_value_raw( + self, type_name: str, new_value: str, schema: Optional[str] = None, + before: Optional[str] = None, after: Optional[str] = None + ) -> str: """Format ALTER TYPE ADD VALUE statement. Args: @@ -128,7 +128,7 @@ def create_enum_type( Returns: SQL statement string """ - return self.format_create_enum_type(name, values, schema, if_not_exists) + return self.format_create_enum_type_raw(name, values, schema, if_not_exists) def drop_enum_type( self, name: str, schema: Optional[str] = None, if_exists: bool = False, cascade: bool = False @@ -146,7 +146,7 @@ def drop_enum_type( Returns: SQL statement string """ - return self.format_drop_enum_type(name, schema, if_exists, cascade) + return self.format_drop_enum_type_raw(name, schema, if_exists, cascade) def alter_enum_add_value( self, @@ -172,4 +172,73 @@ def alter_enum_add_value( Returns: SQL statement string """ - return self.format_alter_enum_add_value(type_name, new_value, schema, before, after) + return self.format_alter_enum_add_value_raw(type_name, new_value, schema, before, after) + + # ========================================================================= + # Expression-based methods (for expression-dialect architecture) + # ========================================================================= + + def format_create_enum_type( + self, + expr_or_name: Union["CreateEnumTypeExpression", str], + values: Optional[List[str]] = None, + 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.""" + if hasattr(expr_or_name, "values"): + sql = self.format_create_enum_type_raw( + expr_or_name.name, + expr_or_name.values, + expr_or_name.schema, + expr_or_name.if_not_exists, + ) + return (sql, ()) + + if values is None: + raise TypeError("values must be provided when formatting enum type by name") + + return self.format_create_enum_type_raw(expr_or_name, values, schema, if_not_exists) + + def format_drop_enum_type( + self, + expr_or_name: Union["DropEnumTypeExpression", str], + schema: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + ) -> Union[str, Tuple[str, tuple]]: + """Format DROP TYPE for either the legacy or expression-based API.""" + if hasattr(expr_or_name, "if_exists"): + sql = self.format_drop_enum_type_raw( + expr_or_name.name, + expr_or_name.schema, + expr_or_name.if_exists, + expr_or_name.cascade, + ) + return (sql, ()) + + return self.format_drop_enum_type_raw(expr_or_name, schema, if_exists, cascade) + + def format_alter_enum_add_value( + self, + expr_or_type_name: Union["AlterEnumAddValueExpression", str], + new_value: Optional[str] = None, + schema: Optional[str] = None, + 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.""" + if hasattr(expr_or_type_name, "new_value"): + sql = self.format_alter_enum_add_value_raw( + expr_or_type_name.type_name, + expr_or_type_name.new_value, + expr_or_type_name.schema, + expr_or_type_name.before, + expr_or_type_name.after, + ) + return (sql, ()) + + if new_value is None: + raise TypeError("new_value must be provided when formatting enum value by name") + + return self.format_alter_enum_add_value_raw(expr_or_type_name, new_value, schema, before, after) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py index 56ab0f7c..37436e9e 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py @@ -27,11 +27,12 @@ from .dml.stored_procedure import PostgresStoredProcedureSupport from .dml.extended_statistics import PostgresExtendedStatisticsSupport from .dml.advisory_lock import PostgresAdvisoryLockSupport +from .dml.locking import PostgresLockingSupport # Type protocols from .types.data_type import PostgresDataTypeSupport -from .types.multirange import MultirangeSupport -from .types.enum import EnumTypeSupport +from .types.multirange import PostgresMultirangeSupport +from .types.enum import PostgresEnumTypeSupport # Extension protocols from .extensions.pgvector import PostgresPgvectorSupport @@ -69,10 +70,9 @@ "PostgresStoredProcedureSupport", "PostgresExtendedStatisticsSupport", "PostgresAdvisoryLockSupport", + "PostgresLockingSupport", # Type protocols "PostgresDataTypeSupport", - "MultirangeSupport", - "EnumTypeSupport", # Extension protocols "PostgresPgvectorSupport", "PostgresPostGISSupport", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/comment.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/comment.py index b29052cd..fe9b3a4d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/comment.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/comment.py @@ -8,7 +8,7 @@ from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ...expressions import CommentExpression + from ...expression.ddl import CommentExpression @runtime_checkable 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 8a2f20d6..b2c16f4a 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/index.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/index.py @@ -8,7 +8,7 @@ from typing import Protocol, runtime_checkable, Optional, Tuple, List, Dict, Any, TYPE_CHECKING if TYPE_CHECKING: - from ...expressions import ReindexExpression + from ...expression.ddl import ReindexExpression @runtime_checkable 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 ccc37c47..d14fe58d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py @@ -8,7 +8,7 @@ from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ...expressions import ( + from ...expression.ddl import ( CreatePartitionExpression, DetachPartitionExpression, ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/__init__.py index 1863e6d5..7e25d59c 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/__init__.py @@ -4,9 +4,11 @@ from .vacuum import PostgresVacuumSupport from .stored_procedure import PostgresStoredProcedureSupport from .extended_statistics import PostgresExtendedStatisticsSupport +from .locking import PostgresLockingSupport __all__ = [ "PostgresVacuumSupport", "PostgresStoredProcedureSupport", "PostgresExtendedStatisticsSupport", + "PostgresLockingSupport", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/extended_statistics.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/extended_statistics.py index c90ef0e8..d3485b63 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/extended_statistics.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/extended_statistics.py @@ -8,7 +8,7 @@ from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ...expressions import CreateStatisticsExpression, DropStatisticsExpression + from ...expression.ddl import CreateStatisticsExpression, DropStatisticsExpression @runtime_checkable diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/locking.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/locking.py new file mode 100644 index 00000000..0531d9be --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/locking.py @@ -0,0 +1,58 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/locking.py +"""PostgreSQL row-level locking protocol definition. + +This module defines the protocol for PostgreSQL-specific row-level locking +features that are not part of the SQL standard. +""" + +from typing import Any, Protocol, runtime_checkable, Tuple + + +@runtime_checkable +class PostgresLockingSupport(Protocol): + """PostgreSQL row-level locking protocol. + + Feature Source: Native support (no extension required) + + PostgreSQL locking features beyond SQL standard: + - FOR NO KEY UPDATE: Weaker exclusive lock (PG 9.0+) + - FOR SHARE: Shared lock (PG 9.0+) + - FOR KEY SHARE: Weakest shared lock (PG 9.3+) + - SKIP LOCKED: Skip locked rows (PG 9.5+) + + Official Documentation: + - Row Locking: https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE + + Version Requirements: + - FOR NO KEY UPDATE: PostgreSQL 9.0+ + - FOR SHARE: PostgreSQL 9.0+ + - FOR KEY SHARE: PostgreSQL 9.3+ + - SKIP LOCKED: PostgreSQL 9.5+ + """ + + def supports_for_no_key_update(self) -> bool: + """Whether FOR NO KEY UPDATE is supported (PostgreSQL 9.0+).""" + ... + + def supports_for_share(self) -> bool: + """Whether FOR SHARE is supported (PostgreSQL 9.0+).""" + ... + + def supports_for_key_share(self) -> bool: + """Whether FOR KEY SHARE is supported (PostgreSQL 9.3+).""" + ... + + def supports_for_update_skip_locked(self) -> bool: + """Whether FOR UPDATE SKIP LOCKED is supported (PostgreSQL 9.5+).""" + ... + + def format_postgres_for_update_clause(self, clause: Any) -> Tuple[str, tuple]: + """Format PostgreSQL-specific FOR UPDATE clause. + + Args: + clause: PostgresForUpdateClause instance + + Returns: + Tuple of (SQL string, parameters tuple) + """ + ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/vacuum.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/vacuum.py index 09c3f58e..1fa840e4 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/vacuum.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/vacuum.py @@ -8,7 +8,7 @@ from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ...expressions import VacuumExpression, AnalyzeExpression + from ...expression.ddl import VacuumExpression, AnalyzeExpression @runtime_checkable diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/materialized_view.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/materialized_view.py index 40f924a3..ca6a2fef 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/materialized_view.py @@ -8,7 +8,7 @@ from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ...expressions import RefreshMaterializedViewPgExpression + from ...expression.ddl import RefreshMaterializedViewPgExpression @runtime_checkable diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/__init__.py index abb3d4ce..15480d8f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/__init__.py @@ -2,11 +2,11 @@ """Data type related PostgreSQL protocols.""" from .data_type import PostgresDataTypeSupport -from .multirange import MultirangeSupport -from .enum import EnumTypeSupport +from .multirange import PostgresMultirangeSupport +from .enum import PostgresEnumTypeSupport __all__ = [ "PostgresDataTypeSupport", - "MultirangeSupport", - "EnumTypeSupport", -] + "PostgresMultirangeSupport", + "PostgresEnumTypeSupport", +] \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/enum.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/enum.py index 0d740871..124454e6 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/enum.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/enum.py @@ -8,7 +8,7 @@ @runtime_checkable -class EnumTypeSupport(Protocol): +class PostgresEnumTypeSupport(Protocol): """PostgreSQL ENUM type management protocol. PostgreSQL ENUM types are custom types created with CREATE TYPE. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/multirange.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/multirange.py index 3ce9d4a9..bfc031c4 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/multirange.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/multirange.py @@ -4,7 +4,7 @@ from typing import Protocol -class MultirangeSupport(Protocol): +class PostgresMultirangeSupport(Protocol): """PostgreSQL multirange type support protocol. PostgreSQL 14 introduced multirange types, which are arrays of diff --git a/src/rhosocial/activerecord/backend/impl/postgres/statements.py b/src/rhosocial/activerecord/backend/impl/postgres/statements.py deleted file mode 100644 index a054e765..00000000 --- a/src/rhosocial/activerecord/backend/impl/postgres/statements.py +++ /dev/null @@ -1,403 +0,0 @@ -# src/rhosocial/activerecord/backend/impl/postgres/statements.py -"""PostgreSQL-specific DDL (Data Definition Language) statements. - -These expression classes are responsible for collecting the parameters and structure -for PostgreSQL-specific SQL statements and delegating the actual SQL string generation -to the PostgreSQL dialect. - -This follows the expression-dialect separation architecture from the main package. -""" - -from typing import List, Optional, Tuple, TYPE_CHECKING - -from rhosocial.activerecord.backend.expression.bases import BaseExpression - -if TYPE_CHECKING: - from rhosocial.activerecord.backend.dialect import SQLDialectBase - - -# region ENUM Type DDL Statements - - -class CreateEnumTypeExpression(BaseExpression): - """PostgreSQL CREATE TYPE ... AS ENUM statement. - - This expression represents the creation of a PostgreSQL ENUM type. - ENUM types in PostgreSQL are reusable custom types created with CREATE TYPE. - - Examples: - >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect - >>> dialect = PostgresDialect() - >>> create_enum = CreateEnumTypeExpression( - ... dialect=dialect, - ... name='status', - ... values=['pending', 'processing', 'ready', 'failed'] - ... ) - >>> create_enum.to_sql() - ("CREATE TYPE status AS ENUM ('pending', 'processing', 'ready', 'failed')", ()) - - >>> # With schema - >>> create_enum = CreateEnumTypeExpression( - ... dialect=dialect, - ... name='status', - ... values=['draft', 'published'], - ... schema='app', - ... if_not_exists=True - ... ) - >>> create_enum.to_sql() - ("CREATE TYPE IF NOT EXISTS app.status AS ENUM ('draft', 'published')", ()) - """ - - def __init__( - self, - dialect: "SQLDialectBase", - name: str, - values: List[str], - schema: Optional[str] = None, - if_not_exists: bool = False, - ): - """Initialize CREATE TYPE expression. - - Args: - dialect: SQL dialect instance - name: ENUM type name - values: List of allowed values - schema: Optional schema name - if_not_exists: Add IF NOT EXISTS clause (PostgreSQL 9.1+) - - Raises: - ValueError: If name is empty or values is empty - """ - super().__init__(dialect) - - if not name: - raise ValueError("Enum type name cannot be empty") - if not values: - raise ValueError("ENUM must have at least one value") - if len(values) != len(set(values)): - raise ValueError("ENUM values must be unique") - - self.name = name - self.values = list(values) - self.schema = schema - self.if_not_exists = if_not_exists - - def to_sql(self) -> Tuple[str, tuple]: - """Generate CREATE TYPE SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple) - """ - values_str = ", ".join(f"'{v}'" for v in self.values) - exists_clause = "IF NOT EXISTS " if self.if_not_exists else "" - type_name = f"{self.schema}.{self.name}" if self.schema else self.name - sql = f"CREATE TYPE {exists_clause}{type_name} AS ENUM ({values_str})" - return (sql, ()) - - -class DropEnumTypeExpression(BaseExpression): - """PostgreSQL DROP TYPE statement for ENUM types. - - This expression represents dropping an ENUM type from the database. - - Examples: - >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect - >>> dialect = PostgresDialect() - >>> drop_enum = DropEnumTypeExpression( - ... dialect=dialect, - ... name='status' - ... ) - >>> drop_enum.to_sql() - ('DROP TYPE status', ()) - - >>> # With options - >>> drop_enum = DropEnumTypeExpression( - ... dialect=dialect, - ... name='status', - ... schema='app', - ... if_exists=True, - ... cascade=True - ... ) - >>> drop_enum.to_sql() - ('DROP TYPE IF EXISTS app.status CASCADE', ()) - """ - - def __init__( - self, - dialect: "SQLDialectBase", - name: str, - schema: Optional[str] = None, - if_exists: bool = False, - cascade: bool = False, - ): - """Initialize DROP TYPE expression. - - Args: - dialect: SQL dialect instance - name: ENUM type name - schema: Optional schema name - if_exists: Add IF EXISTS clause - cascade: Add CASCADE clause - - Raises: - ValueError: If name is empty - """ - super().__init__(dialect) - - if not name: - raise ValueError("Enum type name cannot be empty") - - self.name = name - self.schema = schema - self.if_exists = if_exists - self.cascade = cascade - - def to_sql(self) -> Tuple[str, tuple]: - """Generate DROP TYPE SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple) - """ - exists_clause = "IF EXISTS " if self.if_exists else "" - cascade_clause = " CASCADE" if self.cascade else "" - type_name = f"{self.schema}.{self.name}" if self.schema else self.name - sql = f"DROP TYPE {exists_clause}{type_name}{cascade_clause}" - return (sql, ()) - - -class AlterEnumTypeAddValueExpression(BaseExpression): - """PostgreSQL ALTER TYPE ... ADD VALUE statement. - - This expression represents adding a new value to an existing ENUM type. - - Note: Requires PostgreSQL 9.1+ - - Examples: - >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect - >>> dialect = PostgresDialect() - >>> add_value = AlterEnumTypeAddValueExpression( - ... dialect=dialect, - ... type_name='status', - ... new_value='cancelled' - ... ) - >>> add_value.to_sql() - ("ALTER TYPE status ADD VALUE 'cancelled'", ()) - - >>> # With position - >>> add_value = AlterEnumTypeAddValueExpression( - ... dialect=dialect, - ... type_name='status', - ... new_value='cancelled', - ... before='pending' - ... ) - >>> add_value.to_sql() - ("ALTER TYPE status ADD VALUE 'cancelled' BEFORE 'pending'", ()) - """ - - def __init__( - self, - dialect: "SQLDialectBase", - type_name: str, - new_value: str, - schema: Optional[str] = None, - before: Optional[str] = None, - after: Optional[str] = None, - ): - """Initialize ALTER TYPE ADD VALUE expression. - - Args: - dialect: SQL dialect instance - type_name: ENUM type name - new_value: New value to add - schema: Optional schema name - before: Add before this value (optional) - after: Add after this value (optional) - - Raises: - ValueError: If type_name or new_value is empty, or both before and after specified - """ - super().__init__(dialect) - - if not type_name: - raise ValueError("Enum type name cannot be empty") - if not new_value: - raise ValueError("New value cannot be empty") - if before and after: - raise ValueError("Cannot specify both 'before' and 'after'") - - self.type_name = type_name - self.new_value = new_value - self.schema = schema - self.before = before - self.after = after - - def to_sql(self) -> Tuple[str, tuple]: - """Generate ALTER TYPE ADD VALUE SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple) - """ - full_type_name = f"{self.schema}.{self.type_name}" if self.schema else self.type_name - sql = f"ALTER TYPE {full_type_name} ADD VALUE '{self.new_value}'" - - if self.before: - sql += f" BEFORE '{self.before}'" - elif self.after: - sql += f" AFTER '{self.after}'" - - return (sql, ()) - - -class AlterEnumTypeRenameValueExpression(BaseExpression): - """PostgreSQL ALTER TYPE ... RENAME VALUE statement. - - This expression represents renaming a value in an existing ENUM type. - - Note: Requires PostgreSQL 9.1+ - - Examples: - >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect - >>> dialect = PostgresDialect() - >>> rename_value = AlterEnumTypeRenameValueExpression( - ... dialect=dialect, - ... type_name='status', - ... old_value='pending', - ... new_value='waiting' - ... ) - >>> rename_value.to_sql() - ("ALTER TYPE status RENAME VALUE 'pending' TO 'waiting'", ()) - """ - - def __init__( - self, dialect: "SQLDialectBase", type_name: str, old_value: str, new_value: str, schema: Optional[str] = None - ): - """Initialize ALTER TYPE RENAME VALUE expression. - - Args: - dialect: SQL dialect instance - type_name: ENUM type name - old_value: Current value name - new_value: New value name - schema: Optional schema name - - Raises: - ValueError: If any required string parameter is empty - """ - super().__init__(dialect) - - if not type_name: - raise ValueError("Enum type name cannot be empty") - if not old_value: - raise ValueError("Old value cannot be empty") - if not new_value: - raise ValueError("New value cannot be empty") - - self.type_name = type_name - self.old_value = old_value - self.new_value = new_value - self.schema = schema - - def to_sql(self) -> Tuple[str, tuple]: - """Generate ALTER TYPE RENAME VALUE SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple) - """ - full_type_name = f"{self.schema}.{self.type_name}" if self.schema else self.type_name - sql = f"ALTER TYPE {full_type_name} RENAME VALUE '{self.old_value}' TO '{self.new_value}'" - return (sql, ()) - - -# endregion ENUM Type DDL Statements - - -# region Range Type DDL Statements - - -class CreateRangeTypeExpression(BaseExpression): - """PostgreSQL CREATE TYPE ... AS RANGE statement. - - This expression represents the creation of a PostgreSQL RANGE type. - - Examples: - >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect - >>> dialect = PostgresDialect() - >>> create_range = CreateRangeTypeExpression( - ... dialect=dialect, - ... name='float_range', - ... subtype='float8' - ... ) - >>> create_range.to_sql() - ('CREATE TYPE float_range AS RANGE (subtype=float8)', ()) - """ - - def __init__( - self, - dialect: "SQLDialectBase", - name: str, - subtype: str, - schema: Optional[str] = None, - subtype_opclass: Optional[str] = None, - collation: Optional[str] = None, - canonical: Optional[str] = None, - subtype_diff: Optional[str] = None, - if_not_exists: bool = False, - ): - """Initialize CREATE RANGE TYPE expression. - - Args: - dialect: SQL dialect instance - name: Range type name - subtype: Subtype (base type) for the range - schema: Optional schema name - subtype_opclass: Optional subtype operator class - collation: Optional collation for the subtype - canonical: Optional canonical function - subtype_diff: Optional subtype difference function - if_not_exists: Add IF NOT EXISTS clause (PostgreSQL 9.1+) - - Raises: - ValueError: If name or subtype is empty - """ - super().__init__(dialect) - - if not name: - raise ValueError("Range type name cannot be empty") - if not subtype: - raise ValueError("Subtype cannot be empty") - - self.name = name - self.subtype = subtype - self.schema = schema - self.subtype_opclass = subtype_opclass - self.collation = collation - self.canonical = canonical - self.subtype_diff = subtype_diff - self.if_not_exists = if_not_exists - - def to_sql(self) -> Tuple[str, tuple]: - """Generate CREATE TYPE AS RANGE SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple) - """ - exists_clause = "IF NOT EXISTS " if self.if_not_exists else "" - type_name = f"{self.schema}.{self.name}" if self.schema else self.name - - options = [f"subtype={self.subtype}"] - - if self.subtype_opclass: - options.append(f"subtype_opclass={self.subtype_opclass}") - if self.collation: - options.append(f"collation={self.collation}") - if self.canonical: - options.append(f"canonical={self.canonical}") - if self.subtype_diff: - options.append(f"subtype_diff={self.subtype_diff}") - - options_str = ", ".join(options) - sql = f"CREATE TYPE {exists_clause}{type_name} AS RANGE ({options_str})" - return (sql, ()) - - -# endregion Range Type DDL Statements diff --git a/src/rhosocial/activerecord/backend/impl/postgres/types/enum.py b/src/rhosocial/activerecord/backend/impl/postgres/types/enum.py index 578b5e5d..3a836bb3 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/types/enum.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/types/enum.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase -from ..statements import ( +from ..expression.ddl import ( CreateEnumTypeExpression, DropEnumTypeExpression, AlterEnumTypeAddValueExpression, 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 new file mode 100644 index 00000000..55b0ba35 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py @@ -0,0 +1,437 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py +"""Tests for PostgreSQL DDL expression classes. + +This module tests the expression-based format methods for DDL operations, +including materialized view refresh, comment, and partition expressions. +""" +import pytest + +import pytest +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + RefreshMaterializedViewPgExpression, + CommentExpression, + CreatePartitionExpression, + DetachPartitionExpression, + AttachPartitionExpression, + VacuumExpression, + AnalyzeExpression, +) +from rhosocial.activerecord.backend.impl.postgres.mixins.dml.extended_statistics import ( + PostgresExtendedStatisticsMixin, +) + + +class TestRefreshMaterializedViewPgExpression: + """Test RefreshMaterializedViewPgExpression.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_basic_refresh(self, dialect): + """Test basic REFRESH MATERIALIZED VIEW statement.""" + expr = RefreshMaterializedViewPgExpression( + dialect=dialect, + name="monthly_sales_summary", + ) + sql, params = expr.to_sql() + assert sql == "REFRESH MATERIALIZED VIEW monthly_sales_summary" + assert params == () + + def test_refresh_with_schema(self, dialect): + """Test REFRESH MATERIALIZED VIEW with schema.""" + expr = RefreshMaterializedViewPgExpression( + dialect=dialect, + name="monthly_sales_summary", + schema="analytics", + ) + sql, params = expr.to_sql() + assert "analytics.monthly_sales_summary" in sql + assert params == () + + def test_refresh_concurrently_pg13(self): + """Test CONCURRENTLY refresh requires PG 9.4+.""" + dialect_pg93 = PostgresDialect(version=(9, 3, 0)) + expr = RefreshMaterializedViewPgExpression( + dialect=dialect_pg93, + name="monthly_sales_summary", + concurrently=True, + ) + + with pytest.raises(ValueError, match="CONCURRENTLY requires PostgreSQL 9"): + expr.to_sql() + + def test_refresh_concurrently_pg94(self, dialect): + """Test CONCURRENTLY refresh with PG 9.4+.""" + expr = RefreshMaterializedViewPgExpression( + dialect=dialect, + name="monthly_sales_summary", + concurrently=True, + ) + sql, params = expr.to_sql() + assert "CONCURRENTLY" in sql + assert params == () + + def test_refresh_with_data_false_pg93(self): + """Test WITH NO DATA requires PG 9.4+.""" + dialect_pg93 = PostgresDialect(version=(9, 3, 0)) + expr = RefreshMaterializedViewPgExpression( + dialect=dialect_pg93, + name="monthly_sales_summary", + with_data=False, + ) + + sql, params = expr.to_sql() + assert "WITH NO DATA" in sql + + def test_refresh_with_data_false_pg94(self, dialect): + """Test WITH NO DATA with PG 9.4+.""" + expr = RefreshMaterializedViewPgExpression( + dialect=dialect, + name="monthly_sales_summary", + with_data=False, + ) + sql, params = expr.to_sql() + assert "WITH NO DATA" in sql + assert params == () + + def test_refresh_concurrently_and_with_data(self, dialect): + """Test CONCURRENTLY with WITH NO DATA.""" + expr = RefreshMaterializedViewPgExpression( + dialect=dialect, + name="monthly_sales_summary", + concurrently=True, + with_data=False, + ) + sql, params = expr.to_sql() + assert "CONCURRENTLY" in sql + assert "WITH NO DATA" in sql + assert params == () + + +class TestCommentExpression: + """Test CommentExpression.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_comment_on_table(self, dialect): + """Test COMMENT ON TABLE.""" + expr = CommentExpression( + dialect=dialect, + object_type="TABLE", + object_name="users", + comment="User accounts table", + ) + sql, params = expr.to_sql() + assert "COMMENT ON TABLE" in sql + assert "users" in sql + assert params == ("User accounts table",) + + def test_comment_on_column(self, dialect): + """Test COMMENT ON COLUMN.""" + expr = CommentExpression( + dialect=dialect, + object_type="COLUMN", + object_name="users.email", + comment="User email address", + ) + sql, params = expr.to_sql() + assert "COMMENT ON COLUMN" in sql + assert "users.email" in sql + assert params == ("User email address",) + + def test_comment_on_index(self, dialect): + """Test COMMENT ON INDEX.""" + expr = CommentExpression( + dialect=dialect, + object_type="INDEX", + object_name="users_email_idx", + comment="Email index for users table", + ) + sql, params = expr.to_sql() + assert "COMMENT ON INDEX" in sql + + def test_comment_on_view(self, dialect): + """Test COMMENT ON VIEW.""" + expr = CommentExpression( + dialect=dialect, + object_type="VIEW", + object_name="user_stats", + comment="User statistics view", + ) + sql, params = expr.to_sql() + assert "COMMENT ON VIEW" in sql + + def test_comment_on_schema(self, dialect): + """Test COMMENT ON SCHEMA.""" + expr = CommentExpression( + dialect=dialect, + object_type="SCHEMA", + object_name="analytics", + comment="Analytics schema", + ) + sql, params = expr.to_sql() + assert "COMMENT ON SCHEMA" in sql + + def test_comment_on_function(self, dialect): + """Test COMMENT ON FUNCTION.""" + expr = CommentExpression( + dialect=dialect, + object_type="FUNCTION", + object_name="calculate_total", + comment="Calculate total amount", + ) + sql, params = expr.to_sql() + assert "COMMENT ON FUNCTION" in sql + + def test_remove_comment(self, dialect): + """Test removing comment by setting comment to None.""" + expr = CommentExpression( + dialect=dialect, + object_type="TABLE", + object_name="users", + comment=None, + ) + sql, params = expr.to_sql() + assert "COMMENT ON TABLE" in sql + assert "NULL" in sql + assert params == () + + def test_comment_with_schema(self, dialect): + """Test comment on object with schema.""" + expr = CommentExpression( + dialect=dialect, + object_type="TABLE", + object_name="public.users", + comment="Public users table", + schema="public", + ) + sql, params = expr.to_sql() + assert "public.users" in sql + assert params == ("Public users table",) + + +class TestCreatePartitionExpression: + """Test CreatePartitionExpression.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_create_range_partition(self, dialect): + """Test CREATE TABLE ... PARTITION OF for RANGE.""" + expr = CreatePartitionExpression( + dialect=dialect, + partition_name="orders_2024_q1", + parent_table="orders", + partition_type="RANGE", + partition_values={"from": "2024-01-01", "to": "2024-04-01"}, + ) + sql, params = expr.to_sql() + assert "CREATE TABLE" in sql + assert "PARTITION OF" in sql + assert "FOR VALUES" in sql + assert "FROM" in sql + assert "TO" in sql + assert params == () + + def test_create_list_partition(self, dialect): + """Test CREATE TABLE ... PARTITION OF for LIST.""" + expr = CreatePartitionExpression( + dialect=dialect, + partition_name="orders_active", + parent_table="orders", + partition_type="LIST", + partition_values={"values": ["active", "pending"]}, + ) + sql, params = expr.to_sql() + assert "FOR VALUES" in sql + assert "IN" in sql + assert params == () + + def test_create_hash_partition_pg10(self): + """Test HASH partitioning requires PG 11+.""" + dialect_pg10 = PostgresDialect(version=(10, 0, 0)) + expr = CreatePartitionExpression( + dialect=dialect_pg10, + 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_create_hash_partition_pg11(self, dialect): + """Test HASH partitioning with PG 11+.""" + expr = CreatePartitionExpression( + dialect=dialect, + partition_name="orders_shard0", + parent_table="orders", + partition_type="HASH", + partition_values={"modulus": 4, "remainder": 0}, + ) + sql, params = expr.to_sql() + assert "FOR VALUES" in sql + assert "MODULUS 4" in sql + assert "REMAINDER 0" in sql + + def test_create_partition_with_schema(self, dialect): + """Test partition with schema.""" + expr = CreatePartitionExpression( + dialect=dialect, + partition_name="2024_q1", + parent_table="orders", + partition_type="RANGE", + partition_values={"from": "2024-01-01", "to": "2024-04-01"}, + schema="sales", + ) + sql, params = expr.to_sql() + assert '"sales".' in sql + + def test_create_partition_if_not_exists(self, dialect): + """Test partition with IF NOT EXISTS.""" + expr = CreatePartitionExpression( + dialect=dialect, + partition_name="orders_2024_q1", + parent_table="orders", + partition_type="RANGE", + partition_values={"from": "2024-01-01", "to": "2024-04-01"}, + if_not_exists=True, + ) + sql, params = expr.to_sql() + assert "IF NOT EXISTS" in sql + + def test_create_partition_with_tablespace(self, dialect): + """Test partition with TABLESPACE.""" + expr = CreatePartitionExpression( + dialect=dialect, + partition_name="orders_2024_q1", + parent_table="orders", + partition_type="RANGE", + partition_values={"from": "2024-01-01", "to": "2024-04-01"}, + tablespace="faststorage", + ) + sql, params = expr.to_sql() + assert "TABLESPACE" in sql + assert "faststorage" in sql + + +class TestDetachPartitionExpression: + """Test DetachPartitionExpression.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_basic_detach(self, dialect): + """Test basic DETACH PARTITION.""" + expr = DetachPartitionExpression( + dialect=dialect, + partition_name="orders_2023", + parent_table="orders", + ) + sql, params = expr.to_sql() + assert "ALTER TABLE" in sql + assert "DETACH PARTITION" in sql + assert "orders_2023" in sql + assert params == () + + def test_detach_concurrently_pg13(self): + """Test CONCURRENTLY requires PG 14+.""" + dialect_pg13 = PostgresDialect(version=(13, 0, 0)) + expr = DetachPartitionExpression( + dialect=dialect_pg13, + partition_name="orders_2023", + parent_table="orders", + concurrently=True, + ) + with pytest.raises(ValueError, match="DETACH CONCURRENTLY requires PostgreSQL 14"): + expr.to_sql() + + def test_detach_concurrently_pg14(self, dialect): + """Test DETACH CONCURRENTLY with PG 14+.""" + expr = DetachPartitionExpression( + dialect=dialect, + partition_name="orders_2023", + parent_table="orders", + concurrently=True, + ) + sql, params = expr.to_sql() + assert "DETACH CONCURRENTLY" in sql + + def test_detach_finalize_requires_concurrently(self, dialect): + """Test FINALIZE requires CONCURRENTLY.""" + expr = DetachPartitionExpression( + dialect=dialect, + partition_name="orders_2023", + parent_table="orders", + finalize=True, + ) + with pytest.raises(ValueError, match="FINALIZE only valid with CONCURRENTLY"): + expr.to_sql() + + def test_detach_with_schema(self, dialect): + """Test partition detach with schema.""" + expr = DetachPartitionExpression( + dialect=dialect, + partition_name="orders_2023", + parent_table="orders", + schema="sales", + ) + sql, params = expr.to_sql() + assert '"sales".' in sql + + +class TestAttachPartitionExpression: + """Test AttachPartitionExpression.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_attach_range_partition(self, dialect): + """Test ATTACH PARTITION for RANGE.""" + expr = AttachPartitionExpression( + dialect=dialect, + partition_name="orders_2024_q1", + parent_table="orders", + partition_type="RANGE", + partition_values={"from": "2024-01-01", "to": "2024-04-01"}, + ) + sql, params = expr.to_sql() + assert "ALTER TABLE" in sql + assert "ATTACH PARTITION" in sql + assert "FOR VALUES" in sql + assert "FROM" in sql + assert "TO" in sql + assert params == () + + def test_attach_list_partition(self, dialect): + """Test ATTACH PARTITION for LIST.""" + expr = AttachPartitionExpression( + dialect=dialect, + partition_name="orders_active", + parent_table="orders", + partition_type="LIST", + partition_values={"values": ["active"]}, + ) + sql, params = expr.to_sql() + assert "FOR VALUES" in sql + assert "IN" in sql + + def test_attach_hash_partition(self, dialect): + """Test ATTACH PARTITION for HASH.""" + expr = AttachPartitionExpression( + dialect=dialect, + partition_name="orders_shard0", + parent_table="orders", + partition_type="HASH", + partition_values={"modulus": 4, "remainder": 0}, + ) + sql, params = expr.to_sql() + assert "FOR VALUES" in sql + assert "MODULUS 4" in sql \ No newline at end of file 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 new file mode 100644 index 00000000..95246a55 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py @@ -0,0 +1,254 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py +"""Tests for PostgreSQL DDL protocol feature detection. + +This module tests the protocol-based feature detection methods: +- PostgresPartitionSupport +- PostgresCommentSupport +- PostgresIndexSupport +- PostgresMaterializedViewSupport +""" +import pytest + +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect + + +class TestPostgresPartitionSupportFeatureDetection: + """Test PostgresPartitionSupport feature detection methods.""" + + def test_supports_hash_partitioning_pg10(self): + """HASH partitioning requires PG 11+.""" + dialect = PostgresDialect(version=(10, 0, 0)) + assert dialect.supports_hash_partitioning() is False + + def test_supports_hash_partitioning_pg11(self): + """PostgreSQL 11 supports HASH partitioning.""" + dialect = PostgresDialect(version=(11, 0, 0)) + assert dialect.supports_hash_partitioning() is True + + def test_supports_default_partition_pg10(self): + """DEFAULT partition requires PG 11+.""" + dialect = PostgresDialect(version=(10, 0, 0)) + assert dialect.supports_default_partition() is False + + def test_supports_default_partition_pg11(self): + """PostgreSQL 11 supports DEFAULT partition.""" + dialect = PostgresDialect(version=(11, 0, 0)) + assert dialect.supports_default_partition() is True + + def test_supports_partition_key_update_pg10(self): + """Partition key update requires PG 11+.""" + dialect = PostgresDialect(version=(10, 0, 0)) + assert dialect.supports_partition_key_update() is False + + def test_supports_partition_key_update_pg11(self): + """PostgreSQL 11 supports partition key update.""" + dialect = PostgresDialect(version=(11, 0, 0)) + assert dialect.supports_partition_key_update() is True + + def test_supports_concurrent_detach_pg13(self): + """CONCURRENTLY DETACH requires PG 14+.""" + dialect = PostgresDialect(version=(13, 0, 0)) + assert dialect.supports_concurrent_detach() is False + + def test_supports_concurrent_detach_pg14(self): + """PostgreSQL 14 supports CONCURRENTLY DETACH.""" + dialect = PostgresDialect(version=(14, 0, 0)) + assert dialect.supports_concurrent_detach() is True + + def test_supports_partition_bounds_expression_pg11(self): + """Partition bounds expression requires PG 12+.""" + dialect = PostgresDialect(version=(11, 0, 0)) + assert dialect.supports_partition_bounds_expression() is False + + def test_supports_partition_bounds_expression_pg12(self): + """PostgreSQL 12 supports partition bounds expression.""" + dialect = PostgresDialect(version=(12, 0, 0)) + assert dialect.supports_partition_bounds_expression() is True + + def test_supports_partitionwise_join_pg10(self): + """Partitionwise join requires PG 11+.""" + dialect = PostgresDialect(version=(10, 0, 0)) + assert dialect.supports_partitionwise_join() is False + + def test_supports_partitionwise_join_pg11(self): + """PostgreSQL 11 supports partitionwise join.""" + dialect = PostgresDialect(version=(11, 0, 0)) + assert dialect.supports_partitionwise_join() is True + + def test_supports_partitionwise_aggregate_pg10(self): + """Partitionwise aggregate requires PG 11+.""" + dialect = PostgresDialect(version=(10, 0, 0)) + assert dialect.supports_partitionwise_aggregate() is False + + def test_supports_partitionwise_aggregate_pg11(self): + """PostgreSQL 11 supports partitionwise aggregate.""" + dialect = PostgresDialect(version=(11, 0, 0)) + assert dialect.supports_partitionwise_aggregate() is True + + +class TestPostgresIndexSupportFeatureDetection: + """Test PostgresIndexSupport feature detection methods.""" + + def test_supports_safe_hash_index_pg9(self): + """Hash index WAL requires PG 10+.""" + dialect = PostgresDialect(version=(9, 0, 0)) + assert dialect.supports_safe_hash_index() is False + + def test_supports_safe_hash_index_pg10(self): + """PostgreSQL 10 supports safe hash indexes.""" + dialect = PostgresDialect(version=(10, 0, 0)) + assert dialect.supports_safe_hash_index() is True + + def test_supports_parallel_create_index_pg10(self): + """Parallel CREATE INDEX requires PG 11+.""" + dialect = PostgresDialect(version=(10, 0, 0)) + assert dialect.supports_parallel_create_index() is False + + def test_supports_parallel_create_index_pg11(self): + """PostgreSQL 11 supports parallel CREATE INDEX.""" + dialect = PostgresDialect(version=(11, 0, 0)) + assert dialect.supports_parallel_create_index() is True + + def test_supports_gist_include_pg11(self): + """GiST INCLUDE requires PG 12+.""" + dialect = PostgresDialect(version=(11, 0, 0)) + assert dialect.supports_gist_include() is False + + def test_supports_gist_include_pg12(self): + """PostgreSQL 12 supports GiST INCLUDE.""" + dialect = PostgresDialect(version=(12, 0, 0)) + assert dialect.supports_gist_include() is True + + def test_supports_reindex_concurrently_pg11(self): + """REINDEX CONCURRENTLY requires PG 12+.""" + dialect = PostgresDialect(version=(11, 0, 0)) + assert dialect.supports_reindex_concurrently() is False + + def test_supports_reindex_concurrently_pg12(self): + """PostgreSQL 12 supports REINDEX CONCURRENTLY.""" + dialect = PostgresDialect(version=(12, 0, 0)) + assert dialect.supports_reindex_concurrently() is True + + def test_supports_btree_deduplication_pg12(self): + """B-tree deduplication requires PG 13+.""" + dialect = PostgresDialect(version=(12, 0, 0)) + assert dialect.supports_btree_deduplication() is False + + def test_supports_btree_deduplication_pg13(self): + """PostgreSQL 13 supports B-tree deduplication.""" + dialect = PostgresDialect(version=(13, 0, 0)) + assert dialect.supports_btree_deduplication() is True + + def test_supports_brin_multivalue_pg13(self): + """BRIN multivalue requires PG 14+.""" + dialect = PostgresDialect(version=(13, 0, 0)) + assert dialect.supports_brin_multivalue() is False + + def test_supports_brin_multivalue_pg14(self): + """PostgreSQL 14 supports BRIN multivalue.""" + dialect = PostgresDialect(version=(14, 0, 0)) + assert dialect.supports_brin_multivalue() is True + + def test_supports_brin_bloom_pg13(self): + """BRIN bloom requires PG 14+.""" + dialect = PostgresDialect(version=(13, 0, 0)) + assert dialect.supports_brin_bloom() is False + + def test_supports_brin_bloom_pg14(self): + """PostgreSQL 14 supports BRIN bloom.""" + dialect = PostgresDialect(version=(14, 0, 0)) + assert dialect.supports_brin_bloom() is True + + def test_supports_spgist_include_pg13(self): + """SP-GiST INCLUDE requires PG 14+.""" + dialect = PostgresDialect(version=(13, 0, 0)) + assert dialect.supports_spgist_include() is False + + def test_supports_spgist_include_pg14(self): + """PostgreSQL 14 supports SP-GiST INCLUDE.""" + dialect = PostgresDialect(version=(14, 0, 0)) + assert dialect.supports_spgist_include() is True + + +class TestPostgresMaterializedViewSupportFeatureDetection: + """Test PostgresMaterializedViewSupport feature detection methods.""" + + def test_supports_materialized_view_concurrent_refresh_pg93(self): + """CONCURRENTLY refresh requires PG 9.4+.""" + dialect = PostgresDialect(version=(9, 3, 0)) + assert dialect.supports_materialized_view_concurrent_refresh() is False + + def test_supports_materialized_view_concurrent_refresh_pg94(self): + """PostgreSQL 9.4 supports CONCURRENTLY refresh.""" + dialect = PostgresDialect(version=(9, 4, 0)) + assert dialect.supports_materialized_view_concurrent_refresh() is True + + +class TestPostgresVacuumFeatureDetection: + """Test PostgresVacuumMixin feature detection methods.""" + + def test_supports_parallel_vacuum_pg12(self): + """Parallel VACUUM requires PG 13+.""" + dialect = PostgresDialect(version=(12, 0, 0)) + assert dialect.supports_parallel_vacuum() is False + + def test_supports_parallel_vacuum_pg13(self): + """PostgreSQL 13 supports parallel VACUUM.""" + dialect = PostgresDialect(version=(13, 0, 0)) + assert dialect.supports_parallel_vacuum() is True + + def test_supports_index_cleanup_auto_pg13(self): + """INDEX_CLEANUP requires PG 14+.""" + dialect = PostgresDialect(version=(13, 0, 0)) + assert dialect.supports_index_cleanup_auto() is False + + def test_supports_index_cleanup_auto_pg14(self): + """PostgreSQL 14 supports INDEX_CLEANUP.""" + dialect = PostgresDialect(version=(14, 0, 0)) + assert dialect.supports_index_cleanup_auto() is True + + def test_supports_vacuum_process_toast_pg13(self): + """PROCESS_TOAST requires PG 14+.""" + dialect = PostgresDialect(version=(13, 0, 0)) + assert dialect.supports_vacuum_process_toast() is False + + def test_supports_vacuum_process_toast_pg14(self): + """PostgreSQL 14 supports PROCESS_TOAST.""" + dialect = PostgresDialect(version=(14, 0, 0)) + assert dialect.supports_vacuum_process_toast() is True + + +class TestProtocolRuntimeCheckable: + """Test that protocols are runtime checkable.""" + + def test_postgres_partition_support_is_runtime_checkable(self): + """PostgresPartitionSupport should be runtime checkable.""" + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresPartitionSupport, + ) + dialect = PostgresDialect(version=(14, 0, 0)) + assert isinstance(dialect, PostgresPartitionSupport) + + def test_postgres_comment_support_is_runtime_checkable(self): + """PostgresCommentSupport should be runtime checkable.""" + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresCommentSupport, + ) + dialect = PostgresDialect(version=(14, 0, 0)) + assert isinstance(dialect, PostgresCommentSupport) + + def test_postgres_index_support_is_runtime_checkable(self): + """PostgresIndexSupport should be runtime checkable.""" + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresIndexSupport, + ) + dialect = PostgresDialect(version=(14, 0, 0)) + assert isinstance(dialect, PostgresIndexSupport) + + def test_postgres_materialized_view_support_is_runtime_checkable(self): + """PostgresMaterializedViewSupport should be runtime checkable.""" + from rhosocial.activerecord.backend.impl.postgres.protocols import ( + PostgresMaterializedViewSupport, + ) + dialect = PostgresDialect(version=(14, 0, 0)) + assert isinstance(dialect, PostgresMaterializedViewSupport) \ No newline at end of file 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 abb314e4..ef042a1d 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 @@ -13,8 +13,8 @@ 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 EnumTypeSupport -from rhosocial.activerecord.backend.impl.postgres.statements import ( +from rhosocial.activerecord.backend.impl.postgres.protocols import PostgresEnumTypeSupport +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( CreateEnumTypeExpression, DropEnumTypeExpression, AlterEnumTypeAddValueExpression, @@ -368,13 +368,15 @@ def test_batch_from_database(self): assert results[2] is None -class TestEnumTypeSupport: - """Tests for EnumTypeSupport protocol implementation.""" +class TestPostgresEnumTypeSupport: + """Tests for PostgresEnumTypeSupport protocol implementation.""" - def test_dialect_implements_enum_type_support(self): - """Test that PostgresDialect implements EnumTypeSupport.""" + def test_dialect_implements_postgres_enum_type_support(self): + """Test that PostgresDialect implements PostgresEnumTypeSupport.""" dialect = PostgresDialect() - assert isinstance(dialect, EnumTypeSupport) + # Check via MRO + from rhosocial.activerecord.backend.impl.postgres.protocols import PostgresEnumTypeSupport + assert PostgresEnumTypeSupport in dialect.__class__.__mro__ def test_create_enum_type_method(self): """Test create_enum_type method.""" 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 f67afa5e..4479bd91 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 @@ -7,7 +7,7 @@ import pytest from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect -from rhosocial.activerecord.backend.impl.postgres.expressions import ( +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( VacuumExpression, AnalyzeExpression, CreatePartitionExpression, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extended_statistics.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extended_statistics.py index e4d98a8c..698f636b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extended_statistics.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extended_statistics.py @@ -9,7 +9,7 @@ import pytest from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect -from rhosocial.activerecord.backend.impl.postgres.expressions import ( +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( CreateStatisticsExpression, DropStatisticsExpression, ) 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 606db3ec..8bf9829c 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 @@ -9,7 +9,7 @@ import pytest from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect -from rhosocial.activerecord.backend.impl.postgres.expressions import ReindexExpression +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ReindexExpression class TestIndexFeatureDetection: 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 new file mode 100644 index 00000000..2ea8f9f2 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py @@ -0,0 +1,289 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py +""" +Test SQLFunctionSupport protocol implementation for PostgreSQL dialect. + +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 + + +class TestPostgreSQLFunctionSupportBasic: + """Basic tests for PostgreSQL function support detection.""" + + def test_supports_functions_returns_dict(self): + """Test that supports_functions returns a dictionary.""" + dialect = PostgresDialect() + result = dialect.supports_functions() + assert isinstance(result, dict) + assert len(result) > 0 + + def test_supports_functions_all_values_are_bool(self): + """Test that all values in the returned dict are booleans.""" + dialect = PostgresDialect() + result = dialect.supports_functions() + for func_name, supported in result.items(): + assert isinstance(supported, bool), f"Value for {func_name} is not bool" + + def test_core_functions_always_supported(self): + """Test that core functions are marked as supported.""" + dialect = PostgresDialect() + result = dialect.supports_functions() + core_functions = ["count", "sum_", "avg", "min_", "max_", "coalesce", "nullif"] + for func in core_functions: + assert func in result, f"Core function {func} not in result" + assert result[func] is True, f"Core function {func} should be supported" + + +class TestPostgreSQLFunctionSupportVersionDependent: + """Tests for version-dependent function support.""" + + def test_json_path_functions_require_pg_12(self): + """Test that JSONPath functions require PostgreSQL 12+.""" + json_path_functions = ["jsonb_path_query", "jsonb_path_query_first", + "jsonb_path_exists", "jsonb_path_match", + "json_path_root", "json_path_key", "json_path_index", + "json_path_wildcard", "json_path_filter"] + + dialect_old = PostgresDialect(version=(11, 99, 99)) + result_old = dialect_old.supports_functions() + for func in json_path_functions: + assert result_old.get(func) is False + + dialect_new = PostgresDialect(version=(12, 0, 0)) + result_new = dialect_new.supports_functions() + for func in json_path_functions: + assert result_new.get(func) is True + + def test_range_functions_require_pg_9_2(self): + """Test that range functions require PostgreSQL 9.2+.""" + range_functions = ["range_contains", "range_contained_by", "range_overlaps", + "range_adjacent", "range_union", "range_intersection", + "range_lower", "range_upper", "range_is_empty"] + + dialect_old = PostgresDialect(version=(9, 1, 99)) + result_old = dialect_old.supports_functions() + for func in range_functions: + assert result_old.get(func) is False + + dialect_new = PostgresDialect(version=(9, 2, 0)) + result_new = dialect_new.supports_functions() + for func in range_functions: + assert result_new.get(func) is True + + def test_range_constructors_require_pg_9_2(self): + """Test that range constructors require PostgreSQL 9.2+.""" + range_constructors = ["int4range", "int8range", "numrange", + "tsrange", "tstzrange", "daterange"] + + dialect_old = PostgresDialect(version=(9, 1, 99)) + result_old = dialect_old.supports_functions() + for func in range_constructors: + assert result_old.get(func) is False + + dialect_new = PostgresDialect(version=(9, 2, 0)) + result_new = dialect_new.supports_functions() + for func in range_constructors: + assert result_new.get(func) is True + + def test_enum_functions_require_pg_8_3(self): + """Test that enum functions require PostgreSQL 8.3+.""" + enum_functions = ["enum_range", "enum_first", "enum_last", + "enum_lt", "enum_le", "enum_gt", "enum_ge"] + + dialect_old = PostgresDialect(version=(8, 2, 99)) + result_old = dialect_old.supports_functions() + for func in enum_functions: + assert result_old.get(func) is False + + dialect_new = PostgresDialect(version=(8, 3, 0)) + result_new = dialect_new.supports_functions() + for func in enum_functions: + assert result_new.get(func) is True + + def test_text_search_functions_require_pg_8_3(self): + """Test that text search functions require PostgreSQL 8.3+.""" + ts_functions = ["to_tsvector", "to_tsquery", "plainto_tsquery", + "ts_matches", "ts_headline", "tsvector_concat"] + + dialect_old = PostgresDialect(version=(8, 2, 99)) + result_old = dialect_old.supports_functions() + for func in ts_functions: + assert result_old.get(func) is False + + dialect_new = PostgresDialect(version=(8, 3, 0)) + result_new = dialect_new.supports_functions() + for func in ts_functions: + assert result_new.get(func) is True + + def test_phraseto_tsquery_requires_pg_9_6(self): + """Test that phraseto_tsquery requires PostgreSQL 9.6+.""" + dialect_old = PostgresDialect(version=(9, 5, 99)) + result_old = dialect_old.supports_functions() + assert result_old.get("phraseto_tsquery") is False + + dialect_new = PostgresDialect(version=(9, 6, 0)) + result_new = dialect_new.supports_functions() + assert result_new.get("phraseto_tsquery") is True + + def test_websearch_to_tsquery_requires_pg_11(self): + """Test that websearch_to_tsquery requires PostgreSQL 11+.""" + dialect_old = PostgresDialect(version=(10, 99, 99)) + result_old = dialect_old.supports_functions() + assert result_old.get("websearch_to_tsquery") is False + + dialect_new = PostgresDialect(version=(11, 0, 0)) + result_new = dialect_new.supports_functions() + assert result_new.get("websearch_to_tsquery") is True + + def test_array_functions_require_various_versions(self): + """Test that array functions have various version requirements.""" + dialect_83 = PostgresDialect(version=(8, 3, 0)) + dialect_84 = PostgresDialect(version=(8, 4, 0)) + dialect_95 = PostgresDialect(version=(9, 5, 0)) + + result_83 = dialect_83.supports_functions() + result_84 = dialect_84.supports_functions() + result_95 = dialect_95.supports_functions() + + assert result_83.get("array_fill") is False + assert result_84.get("array_fill") is True + + assert result_83.get("array_ndims") is False + assert result_84.get("array_ndims") is True + + assert result_84.get("array_position") is False + assert result_95.get("array_position") is True + + def test_bit_count_requires_pg_9_5(self): + """Test that bit_count requires PostgreSQL 9.5+.""" + dialect_old = PostgresDialect(version=(9, 4, 99)) + result_old = dialect_old.supports_functions() + assert result_old.get("bit_count") is False + + dialect_new = PostgresDialect(version=(9, 5, 0)) + result_new = dialect_new.supports_functions() + assert result_new.get("bit_count") is True + + def test_xml_functions_require_pg_8_3(self): + """Test that XML functions require PostgreSQL 8.3+.""" + xml_functions = ["xmlparse", "xpath_query", "xpath_exists"] + + dialect_old = PostgresDialect(version=(8, 2, 99)) + result_old = dialect_old.supports_functions() + for func in xml_functions: + assert result_old.get(func) is False + + dialect_new = PostgresDialect(version=(8, 3, 0)) + result_new = dialect_new.supports_functions() + for func in xml_functions: + assert result_new.get(func) 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)) + result_old = dialect_old.supports_functions() + assert result_old.get("xml_is_well_formed") is False + + dialect_new = PostgresDialect(version=(9, 1, 0)) + result_new = dialect_new.supports_functions() + assert result_new.get("xml_is_well_formed") is True + + def test_hstore_to_json_functions_require_pg_9_3(self): + """Test that hstore_to_json functions require PostgreSQL 9.3+.""" + dialect_old = PostgresDialect(version=(9, 2, 99)) + result_old = dialect_old.supports_functions() + assert result_old.get("hstore_to_json") is False + + dialect_new = PostgresDialect(version=(9, 3, 0)) + result_new = dialect_new.supports_functions() + assert result_new.get("hstore_to_json") is True + + def test_hstore_to_jsonb_requires_pg_9_4(self): + """Test that hstore_to_jsonb requires PostgreSQL 9.4+.""" + dialect_old = PostgresDialect(version=(9, 3, 99)) + result_old = dialect_old.supports_functions() + assert result_old.get("hstore_to_jsonb") is False + + dialect_new = PostgresDialect(version=(9, 4, 0)) + result_new = dialect_new.supports_functions() + assert result_new.get("hstore_to_jsonb") is True + + def test_macaddr8_set7bit_requires_pg_10(self): + """Test that macaddr8_set7bit requires PostgreSQL 10+.""" + dialect_old = PostgresDialect(version=(9, 99, 99)) + result_old = dialect_old.supports_functions() + assert result_old.get("macaddr8_set7bit") is False + + dialect_new = PostgresDialect(version=(10, 0, 0)) + result_new = dialect_new.supports_functions() + assert result_new.get("macaddr8_set7bit") is True + + def test_always_available_functions(self): + """Test functions that are available in all PostgreSQL versions.""" + dialect = PostgresDialect() + result = dialect.supports_functions() + + always_available = [ + "geometry_distance", "geometry_contains", "geometry_area", + "bit_concat", "bit_and", "bit_or", "bit_xor", "bit_shift_left", + "round_", "pow", "power", "sqrt", "mod", "ceil", "floor", + "array_agg", "array_append", "array_cat", "array_length", + ] + for func in always_available: + assert result.get(func) is True, f"{func} should be always available" + + +class TestPostgreSQLFunctionSupportPrivateMethod: + """Tests for the private _is_postgres_function_supported method.""" + + def test_unknown_function_returns_true(self): + """Test that unknown functions return True (no restriction).""" + dialect = PostgresDialect() + result = dialect._is_postgres_function_supported("unknown_function_xyz") + assert result is True + + def test_version_restricted_function_below_minimum(self): + """Test that version-restricted function returns False below minimum.""" + dialect = PostgresDialect(version=(11, 0, 0)) + result = dialect._is_postgres_function_supported("jsonb_path_query") + assert result is False + + def test_version_restricted_function_at_minimum(self): + """Test that version-restricted function returns True at minimum.""" + dialect = PostgresDialect(version=(12, 0, 0)) + result = dialect._is_postgres_function_supported("jsonb_path_query") + assert result is True + + def test_version_restricted_function_above_minimum(self): + """Test that version-restricted function returns True above minimum.""" + dialect = PostgresDialect(version=(15, 0, 0)) + result = dialect._is_postgres_function_supported("jsonb_path_query") + assert result is True + + +class TestPostgreSQLFunctionSupportIntegration: + """Integration tests for function support detection.""" + + def test_function_dict_contains_both_core_and_backend_functions(self): + """Test that the result contains both core and PostgreSQL-specific functions.""" + dialect = PostgresDialect() + result = dialect.supports_functions() + + assert any(func in result for func in ["count", "sum", "avg"]) + assert any(func in result for func in ["range_contains", "geometry_distance", "enum_range"]) + + def test_function_support_changes_with_version(self): + """Test that function support changes across different versions.""" + old_dialect = PostgresDialect(version=(8, 2, 0)) + new_dialect = PostgresDialect(version=(15, 0, 0)) + + old_result = old_dialect.supports_functions() + new_result = new_dialect.supports_functions() + + assert old_result.get("enum_range") is False + assert new_result.get("enum_range") is True + + assert old_result.get("jsonb_path_query") is False + assert new_result.get("jsonb_path_query") is True From ac9b8fa4761a8ca6a2977eea150f11c24fa711bd Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 17 Apr 2026 16:54:17 +0800 Subject: [PATCH 3/5] fix(postgres): register requires_inner_join pytest marker --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index f5d391f1..dd0d3dce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,7 @@ testpaths = [ markers = [ "requires_capability: marks tests requiring specific backend capabilities", "requires_protocol: marks tests requiring specific backend protocol support", + "requires_inner_join: marks tests requiring INNER JOIN support", ] [tool.coverage.run] From 2ff557a0fef427e2e6359c7b0279ae51b845f98a Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 17 Apr 2026 20:59:40 +0800 Subject: [PATCH 4/5] chore(ci): use PyPI packages instead of git+https --- .github/workflows/test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5f242815..46b3fb61 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,10 +65,10 @@ jobs: pip install -r requirements.txt pip install -r requirements-dev.txt - - name: Install activerecord and testsuite from Git + - name: Install activerecord and testsuite from PyPI run: | - pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev24 - pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev11 + pip install rhosocial-activerecord==1.0.0.dev24 + pip install rhosocial-activerecord-testsuite==1.0.0.dev11 - name: Create postgres scenario config run: | From 2ce7545941a781215dbd2cd80337219a08603aeb Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 17 Apr 2026 21:17:26 +0800 Subject: [PATCH 5/5] docs: update CHANGELOG for v1.0.0.dev11 --- CHANGELOG.md | 7 +++++++ changelog.d/30.added.md | 1 - 2 files changed, 7 insertions(+), 1 deletion(-) delete mode 100644 changelog.d/30.added.md diff --git a/CHANGELOG.md b/CHANGELOG.md index db40748f..a09f18dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [v1.0.0.dev11] - 2026-04-17 + +### Added + +- Add comprehensive PostgreSQL backend examples covering connection, CRUD, transactions, CTEs, window functions, and more ([#30](https://github.com/rhosocial/python-activerecord-postgres/issues/30)) + + ## [v1.0.0.dev10] - 2026-04-13 ### Added diff --git a/changelog.d/30.added.md b/changelog.d/30.added.md deleted file mode 100644 index d6cdd961..00000000 --- a/changelog.d/30.added.md +++ /dev/null @@ -1 +0,0 @@ -Add comprehensive PostgreSQL backend examples covering connection, CRUD, transactions, CTEs, window functions, and more \ No newline at end of file