diff --git a/changelog.d/46.added.md b/changelog.d/46.added.md new file mode 100644 index 00000000..cf118163 --- /dev/null +++ b/changelog.d/46.added.md @@ -0,0 +1 @@ +Added PostgreSQL-specific DataType subclasses and schema-diff support with `PostgresSchemaDiffer` for accurate schema introspection and column comparison. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py index 83f988d5..9c6f720e 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py @@ -23,6 +23,55 @@ from .dialect import PostgresDialect from .transaction import PostgresTransactionManager, AsyncPostgresTransactionManager from .types import PostgresEnumType +from .expression.types import ( + PostgresBigSerialType, + PostgresBitType, + PostgresBoxType, + PostgresByteaType, + PostgresCIDType, + PostgresCircleType, + PostgresDateMultirangeType, + PostgresDateRangeType, + PostgresGeographyType, + PostgresGeometryType, + PostgresHstoreType, + PostgresInetType, + PostgresInt4MultirangeType, + PostgresInt4RangeType, + PostgresInt8MultirangeType, + PostgresInt8RangeType, + PostgresJsonPathType, + PostgresLineSegmentType, + PostgresLineType, + PostgresMacAddr8Type, + PostgresMacAddrType, + PostgresMoneyType, + PostgresNumMultirangeType, + PostgresNumRangeType, + PostgresOIDType, + PostgresPathType, + PostgresPgLSNType, + PostgresPointType, + PostgresPolygonType, + PostgresRegClassType, + PostgresRegTypeType, + PostgresSerialType, + PostgresSmallSerialType, + PostgresTIDType, + PostgresTSQueryType, + PostgresTSVectorType, + PostgresTsMultirangeType, + PostgresTsRangeType, + PostgresTsTzMultirangeType, + PostgresTsTzRangeType, + PostgresUUIDType, + PostgresVarBitType, + PostgresVectorType, + PostgresXID8Type, + PostgresXIDType, + PostgresXMLType, + PostgresCidrType, +) from .explain import PostgresExplainResult, PostgresExplainPlanLine from .expression.ddl import ( PostgresCreateEnumTypeExpression, @@ -505,4 +554,52 @@ # hstore functions - Subscript "hstore_subscript_get", "hstore_subscript_set", + # PostgreSQL DataType subclasses for DDL + "PostgresBigSerialType", + "PostgresBitType", + "PostgresBoxType", + "PostgresByteaType", + "PostgresCIDType", + "PostgresCircleType", + "PostgresDateMultirangeType", + "PostgresDateRangeType", + "PostgresGeographyType", + "PostgresGeometryType", + "PostgresHstoreType", + "PostgresInetType", + "PostgresInt4MultirangeType", + "PostgresInt4RangeType", + "PostgresInt8MultirangeType", + "PostgresInt8RangeType", + "PostgresJsonPathType", + "PostgresLineSegmentType", + "PostgresLineType", + "PostgresMacAddr8Type", + "PostgresMacAddrType", + "PostgresMoneyType", + "PostgresNumMultirangeType", + "PostgresNumRangeType", + "PostgresOIDType", + "PostgresPathType", + "PostgresPgLSNType", + "PostgresPointType", + "PostgresPolygonType", + "PostgresRegClassType", + "PostgresRegTypeType", + "PostgresSerialType", + "PostgresSmallSerialType", + "PostgresTIDType", + "PostgresTSQueryType", + "PostgresTSVectorType", + "PostgresTsMultirangeType", + "PostgresTsRangeType", + "PostgresTsTzMultirangeType", + "PostgresTsTzRangeType", + "PostgresUUIDType", + "PostgresVarBitType", + "PostgresVectorType", + "PostgresXID8Type", + "PostgresXIDType", + "PostgresXMLType", + "PostgresCidrType", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index c59bb218..8a681cb0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -90,6 +90,7 @@ IntrospectionSupport, TransactionControlSupport, SQLFunctionSupport, + DDLTypeSupport, ) from .mixins import ( PostgresExtensionMixin, @@ -169,6 +170,7 @@ PostgresRangeTypeMixin, PostgresJSONBEnhancedMixin, PostgresArrayEnhancedMixin, + PostgresTypeFormatSupportMixin, # DDL/DML operation mixins (new) PostgresExtendedStatisticsMixin, PostgresStoredProcedureMixin, @@ -400,6 +402,7 @@ class PostgresDialect( PostgresFullTextSearchMixin, PostgresRangeTypeMixin, PostgresArrayEnhancedMixin, + PostgresTypeFormatSupportMixin, # DDL/DML operation mixins (new) PostgresExtendedStatisticsMixin, PostgresStoredProcedureMixin, @@ -524,6 +527,8 @@ class PostgresDialect( PostgresRangeTypeSupport, PostgresJSONBEnhancedSupport, PostgresArrayEnhancedSupport, + # DataType Support Protocol + DDLTypeSupport, # New feature protocols PostgresParallelQuerySupport, PostgresStoredProcedureSupport, diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/conftest.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/conftest.py index 44cc9fd2..a3c1a4db 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/conftest.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/conftest.py @@ -416,4 +416,46 @@ 'max_version': '18', 'note': 'Requires superuser or pg_read_server_files role; only available in PG15+ docker images', }, + 'schema_diff/table_changes.py': { + 'title': 'Schema Diff — Table Add/Remove', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'schema_diff/column_order_irrelevant.py': { + 'title': 'Schema Diff — Column Order Is Ignored', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'schema_diff/postgres10_identity.py': { + 'title': 'Schema Diff — PG9 SERIAL vs PG10+ IDENTITY', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'schema_diff/array_type_diff.py': { + 'title': 'Schema Diff — Array Type Dimension Changes', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'schema_diff/index_type_diff.py': { + 'title': 'Schema Diff — PostgreSQL Index Type Switches', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'schema_diff/serialization_roundtrip.py': { + 'title': 'Schema Diff — Snapshot Serialization Roundtrip', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, } diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py index 9005f9a7..ce68ffae 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py @@ -38,7 +38,7 @@ drop_table = DropTableExpression( dialect=dialect, - table_name='users', + table='users', if_exists=True, cascade=True, ) @@ -47,7 +47,7 @@ create_table = CreateTableExpression( dialect=dialect, - table_name='users', + table='users', columns=[ ColumnDefinition( 'id', @@ -148,13 +148,23 @@ 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 +from rhosocial.activerecord.backend.expression import QueryExpression, TableExpression +from rhosocial.activerecord.backend.expression.core import WildcardExpression, Column +from rhosocial.activerecord.backend.expression.query_parts import OrderByClause + +verify_expr = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'column_name'), + Column(dialect, 'data_type'), + Column(dialect, 'is_nullable'), + ], + from_=TableExpression(dialect, 'columns', schema_name='information_schema'), + where=Column(dialect, 'table_name') == Literal(dialect, 'users'), + order_by=OrderByClause(dialect, [Column(dialect, 'ordinal_position')]), ) +options = ExecutionOptions(stmt_type=StatementType.DQL) +result = backend.execute(*verify_expr.to_sql(), options=options) print("Table structure after alterations:") for row in result.data or []: print(f" {row}") 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 index 9e097b89..5e0c1ea3 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/create_index.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/create_index.py @@ -33,7 +33,7 @@ drop_table = DropTableExpression( dialect=dialect, - table_name='products', + table='products', if_exists=True, cascade=True, ) @@ -42,7 +42,7 @@ create_table = CreateTableExpression( dialect=dialect, - table_name='products', + table='products', columns=[ ColumnDefinition( 'id', @@ -83,11 +83,17 @@ 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 +from rhosocial.activerecord.backend.expression import QueryExpression, TableExpression +from rhosocial.activerecord.backend.expression.core import Column, Literal + +verify_expr = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'indexname')], + from_=TableExpression(dialect, 'pg_indexes'), + where=Column(dialect, 'indexname') == Literal(dialect, 'idx_category_price'), ) +options = ExecutionOptions(stmt_type=StatementType.DQL) +verify_result = backend.execute(*verify_expr.to_sql(), options=options) print(f"Index info: {verify_result.data}") # ============================================================ 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 index 557e2858..1ef87074 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py @@ -2,10 +2,16 @@ 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 +1. Creating a materialized view with aggregations +2. Column aliases for the materialized view +3. Storage options (WITH clause) for physical storage tuning +4. TABLESPACE assignment +5. WITH DATA vs WITH NO DATA population modes +6. REFRESH MATERIALIZED VIEW (normal) +7. REFRESH MATERIALIZED VIEW CONCURRENTLY (PostgreSQL 9.4+) +8. REFRESH MATERIALIZED VIEW WITH DATA (after NO DATA) +9. Schema-qualified refresh (PostgresRefreshMaterializedViewExpression) +10. DROP MATERIALIZED VIEW with CASCADE and IF EXISTS """ # ============================================================ @@ -24,6 +30,7 @@ CreateMaterializedViewExpression, DropMaterializedViewExpression, RefreshMaterializedViewExpression, + CreateIndexExpression, GroupByHavingClause, FunctionCall, ) @@ -46,22 +53,35 @@ ) backend = PostgresBackend(connection_config=config) backend.connect() +backend.introspect_and_adapt() dialect = backend.dialect dql_options = ExecutionOptions(stmt_type=StatementType.DQL) +ddl_options = ExecutionOptions(stmt_type=StatementType.DDL) +_drop_mv = DropMaterializedViewExpression( + dialect=dialect, view_name='sales_summary', if_exists=True, cascade=True, +) +backend.execute(*_drop_mv.to_sql()) +_drop_mv = DropMaterializedViewExpression( + dialect=dialect, view_name='sales_daily', if_exists=True, cascade=True, +) +backend.execute(*_drop_mv.to_sql()) +_drop_mv = DropMaterializedViewExpression( + dialect=dialect, view_name='sales_product_summary', if_exists=True, cascade=True, +) +backend.execute(*_drop_mv.to_sql()) drop_table = DropTableExpression( dialect=dialect, - table_name='sales', + table='sales', if_exists=True, cascade=True, ) -sql, params = drop_table.to_sql() -backend.execute(sql, params) +backend.execute(*drop_table.to_sql()) create_table = CreateTableExpression( dialect=dialect, - table_name='sales', + table='sales', columns=[ ColumnDefinition( 'id', @@ -105,18 +125,18 @@ backend.execute(sql, params) # ============================================================ -# SECTION: Create Materialized View +# SECTION: 1. Create Materialized View with Column Aliases and Storage Options # ============================================================ -# Materialized views store the query result physically +# Column aliases rename the output columns of the materialized view. +# Storage options control physical storage behavior via WITH (param = value). -# 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'), + FunctionCall(dialect, 'COUNT', WildcardExpression(dialect)), + FunctionCall(dialect, 'SUM', Column(dialect, 'amount')), + FunctionCall(dialect, 'AVG', Column(dialect, 'amount')), ], from_=TableExpression(dialect, 'sales'), group_by_having=GroupByHavingClause( @@ -129,12 +149,18 @@ dialect=dialect, view_name='sales_summary', query=summary_query, + column_aliases=['product_id', 'total_sales', 'total_amount', 'avg_amount'], + storage_options={ + 'fillfactor': 70, + 'autovacuum_enabled': 'true', + }, ) sql, params = create_mv.to_sql() -print(f"Create MV SQL: {sql}") +print(f"Create MV with column aliases and storage options:") +print(f" SQL: {sql}") backend.execute(sql, params) -# Verify the materialized view content +# Verify verify_query = QueryExpression( dialect=dialect, select=[WildcardExpression(dialect)], @@ -143,53 +169,47 @@ ) sql, params = verify_query.to_sql() result = backend.execute(sql, params, options=dql_options) -print("Materialized view result:") +print("MV result:") for row in result.data or []: print(f" {row}") # ============================================================ -# SECTION: Refresh Materialized View +# SECTION: 2. TABLESPACE (SQL Generation Demo) # ============================================================ -# Refresh to update the data +# Tablespace requires an existing tablespace on the server. +# This section generates the SQL to show the syntax; execution is optional. -# Insert new data -insert_new = InsertExpression( +print() +sales_query = QueryExpression( 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'")]], - ), + select=[ + Column(dialect, 'product_id'), + Column(dialect, 'amount'), + Column(dialect, 'sale_date'), + ], + from_=TableExpression(dialect, 'sales'), ) -sql, params = insert_new.to_sql() -backend.execute(sql, params) -# Data in view is stale - refresh to see new data -refresh_mv = RefreshMaterializedViewExpression( +mv_tablespace = CreateMaterializedViewExpression( dialect=dialect, - view_name='sales_summary', + view_name='sales_on_fast_storage', + query=sales_query, + tablespace='fast_ssd', ) -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}") +sql, params = mv_tablespace.to_sql() +print(f"TABLESPACE example SQL (not executed): {sql}") # ============================================================ -# SECTION: Create with NO DATA +# SECTION: 3. WITH NO DATA — Fast Definition Without Population # ============================================================ -# Use WITH NO DATA to create without populating +# Create a materialized view without initial data. The MV is registered +# but cannot be queried until a REFRESH populates it. daily_query = QueryExpression( dialect=dialect, select=[ Column(dialect, 'sale_date'), - FunctionCall(dialect, 'SUM', Column(dialect, 'amount')).as_('daily_total'), + FunctionCall(dialect, 'SUM', Column(dialect, 'amount')), ], from_=TableExpression(dialect, 'sales'), group_by_having=GroupByHavingClause( @@ -202,12 +222,14 @@ dialect=dialect, view_name='sales_daily', query=daily_query, + column_aliases=['sale_date', 'daily_total'], with_data=False, ) sql, params = create_mv_nodata.to_sql() +print(f"\nWITH NO DATA SQL: {sql}") backend.execute(sql, params) -# Verify WITH NO DATA - query should fail or return empty +# Confirm it's unqueriable nodata_query = QueryExpression( dialect=dialect, select=[WildcardExpression(dialect)], @@ -218,17 +240,150 @@ 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}") + print(f"WITH NO DATA query error (expected): {type(e).__name__}: {e}") # ============================================================ -# SECTION: Drop Materialized View +# SECTION: 4. REFRESH MATERIALIZED VIEW (Normal) # ============================================================ +# Refresh recalculates the query and atomically replaces the MV content. + +# Insert new data so refresh has an effect +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) + +# Refresh sales_summary +refresh_mv = RefreshMaterializedViewExpression( + dialect=dialect, + view_name='sales_summary', +) +sql, params = refresh_mv.to_sql() +print(f"\nREFRESH SQL: {sql}") +backend.execute(sql, params) + +sql, params = verify_query.to_sql() +result = backend.execute(sql, params, options=dql_options) +print("After normal refresh:") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: 5. REFRESH MATERIALIZED VIEW CONCURRENTLY (PG 9.4+) +# ============================================================ +# CONCURRENTLY allows the MV to be read during the refresh but +# requires a UNIQUE index on every row of the materialized view. + +# Create a unique index prerequisite +create_unique_idx = CreateIndexExpression( + dialect=dialect, + index_name='sales_summary_product_id_idx', + table_name='sales_summary', + columns=['product_id'], + unique=True, +) +sql, params = create_unique_idx.to_sql() +backend.execute(sql, params, options=ddl_options) + +# Insert more data +insert_more = InsertExpression( + dialect=dialect, + into='sales', + columns=['product_id', 'amount', 'sale_date'], + source=ValuesSource( + dialect, + [[Literal(dialect, '2'), Literal(dialect, '400.00'), Literal(dialect, "'2024-01-06'")]], + ), +) +sql, params = insert_more.to_sql() +backend.execute(sql, params) + +refresh_concurrent = RefreshMaterializedViewExpression( + dialect=dialect, + view_name='sales_summary', + concurrent=True, +) +sql, params = refresh_concurrent.to_sql() +print(f"\nREFRESH CONCURRENTLY SQL: {sql}") +backend.execute(sql, params) + +sql, params = verify_query.to_sql() +result = backend.execute(sql, params, options=dql_options) +print("After CONCURRENTLY refresh:") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: 6. REFRESH WITH DATA — Populate a NO DATA View +# ============================================================ +# An MV created with WITH NO DATA can be populated later via +# REFRESH ... WITH DATA. + +refresh_with_data = RefreshMaterializedViewExpression( + dialect=dialect, + view_name='sales_daily', + with_data=True, +) +sql, params = refresh_with_data.to_sql() +print(f"\nREFRESH WITH DATA SQL: {sql}") +backend.execute(sql, params) + +# Now the NO-DATA view is queryable +sql, params = nodata_query.to_sql() +result = backend.execute(sql, params, options=dql_options) +print("After REFRESH WITH DATA (sales_daily):") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: 7. Schema-Qualified Refresh (PostgresRefreshMaterializedViewExpression) +# ============================================================ +# The PostgreSQL backend provides an extended refresh expression that +# supports specifying the schema for the materialized view. + +from rhosocial.activerecord.backend.impl.postgres.expression.ddl.mv import ( + PostgresRefreshMaterializedViewExpression, +) + +# Refresh with schema (public is PG's default schema) +pg_refresh = PostgresRefreshMaterializedViewExpression( + dialect=dialect, + name='sales_summary', + schema='public', +) +sql, params = pg_refresh.to_sql() +print(f"\nSchema-qualified REFRESH SQL: {sql}") +backend.execute(sql, params) + +# Also supports backward-compatible aliases: name=, concurrently= +pg_refresh_c = PostgresRefreshMaterializedViewExpression( + dialect=dialect, + name='sales_summary', + concurrently=True, +) +sql, params = pg_refresh_c.to_sql() +print(f"Backward-compat CONCURRENTLY SQL: {sql}") + +# ============================================================ +# SECTION: 8. DROP MATERIALIZED VIEW with CASCADE and IF EXISTS +# ============================================================ +# CASCADE drops any objects that depend on the MV. + drop_mv1 = DropMaterializedViewExpression( dialect=dialect, view_name='sales_summary', if_exists=True, + cascade=True, ) sql, params = drop_mv1.to_sql() +print(f"\nDROP CASCADE SQL: {sql}") backend.execute(sql, params) drop_mv2 = DropMaterializedViewExpression( @@ -239,12 +394,26 @@ sql, params = drop_mv2.to_sql() backend.execute(sql, params) +# ============================================================ +# SECTION: 9. DROP MATERIALIZED VIEW Without IF EXISTS (SQL Generation Demo) +# ============================================================ +# Dropping a non-existent view without IF EXISTS will raise an error +# at runtime. This section shows the SQL generation to avoid that. + +drop_noexist = DropMaterializedViewExpression( + dialect=dialect, + view_name='nonexistent_mv', +) +sql, params = drop_noexist.to_sql() +print(f"DROP without IF EXISTS SQL (not executed): {sql}") +print(" Note: This would fail at runtime if the MV does not exist.") + # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ drop_table = DropTableExpression( dialect=dialect, - table_name='sales', + table='sales', if_exists=True, cascade=True, ) @@ -257,8 +426,15 @@ # ============================================================ # 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+) +# 2. Use column_aliases to name the MV output columns +# 3. Use storage_options for physical storage tuning (fillfactor, autovacuum, etc.) +# 4. Use tablespace to assign the MV to a specific storage location +# 5. Use with_data=False for WITH NO DATA (lazy population, MV unqueriable) +# 6. Use RefreshMaterializedViewExpression for standard refresh +# 7. Use RefreshMaterializedViewExpression with concurrent=True for CONCURRENTLY refresh (PG 9.4+, requires UNIQUE index) +# 8. Use RefreshMaterializedViewExpression with with_data=True to populate a NO-DATA MV +# 9. Use PostgresRefreshMaterializedViewExpression for schema-qualified refresh +# 10. Use DropMaterializedViewExpression with cascade=True to drop dependent objects +# 11. Use DropMaterializedViewExpression with if_exists=True for safe drop +# 12. Requires PostgreSQL 9.3+ for basic MV support +# 13. Requires PostgreSQL 9.4+ for CONCURRENTLY refresh \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/array_type_diff.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/array_type_diff.py new file mode 100644 index 00000000..5b0583c7 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/array_type_diff.py @@ -0,0 +1,141 @@ +""" +Schema diff: PostgreSQL array type dimension changes. + +PostgresArrayType.is_equivalent() handles dimension comparison +internally. Column type changes like INTEGER[] → INTEGER[][] +are detected via parsed_data_type equivalence checks. + +Supported versions: PostgreSQL 8.4+ (arrays available since PG 8.4) +""" + +# ============================================================ +# 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, ColumnDefinition, + ColumnConstraint, ColumnConstraintType, +) +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, ArrayType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) + +config = PostgresConnectionConfig( + host=os.getenv("POSTGRES_HOST", "localhost"), + port=int(os.getenv("POSTGRES_PORT", "5432")), + database=os.getenv("POSTGRES_DATABASE", "test"), + username=os.getenv("POSTGRES_USER", "postgres"), + password=os.getenv("POSTGRES_PASSWORD", ""), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +backend.introspect_and_adapt() +dialect = backend.dialect + +drop_v1 = DropTableExpression(dialect, "scores_v1", if_exists=True, cascade=True) +drop_v2 = DropTableExpression(dialect, "scores_v2", if_exists=True, cascade=True) +sql, params = drop_v1.to_sql() +backend.execute(sql, params) +sql, params = drop_v2.to_sql() +backend.execute(sql, params) + +# One-dimensional array column +scores_v1_table = CreateTableExpression( + dialect=dialect, table="scores_v1", columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("values", ArrayType(IntegerType(), dimensions=1)), + ] +) +sql, params = scores_v1_table.to_sql() +backend.execute(sql, params) + +# Two-dimensional array column +scores_v2_table = CreateTableExpression( + dialect=dialect, table="scores_v2", columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("values", ArrayType(IntegerType(), dimensions=2)), + ] +) +sql, params = scores_v2_table.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.schema import ( # noqa: E402 + SyncSchemaSnapshotBuilder, +) +from rhosocial.activerecord.backend.impl.postgres.schema.differ import ( # noqa: E402 + PostgresSchemaDiffer, +) + +builder = SyncSchemaSnapshotBuilder(backend.introspector, dialect) +snapshot = builder.build(schema="public") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +for tbl_name in ("scores_v1", "scores_v2"): + if tbl_name in snapshot.tables: + for col in snapshot.tables[tbl_name].columns: + if col.name == "values": + pt = col.parsed_data_type + dims = pt.dimensions if hasattr(pt, "dimensions") else "N/A" + print(f"{tbl_name}.values: data_type='{col.data_type}', dimensions={dims}") + +# Manually construct two snapshots that differ only in array dimensions +from rhosocial.activerecord.backend.introspection.types import ( # noqa: E402 + TableInfo, ColumnInfo, ColumnNullable, TableType, DatabaseInfo, +) +from rhosocial.activerecord.backend.expression.types import IntegerType, ArrayType # noqa: E402 +from datetime import datetime, timezone # noqa: E402 + +db_info = DatabaseInfo(name="test", version="16", version_tuple=(16, 0, 0), vendor="postgresql") + +# V1: 1-d array +col1d = ColumnInfo( + name="values", table_name="t", ordinal_position=1, + data_type="INTEGER[]", parsed_data_type=ArrayType(IntegerType(), dimensions=1), + nullable=ColumnNullable.NULLABLE, +) +snap1d = snapshot.__class__( + dialect_class=snapshot.dialect_class, + captured_at=datetime.now(tz=timezone.utc), + database_info=db_info, + tables={"t": TableInfo(name="t", columns=[col1d], table_type=TableType.BASE_TABLE)}, +) + +# V2: 2-d array +col2d = ColumnInfo( + name="values", table_name="t", ordinal_position=1, + data_type="INTEGER[][]", parsed_data_type=ArrayType(IntegerType(), dimensions=2), + nullable=ColumnNullable.NULLABLE, +) +snap2d = snapshot.__class__( + dialect_class=snapshot.dialect_class, + captured_at=datetime.now(tz=timezone.utc), + database_info=db_info, + tables={"t": TableInfo(name="t", columns=[col2d], table_type=TableType.BASE_TABLE)}, +) + +differ = PostgresSchemaDiffer() +diff = differ.compare(snap1d, snap2d) +print("\nDiff 1-d vs 2-d array:") +print(f" Modified tables: {diff.modified_tables}") +print(f" Diff is empty: {diff.is_empty}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +sql, params = drop_v1.to_sql() +backend.execute(sql, params) +sql, params = drop_v2.to_sql() +backend.execute(sql, params) +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/column_order_irrelevant.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/column_order_irrelevant.py new file mode 100644 index 00000000..1015e988 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/column_order_irrelevant.py @@ -0,0 +1,103 @@ +""" +Schema diff: PostgreSQL column order has no semantic meaning. + +Unlike MySQL, PostgreSQL does not assign semantic significance to +column ordinal position. Reordering columns produces no diff because +PostgresSchemaDiffer only compares type, nullability, and default value. + +Supported versions: PostgreSQL 9+ +""" + +# ============================================================ +# 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, ColumnDefinition, + ColumnConstraint, ColumnConstraintType, +) +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, VarCharType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AlterTableExpression, AddColumn, DropColumn, +) + +config = PostgresConnectionConfig( + host=os.getenv("POSTGRES_HOST", "localhost"), + port=int(os.getenv("POSTGRES_PORT", "5432")), + database=os.getenv("POSTGRES_DATABASE", "test"), + username=os.getenv("POSTGRES_USER", "postgres"), + password=os.getenv("POSTGRES_PASSWORD", ""), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +backend.introspect_and_adapt() +dialect = backend.dialect + +drop_demo = DropTableExpression(dialect, "demo", if_exists=True, cascade=True) +sql, params = drop_demo.to_sql() +backend.execute(sql, params) + +demo_table = CreateTableExpression( + dialect=dialect, table="demo", columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", VarCharType(100)), + ColumnDefinition("email", VarCharType(200)), + ] +) +sql, params = demo_table.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.schema import ( # noqa: E402 + SyncSchemaSnapshotBuilder, +) +from rhosocial.activerecord.backend.impl.postgres.schema.differ import ( # noqa: E402 + PostgresSchemaDiffer, +) + +builder = SyncSchemaSnapshotBuilder(backend.introspector, dialect) +snapshot_before = builder.build(schema="public") + +# Add a column between name and email — shifts ordinal positions +# but PostgresSchemaDiffer ignores ordinal_position. +add_age = AlterTableExpression(dialect, "demo", [ + AddColumn(dialect, ColumnDefinition("age", IntegerType())) +]) +sql, params = add_age.to_sql() +backend.execute(sql, params) + +# Now drop it — column set is identical +drop_age = AlterTableExpression(dialect, "demo", [ + DropColumn(dialect, "age") +]) +sql, params = drop_age.to_sql() +backend.execute(sql, params) + +snapshot_after = builder.build(schema="public") + +differ = PostgresSchemaDiffer() +diff = differ.compare(snapshot_before, snapshot_after) + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +print(f"Modified tables: {diff.modified_tables}") +print(f"Diff is empty: {diff.is_empty}") +print("(No diff expected — column set unchanged regardless of position)") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +sql, params = drop_demo.to_sql() +backend.execute(sql, params) +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/index_type_diff.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/index_type_diff.py new file mode 100644 index 00000000..ca9c7a29 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/index_type_diff.py @@ -0,0 +1,103 @@ +""" +Schema diff: PostgreSQL-specific index types (BTREE, GIN, GiST, HASH). + +PostgreSQL supports multiple index access methods. The differ checks +index_type for equivalence, so switching from BTREE to GIN or GiST +will be reported as a removed+added index pair. + +Supported versions: PostgreSQL 9+ +""" + +# ============================================================ +# 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, ColumnDefinition, + ColumnConstraint, ColumnConstraintType, +) +from rhosocial.activerecord.backend.expression.types import ( + VarCharType, ArrayType, IntegerType, TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) +from rhosocial.activerecord.backend.expression.statements.ddl_index import ( + CreateIndexExpression, DropIndexExpression, +) + +config = PostgresConnectionConfig( + host=os.getenv("POSTGRES_HOST", "localhost"), + port=int(os.getenv("POSTGRES_PORT", "5432")), + database=os.getenv("POSTGRES_DATABASE", "test"), + username=os.getenv("POSTGRES_USER", "postgres"), + password=os.getenv("POSTGRES_PASSWORD", ""), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +backend.introspect_and_adapt() +dialect = backend.dialect + +drop_demo = DropTableExpression(dialect, "demo", if_exists=True, cascade=True) +sql, params = drop_demo.to_sql() +backend.execute(sql, params) + +demo_table = CreateTableExpression( + dialect=dialect, table="demo", columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", VarCharType(200)), + ColumnDefinition("tags", ArrayType(TextType(), dimensions=1)), + ] +) +sql, params = demo_table.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.schema import ( # noqa: E402 + SyncSchemaSnapshotBuilder, +) +from rhosocial.activerecord.backend.impl.postgres.schema.differ import ( # noqa: E402 + PostgresSchemaDiffer, +) + +builder = SyncSchemaSnapshotBuilder(backend.introspector, dialect) + +# Snapshot with BTREE index +idx_btree = CreateIndexExpression(dialect, "idx_name", "demo", ["name"], index_type="BTREE") +sql, params = idx_btree.to_sql() +backend.execute(sql, params) +snap_btree = builder.build(schema="public") + +# Drop BTREE, create GIN index on array column — different index type +drop_idx = DropIndexExpression(dialect, "idx_name", "demo", if_exists=True) +sql, params = drop_idx.to_sql() +backend.execute(sql, params) +idx_gin = CreateIndexExpression(dialect, "idx_tags", "demo", ["tags"], index_type="GIN") +sql, params = idx_gin.to_sql() +backend.execute(sql, params) +snap_gin = builder.build(schema="public") + +differ = PostgresSchemaDiffer() +diff = differ.compare(snap_btree, snap_gin) + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +print(f"Modified tables: {diff.modified_tables}") + +if "demo" in diff.table_diffs: + td = diff.table_diffs["demo"] + print(f" Removed indexes: {[(idx.name, idx.index_type.value) for idx in td.removed_indexes]}") + print(f" Added indexes: {[(idx.name, idx.index_type.value) for idx in td.added_indexes]}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +sql, params = drop_demo.to_sql() +backend.execute(sql, params) +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/postgres10_identity.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/postgres10_identity.py new file mode 100644 index 00000000..7075b33e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/postgres10_identity.py @@ -0,0 +1,116 @@ +""" +Schema diff: PostgreSQL 9 vs 10+ — SERIAL vs GENERATED AS IDENTITY. + +PostgreSQL 9: SERIAL creates a sequence + DEFAULT nextval(). +PostgreSQL 10+: GENERATED AS IDENTITY is the SQL-standard way. + +When comparing snapshots, a SERIAL column and an IDENTITY column +will appear differently in introspected metadata: +- SERIAL: default_value = 'nextval(''tab_col_seq''::regclass)' +- IDENTITY: is_auto_increment = True, special metadata + +Supported versions: PostgreSQL 9 — SERIAL only. + PostgreSQL 10+ — GENERATED AS IDENTITY available. +""" + +# ============================================================ +# 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, ColumnDefinition, + ColumnConstraint, ColumnConstraintType, +) +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, VarCharType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) + +config = PostgresConnectionConfig( + host=os.getenv("POSTGRES_HOST", "localhost"), + port=int(os.getenv("POSTGRES_PORT", "5432")), + database=os.getenv("POSTGRES_DATABASE", "test"), + username=os.getenv("POSTGRES_USER", "postgres"), + password=os.getenv("POSTGRES_PASSWORD", ""), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +backend.introspect_and_adapt() +dialect = backend.dialect + +drop_pg9 = DropTableExpression(dialect, "pg9_style", if_exists=True, cascade=True) +drop_pg10 = DropTableExpression(dialect, "pg10_style", if_exists=True, cascade=True) +sql, params = drop_pg9.to_sql() +backend.execute(sql, params) +sql, params = drop_pg10.to_sql() +backend.execute(sql, params) + +# PostgreSQL 9-compatible: SERIAL shorthand +pg9_table = CreateTableExpression( + dialect=dialect, table="pg9_style", columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", VarCharType(100)), + ] +) +sql, params = pg9_table.to_sql() +backend.execute(sql, params) + +# PostgreSQL 10+: GENERATED AS IDENTITY +# Note: dialect_options={'identity': 'ALWAYS'} triggers GENERATED ALWAYS AS IDENTITY +# in the PostgreSQL dialect's format_column_definition +try: + pg10_table = CreateTableExpression( + dialect=dialect, table="pg10_style", columns=[ + ColumnDefinition("id", IntegerType(), + constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)], + dialect_options={"identity": "ALWAYS"}), + ColumnDefinition("name", VarCharType(100)), + ] + ) + sql, params = pg10_table.to_sql() + backend.execute(sql, params) +except Exception as e: + print(f"(GENERATED AS IDENTITY not available on this server: {e})") + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.schema import ( # noqa: E402 + SyncSchemaSnapshotBuilder, +) + +builder = SyncSchemaSnapshotBuilder(backend.introspector, dialect) +snapshot = builder.build(schema="public") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +for tbl_name in ("pg9_style", "pg10_style"): + if tbl_name in snapshot.tables: + tbl = snapshot.tables[tbl_name] + for col in tbl.columns: + if col.is_auto_increment or col.is_primary_key: + print(f"{tbl_name}.{col.name}:") + print(f" data_type: {col.data_type}") + print(f" is_auto_increment:{col.is_auto_increment}") + print(f" is_generated: {col.is_generated}") + print(f" default_value: {col.default_value}") + print(f" generated_expr: {col.generated_expression}") + +print() +print("Note: SERIAL vs IDENTITY columns produce different metadata.") +print("When diff-ing PG9 vs PG10+ snapshots, these differences will appear.") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +sql, params = drop_pg9.to_sql() +backend.execute(sql, params) +sql, params = drop_pg10.to_sql() +backend.execute(sql, params) +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/serialization_roundtrip.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/serialization_roundtrip.py new file mode 100644 index 00000000..b630b4f0 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/serialization_roundtrip.py @@ -0,0 +1,127 @@ +""" +Schema diff: snapshot serialization roundtrip across sessions. + +Serialized snapshots can be stored as JSON files and compared +later — useful for CI/CD pipeline schema validation. + +Supported versions: PostgreSQL 9+ +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import json +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, ColumnDefinition, + ColumnConstraint, ColumnConstraintType, +) +from rhosocial.activerecord.backend.expression.types import ( + VarCharType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AlterTableExpression, AddColumn, +) + +config = PostgresConnectionConfig( + host=os.getenv("POSTGRES_HOST", "localhost"), + port=int(os.getenv("POSTGRES_PORT", "5432")), + database=os.getenv("POSTGRES_DATABASE", "test"), + username=os.getenv("POSTGRES_USER", "postgres"), + password=os.getenv("POSTGRES_PASSWORD", ""), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +backend.introspect_and_adapt() +dialect = backend.dialect + +drop_users = DropTableExpression(dialect, "users", if_exists=True, cascade=True) +sql, params = drop_users.to_sql() +backend.execute(sql, params) + +users_table = CreateTableExpression( + dialect=dialect, table="users", columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", VarCharType(100)), + ColumnDefinition("email", VarCharType(200)), + ] +) +sql, params = users_table.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.schema import ( # noqa: E402 + SchemaSnapshot, + SyncSchemaSnapshotBuilder, +) +from rhosocial.activerecord.backend.impl.postgres.schema.differ import ( # noqa: E402 + PostgresSchemaDiffer, +) + +builder = SyncSchemaSnapshotBuilder(backend.introspector, dialect) +snapshot_before = builder.build(schema="public") + +# Serialize to JSON and deserialize back +snapshot_json = json.dumps(snapshot_before.to_dict(), default=str) +snapshot_loaded = SchemaSnapshot.from_dict(json.loads(snapshot_json)) + +# Modify the database +add_phone = AlterTableExpression(dialect, "users", [ + AddColumn(dialect, ColumnDefinition("phone", VarCharType(20))) +]) +sql, params = add_phone.to_sql() +backend.execute(sql, params) + +snapshot_after = builder.build(schema="public") + +# Diff: loaded snapshot (before) vs current (after) +differ = PostgresSchemaDiffer() +diff = differ.compare(snapshot_loaded, snapshot_after) + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +print(f"Snapshot size (JSON): {len(snapshot_json)} chars") +print(f"Roundtrip preserves dialect: {snapshot_loaded.dialect_class == snapshot_before.dialect_class}") +# Verify table data survived the roundtrip +assert snapshot_loaded.dialect_class == snapshot_before.dialect_class +assert list(snapshot_loaded.tables.keys()) == list(snapshot_before.tables.keys()), ( + f"table names mismatch: {list(snapshot_loaded.tables.keys())} " + f"!= {list(snapshot_before.tables.keys())}" +) +for name in snapshot_before.tables: + before_t = snapshot_before.tables[name] + after_t = snapshot_loaded.tables[name] + assert [c.name for c in before_t.columns] == [c.name for c in after_t.columns], ( + f"columns mismatch in table '{name}'" + ) + for col_b, col_a in zip(before_t.columns, after_t.columns): + assert col_b.data_type.__class__.__name__ == col_a.data_type.__class__.__name__, ( + f"type mismatch in {name}.{col_b.name}" + ) +print(f"Table data roundtrip: all columns and types match") +print(f"Added tables: {diff.added_tables}") +print(f"Removed tables: {diff.removed_tables}") +print(f"Modified tables: {diff.modified_tables}") + +if "users" in diff.table_diffs: + td = diff.table_diffs["users"] + db_info = snapshot_after.database_info + print(f"Database version: {db_info.version} (vendor={db_info.vendor})") + for cd in td.column_diffs: + print(f" Column '{cd.column_name}': added") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +sql, params = drop_users.to_sql() +backend.execute(sql, params) +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/table_changes.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/table_changes.py new file mode 100644 index 00000000..e9665e0b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/table_changes.py @@ -0,0 +1,109 @@ +""" +Schema diff: basic table add/remove detection in PostgreSQL. + +PostgreSQL uses multi-level namespaces (schema.table), and the +PostgresSchemaDiffer treats column order as semantically irrelevant. + +Supported versions: PostgreSQL 9+ +""" + +# ============================================================ +# 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, ColumnDefinition, + ColumnConstraint, ColumnConstraintType, TableConstraint, TableConstraintType, +) +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, VarCharType, DecimalType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) + +config = PostgresConnectionConfig( + host=os.getenv("POSTGRES_HOST", "localhost"), + port=int(os.getenv("POSTGRES_PORT", "5432")), + database=os.getenv("POSTGRES_DATABASE", "test"), + username=os.getenv("POSTGRES_USER", "postgres"), + password=os.getenv("POSTGRES_PASSWORD", ""), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +backend.introspect_and_adapt() +dialect = backend.dialect + +drop_users = DropTableExpression(dialect, "users", if_exists=True, cascade=True) +drop_orders = DropTableExpression(dialect, "orders", if_exists=True, cascade=True) +sql, params = drop_users.to_sql() +backend.execute(sql, params) +sql, params = drop_orders.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.schema import ( # noqa: E402 + SyncSchemaSnapshotBuilder, +) +from rhosocial.activerecord.backend.impl.postgres.schema.differ import ( # noqa: E402 + PostgresSchemaDiffer, +) + +builder = SyncSchemaSnapshotBuilder(backend.introspector, dialect) +snapshot_before = builder.build(schema="public") + +users_table = CreateTableExpression( + dialect=dialect, table="users", columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", VarCharType(100), + constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL)]), + ] +) +sql, params = users_table.to_sql() +backend.execute(sql, params) + +orders_table = CreateTableExpression( + dialect=dialect, table="orders", columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("user_id", IntegerType()), + ColumnDefinition("amount", DecimalType(10, 2)), + ], + table_constraints=[ + TableConstraint( + constraint_type=TableConstraintType.FOREIGN_KEY, + columns=["user_id"], + foreign_key_table="users", + foreign_key_columns=["id"], + ) + ] +) +sql, params = orders_table.to_sql() +backend.execute(sql, params) + +snapshot_after = builder.build(schema="public") + +differ = PostgresSchemaDiffer() +diff = differ.compare(snapshot_before, snapshot_after) + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +print(f"Added tables: {diff.added_tables}") +print(f"Removed tables: {diff.removed_tables}") +print(f"Modified tables:{diff.modified_tables}") +print(f"Diff is empty: {diff.is_empty}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +sql, params = drop_users.to_sql() +backend.execute(sql, params) +sql, params = drop_orders.to_sql() +backend.execute(sql, params) +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py index 9de671dd..14200826 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py @@ -47,6 +47,58 @@ PostgresPgPartmanDeleteConfigExpression, ) +# DataType subclasses for DDL +from .types import ( + PostgresBigSerialType, + PostgresBitType, + PostgresBoxType, + PostgresByteaType, + PostgresCharacterVaryingType, + PostgresCIDType, + PostgresCircleType, + PostgresDateMultirangeType, + PostgresDateRangeType, + PostgresGeographyType, + PostgresGeometryType, + PostgresHstoreType, + PostgresInetType, + PostgresInt4MultirangeType, + PostgresInt4RangeType, + PostgresInt8MultirangeType, + PostgresInt8RangeType, + PostgresJsonPathType, + PostgresLineSegmentType, + PostgresLineType, + PostgresMacAddr8Type, + PostgresMacAddrType, + PostgresMoneyType, + PostgresNumMultirangeType, + PostgresNumRangeType, + PostgresOIDType, + PostgresPathType, + PostgresPgLSNType, + PostgresPointType, + PostgresPolygonType, + PostgresRegClassType, + PostgresRegTypeType, + PostgresSerialType, + PostgresSmallSerialType, + PostgresTIDType, + PostgresTSQueryType, + PostgresTSVectorType, + PostgresTsMultirangeType, + PostgresTsRangeType, + PostgresTsTzMultirangeType, + PostgresTsTzRangeType, + PostgresUUIDType, + PostgresVarBitType, + PostgresVectorType, + PostgresXID8Type, + PostgresXIDType, + PostgresXMLType, + PostgresCidrType, +) + __all__ = [ # advisory "PostgresAdvisoryLockExpression", @@ -82,4 +134,53 @@ "PostgresPgPartmanRunMaintenanceExpression", "PostgresPgPartmanUpdateConfigExpression", "PostgresPgPartmanDeleteConfigExpression", + # DataType subclasses for DDL + "PostgresBigSerialType", + "PostgresBitType", + "PostgresBoxType", + "PostgresByteaType", + "PostgresCharacterVaryingType", + "PostgresCIDType", + "PostgresCircleType", + "PostgresDateMultirangeType", + "PostgresDateRangeType", + "PostgresGeographyType", + "PostgresGeometryType", + "PostgresHstoreType", + "PostgresInetType", + "PostgresInt4MultirangeType", + "PostgresInt4RangeType", + "PostgresInt8MultirangeType", + "PostgresInt8RangeType", + "PostgresJsonPathType", + "PostgresLineSegmentType", + "PostgresLineType", + "PostgresMacAddr8Type", + "PostgresMacAddrType", + "PostgresMoneyType", + "PostgresNumMultirangeType", + "PostgresNumRangeType", + "PostgresOIDType", + "PostgresPathType", + "PostgresPgLSNType", + "PostgresPointType", + "PostgresPolygonType", + "PostgresRegClassType", + "PostgresRegTypeType", + "PostgresSerialType", + "PostgresSmallSerialType", + "PostgresTIDType", + "PostgresTSQueryType", + "PostgresTSVectorType", + "PostgresTsMultirangeType", + "PostgresTsRangeType", + "PostgresTsTzMultirangeType", + "PostgresTsTzRangeType", + "PostgresUUIDType", + "PostgresVarBitType", + "PostgresVectorType", + "PostgresXID8Type", + "PostgresXIDType", + "PostgresXMLType", + "PostgresCidrType", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py new file mode 100644 index 00000000..5a1508fd --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py @@ -0,0 +1,391 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/types.py +"""PostgreSQL-specific DataType subclasses. + +Naming convention +----------------- +PostgreSQL-specific types use the ``Postgres`` prefix to distinguish them +from the core types (which have no prefix). This avoids ambiguity when both +core and backend types are used together. + +Usage scope +----------- +These types are used **only** for PostgreSQL backend DDL column definitions, +introspection result parsing, and schema comparison. They should **not** +be used by application code directly — always use the core types for +DDL definition expressions (``ColumnDefinition.data_type``). +""" + +from __future__ import annotations + +from typing import Optional, Set + +from rhosocial.activerecord.backend.expression.types import ( + ArrayType, + BlobType, + DataType, + IntegerType, + SmallIntType, + BigIntType, + VarCharType, +) + + +# --------------------------------------------------------------------------- +# Character varying alias +# --------------------------------------------------------------------------- + +class PostgresCharacterVaryingType(VarCharType, backend="postgres"): + """PostgreSQL ``CHARACTER VARYING(n)`` — alias for ``VARCHAR(n)``.""" + + @classmethod + def synonyms(cls) -> Set[str]: + return {'VarCharType'} + + +# --------------------------------------------------------------------------- +# Binary data: BYTEA +# --------------------------------------------------------------------------- + +class PostgresByteaType(BlobType, backend="postgres"): + """PostgreSQL ``BYTEA`` — variable-length binary string.""" + + @classmethod + def synonyms(cls) -> Set[str]: + return {'BlobType'} + + +# --------------------------------------------------------------------------- +# Serial (auto-increment) types +# --------------------------------------------------------------------------- + +class PostgresSmallSerialType(DataType, backend="postgres"): + """PostgreSQL ``SMALLSERIAL`` — auto-incrementing SMALLINT (2 bytes).""" + + @classmethod + def synonyms(cls) -> Set[str]: + return {'SmallSerialType', 'SmallIntType'} + + +class PostgresSerialType(DataType, backend="postgres"): + """PostgreSQL ``SERIAL`` — auto-incrementing INTEGER (4 bytes).""" + + @classmethod + def synonyms(cls) -> Set[str]: + return {'SerialType', 'IntegerType'} + + +class PostgresBigSerialType(DataType, backend="postgres"): + """PostgreSQL ``BIGSERIAL`` — auto-incrementing BIGINT (8 bytes).""" + + @classmethod + def synonyms(cls) -> Set[str]: + return {'BigSerialType', 'BigIntType'} + + +# --------------------------------------------------------------------------- +# UUID +# --------------------------------------------------------------------------- + +class PostgresUUIDType(DataType, backend="postgres"): + """PostgreSQL ``UUID`` — universally unique identifier.""" + + +# --------------------------------------------------------------------------- +# XML +# --------------------------------------------------------------------------- + +class PostgresXMLType(DataType, backend="postgres"): + """PostgreSQL ``XML`` — XML data type.""" + + +# --------------------------------------------------------------------------- +# Text search +# --------------------------------------------------------------------------- + +class PostgresTSVectorType(DataType, backend="postgres"): + """PostgreSQL ``TSVECTOR`` — text search document.""" + + +class PostgresTSQueryType(DataType, backend="postgres"): + """PostgreSQL ``TSQUERY`` — text search query.""" + + +# --------------------------------------------------------------------------- +# JSON path +# --------------------------------------------------------------------------- + +class PostgresJsonPathType(DataType, backend="postgres"): + """PostgreSQL ``JSONPATH`` — SQL/JSON path expression (PG 12+).""" + + +# --------------------------------------------------------------------------- +# Bit string types +# --------------------------------------------------------------------------- + +class PostgresBitType(DataType, backend="postgres"): + """PostgreSQL ``BIT(n)`` — fixed-length bit string.""" + + n: Optional[int] = None + + def __init__(self, n: Optional[int] = None): + super().__init__() + self.n = n + + def __eq__(self, other: object) -> bool: + if type(self) is not type(other): + return False + return self.n == other.n + + def __hash__(self) -> int: + return hash((type(self), self.n)) + + +class PostgresVarBitType(DataType, backend="postgres"): + """PostgreSQL ``VARBIT(n)`` — variable-length bit string.""" + + n: Optional[int] = None + + def __init__(self, n: Optional[int] = None): + super().__init__() + self.n = n + + def __eq__(self, other: object) -> bool: + if type(self) is not type(other): + return False + return self.n == other.n + + def __hash__(self) -> int: + return hash((type(self), self.n)) + + +# --------------------------------------------------------------------------- +# Network address types +# --------------------------------------------------------------------------- + +class PostgresInetType(DataType, backend="postgres"): + """PostgreSQL ``INET`` — IPv4 or IPv6 address.""" + + +class PostgresCidrType(DataType, backend="postgres"): + """PostgreSQL ``CIDR`` — IPv4 or IPv6 network.""" + + +class PostgresMacAddrType(DataType, backend="postgres"): + """PostgreSQL ``MACADDR`` — MAC address (EUI-48).""" + + +class PostgresMacAddr8Type(DataType, backend="postgres"): + """PostgreSQL ``MACADDR8`` — MAC address (EUI-64, PG 10+).""" + + +# --------------------------------------------------------------------------- +# Geometric types +# --------------------------------------------------------------------------- + +class PostgresPointType(DataType, backend="postgres"): + """PostgreSQL ``POINT`` — geometric point (x, y).""" + + +class PostgresLineType(DataType, backend="postgres"): + """PostgreSQL ``LINE`` — infinite line.""" + + +class PostgresLineSegmentType(DataType, backend="postgres"): + """PostgreSQL ``LSEG`` — line segment.""" + + +class PostgresBoxType(DataType, backend="postgres"): + """PostgreSQL ``BOX`` — rectangular box.""" + + +class PostgresPathType(DataType, backend="postgres"): + """PostgreSQL ``PATH`` — open or closed geometric path.""" + + +class PostgresPolygonType(DataType, backend="postgres"): + """PostgreSQL ``POLYGON`` — closed geometric polygon.""" + + +class PostgresCircleType(DataType, backend="postgres"): + """PostgreSQL ``CIRCLE`` — circle (center + radius).""" + + +# --------------------------------------------------------------------------- +# Monetary type +# --------------------------------------------------------------------------- + +class PostgresMoneyType(DataType, backend="postgres"): + """PostgreSQL ``MONEY`` — currency amount.""" + + +# --------------------------------------------------------------------------- +# Range types +# --------------------------------------------------------------------------- + +class PostgresInt4RangeType(DataType, backend="postgres"): + """PostgreSQL ``INT4RANGE`` — range of integer.""" + + +class PostgresInt8RangeType(DataType, backend="postgres"): + """PostgreSQL ``INT8RANGE`` — range of bigint.""" + + +class PostgresNumRangeType(DataType, backend="postgres"): + """PostgreSQL ``NUMRANGE`` — range of numeric.""" + + +class PostgresTsRangeType(DataType, backend="postgres"): + """PostgreSQL ``TSRANGE`` — range of timestamp without time zone.""" + + +class PostgresTsTzRangeType(DataType, backend="postgres"): + """PostgreSQL ``TSTZRANGE`` — range of timestamp with time zone.""" + + +class PostgresDateRangeType(DataType, backend="postgres"): + """PostgreSQL ``DATERANGE`` — range of date.""" + + +# --------------------------------------------------------------------------- +# Multirange types (PG 14+) +# --------------------------------------------------------------------------- + +class PostgresInt4MultirangeType(DataType, backend="postgres"): + """PostgreSQL ``INT4MULTIRANGE`` — multirange of integer (PG 14+).""" + + +class PostgresInt8MultirangeType(DataType, backend="postgres"): + """PostgreSQL ``INT8MULTIRANGE`` — multirange of bigint (PG 14+).""" + + +class PostgresNumMultirangeType(DataType, backend="postgres"): + """PostgreSQL ``NUMMULTIRANGE`` — multirange of numeric (PG 14+).""" + + +class PostgresTsMultirangeType(DataType, backend="postgres"): + """PostgreSQL ``TSMULTIRANGE`` — multirange of timestamp (PG 14+).""" + + +class PostgresTsTzMultirangeType(DataType, backend="postgres"): + """PostgreSQL ``TSTZMULTIRANGE`` — multirange of timestamptz (PG 14+).""" + + +class PostgresDateMultirangeType(DataType, backend="postgres"): + """PostgreSQL ``DATEMULTIRANGE`` — multirange of date (PG 14+).""" + + +# --------------------------------------------------------------------------- +# Object identifier types +# --------------------------------------------------------------------------- + +class PostgresOIDType(DataType, backend="postgres"): + """PostgreSQL ``OID`` — object identifier.""" + + +class PostgresRegClassType(DataType, backend="postgres"): + """PostgreSQL ``REGCLASS`` — relation name (OID alias).""" + + +class PostgresRegTypeType(DataType, backend="postgres"): + """PostgreSQL ``REGTYPE`` — type name (OID alias).""" + + +class PostgresXIDType(DataType, backend="postgres"): + """PostgreSQL ``XID`` — transaction ID.""" + + +class PostgresXID8Type(DataType, backend="postgres"): + """PostgreSQL ``XID8`` — 64-bit transaction ID (PG 13+).""" + + +class PostgresCIDType(DataType, backend="postgres"): + """PostgreSQL ``CID`` — command ID.""" + + +class PostgresTIDType(DataType, backend="postgres"): + """PostgreSQL ``TID`` — tuple ID (page, tuple).""" + + +# --------------------------------------------------------------------------- +# pg_lsn type +# --------------------------------------------------------------------------- + +class PostgresPgLSNType(DataType, backend="postgres"): + """PostgreSQL ``PG_LSN`` — WAL log sequence number.""" + + +# --------------------------------------------------------------------------- +# Extension-provided types (minimal DataType wrappers) +# --------------------------------------------------------------------------- + +class PostgresHstoreType(DataType, backend="postgres"): + """PostgreSQL ``HSTORE`` — key-value store (hstore extension).""" + + +class PostgresGeometryType(DataType, backend="postgres"): + """PostGIS ``GEOMETRY`` — generic spatial geometry (PostGIS extension). + + For production use, subclass with SRID support as needed. + """ + + +class PostgresGeographyType(DataType, backend="postgres"): + """PostGIS ``GEOGRAPHY`` — geodetic spatial type (PostGIS extension).""" + + +class PostgresCitextType(DataType, backend="postgres"): + """PostgreSQL ``CITEXT`` — case-insensitive text (citext extension).""" + + +class PostgresCubeType(DataType, backend="postgres"): + """PostgreSQL ``CUBE`` — multi-dimensional cube (cube extension).""" + + +class PostgresLtreeType(DataType, backend="postgres"): + """PostgreSQL ``LTREE`` — label tree (ltree extension).""" + + +class PostgresRasterType(DataType, backend="postgres"): + """PostgreSQL ``RASTER`` — raster (PostGIS raster extension).""" + + +class PostgresVectorType(DataType, backend="postgres"): + """pgvector ``VECTOR(n)`` — vector embedding (pgvector extension). + + Args: + dim: Number of dimensions. + """ + + dim: int + + def __init__(self, dim: int): + super().__init__() + self.dim = dim + + def __eq__(self, other: object) -> bool: + if type(self) is not type(other): + return False + return self.dim == other.dim + + def __hash__(self) -> int: + return hash((type(self), self.dim)) + + +# --------------------------------------------------------------------------- +# Array container +# --------------------------------------------------------------------------- + +class PostgresArrayType(ArrayType): + """PostgreSQL array type. + + PostgreSQL normalises all multi-dimensional array declarations to a + single-dimensional internal representation at the storage level. + Therefore ``integer[][]`` and ``integer[]`` are considered equivalent + during schema comparison: ``is_equivalent`` intentionally ignores + ``dimensions``. + """ + + def is_equivalent(self, other: "DataType") -> bool: + if not isinstance(other, ArrayType): + return False + return self.element_type.is_equivalent(other.element_type) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py index c4504f73..3ba187d1 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py @@ -50,6 +50,7 @@ TriggerInfo, ViewInfo, ) +from rhosocial.activerecord.backend.expression.types import DataType from .status_introspector import ( SyncPostgreSQLStatusIntrospector, AsyncPostgreSQLStatusIntrospector, @@ -137,6 +138,7 @@ def _parse_columns(self, rows: List[Dict[str, Any]], table_name: str, schema: st data_type = row.get("data_type", "") # Extract base type (remove array brackets, size specifiers) base_type = data_type.split("[")[0].split("(")[0].strip() + parsed = DataType.parse_data_type_str(self._backend.dialect, data_type) columns.append( ColumnInfo( name=row["column_name"], @@ -145,6 +147,7 @@ def _parse_columns(self, rows: List[Dict[str, Any]], table_name: str, schema: st ordinal_position=row["ordinal_position"], data_type=base_type, data_type_full=data_type, + parsed_data_type=parsed, nullable=ColumnNullable.NULLABLE if row.get("is_nullable") else ColumnNullable.NOT_NULL, default_value=row.get("default_value"), is_primary_key=row.get("is_primary_key", False), diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py index 232e1805..77981ee9 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py @@ -38,6 +38,7 @@ from .types.range_type import PostgresRangeTypeMixin from .types.jsonb_enhanced import PostgresJSONBEnhancedMixin from .types.array_enhanced import PostgresArrayEnhancedMixin +from .types.data_type_formatting import PostgresTypeFormatSupportMixin # Extension mixins from .extensions.pgvector import PostgresPgvectorMixin @@ -129,6 +130,7 @@ "PostgresRangeTypeMixin", "PostgresJSONBEnhancedMixin", "PostgresArrayEnhancedMixin", + "PostgresTypeFormatSupportMixin", # Extension mixins "PostgresPgvectorMixin", "PostgresPostGISMixin", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py index 77e7c10f..66290066 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py @@ -40,13 +40,20 @@ def format_create_table_like(self, expr) -> tuple: def format_column_definition(self, col_def) -> Tuple[str, tuple]: from rhosocial.activerecord.backend.dialect.base import SQLDialectBase all_params: List[Any] = [] - data_type = col_def.data_type - if not re.fullmatch(r"[A-Za-z0-9\s(),\[\]]+", data_type): + type_sql, _ = col_def.data_type.to_sql(self) + if not re.fullmatch(r"[A-Za-z0-9\s(),\[\]]+", type_sql): raise ValueError( - f"Invalid data type '{data_type}': " + f"Invalid data type '{type_sql}': " "must contain only alphanumeric characters, spaces, parentheses, commas, and brackets." ) - col_sql = f"{self.format_identifier(col_def.name)} {data_type}" + col_sql = f"{self.format_identifier(col_def.name)} {type_sql}" + dialect_opts = col_def.dialect_options or {} + identity = dialect_opts.get("identity") + if identity: + if identity.upper() in ("ALWAYS", "BY DEFAULT"): + col_sql += f" GENERATED {identity.upper()} AS IDENTITY" + else: + raise ValueError(f"Invalid identity option '{identity}': must be 'ALWAYS' or 'BY DEFAULT'") for constraint in col_def.constraints: suffix, params = self.format_column_constraint(constraint) col_sql += suffix diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/__init__.py index a223f6b2..0d877803 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/__init__.py @@ -8,6 +8,7 @@ from .range_type import PostgresRangeTypeMixin from .jsonb_enhanced import PostgresJSONBEnhancedMixin from .array_enhanced import PostgresArrayEnhancedMixin +from .data_type_formatting import PostgresTypeFormatSupportMixin __all__ = [ "EnumTypeMixin", @@ -17,4 +18,5 @@ "PostgresRangeTypeMixin", "PostgresJSONBEnhancedMixin", "PostgresArrayEnhancedMixin", + "PostgresTypeFormatSupportMixin", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type_formatting.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type_formatting.py new file mode 100644 index 00000000..bb41a98e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type_formatting.py @@ -0,0 +1,821 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type_formatting.py +"""PostgreSQL DataType formatting and parsing mixin.""" + +from __future__ import annotations + +import re +from typing import Tuple, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.mixins.ddl_type import DDLTypeMixin +from rhosocial.activerecord.backend.dialect.protocols import DDLTypeSupport +from rhosocial.activerecord.backend.expression.types import ( + ArrayType, + BigIntType, + BlobType, + BooleanType, + CharType, + DataType, + DateType, + DateTimeType, + DecimalType, + DoubleType, + FloatType, + IntegerType, + IntType, + IntervalType, + JsonBType, + JsonType, + RealType, + SmallIntType, + TextType, + TimeType, + TimeTzType, + TimestampType, + TimestampTzType, + TinyIntType, + VarCharType, +) +from ...expression.types import ( + PostgresArrayType, + PostgresBigSerialType, + PostgresBitType, + PostgresBoxType, + PostgresByteaType, + PostgresCIDType, + PostgresCircleType, + PostgresDateMultirangeType, + PostgresDateRangeType, + PostgresGeographyType, + PostgresGeometryType, + PostgresHstoreType, + PostgresInetType, + PostgresInt4MultirangeType, + PostgresInt4RangeType, + PostgresInt8MultirangeType, + PostgresInt8RangeType, + PostgresJsonPathType, + PostgresLineSegmentType, + PostgresLineType, + PostgresMacAddr8Type, + PostgresMacAddrType, + PostgresMoneyType, + PostgresNumMultirangeType, + PostgresNumRangeType, + PostgresOIDType, + PostgresPathType, + PostgresPgLSNType, + PostgresPointType, + PostgresPolygonType, + PostgresRegClassType, + PostgresRegTypeType, + PostgresSerialType, + PostgresSmallSerialType, + PostgresTIDType, + PostgresTSQueryType, + PostgresTSVectorType, + PostgresTsMultirangeType, + PostgresTsRangeType, + PostgresTsTzMultirangeType, + PostgresTsTzRangeType, + PostgresUUIDType, + PostgresVarBitType, + PostgresVectorType, + PostgresXID8Type, + PostgresXIDType, + PostgresXMLType, + PostgresCidrType, + PostgresCharacterVaryingType, + PostgresCitextType, + PostgresCubeType, + PostgresLtreeType, + PostgresRasterType, +) + +if TYPE_CHECKING: + pass + + +class PostgresTypeFormatSupportMixin(DDLTypeMixin, DDLTypeSupport): + """PostgreSQL DataType formatting and parsing. + + Implements ``DDLTypeSupport`` so the dialect can render ``DataType`` + expressions to SQL strings and parse raw SQL type strings back into + ``DataType`` instances. + """ + + # ------------------------------------------------------------------ + # DDLTypeSupport — formatting + # ------------------------------------------------------------------ + + # --- PostgreSQL-specific formatters --- + + @DDLTypeMixin.handles(PostgresByteaType) + def format_data_type_bytea(self, data_type) -> Tuple[str, tuple]: + return "BYTEA", () + + @DDLTypeMixin.handles(PostgresSmallSerialType) + def format_data_type_small_serial(self, data_type) -> Tuple[str, tuple]: + return "SMALLSERIAL", () + + @DDLTypeMixin.handles(PostgresSerialType) + def format_data_type_serial(self, data_type) -> Tuple[str, tuple]: + return "SERIAL", () + + @DDLTypeMixin.handles(PostgresBigSerialType) + def format_data_type_big_serial(self, data_type) -> Tuple[str, tuple]: + return "BIGSERIAL", () + + @DDLTypeMixin.handles(PostgresUUIDType) + def format_data_type_uuid(self, data_type) -> Tuple[str, tuple]: + return "UUID", () + + @DDLTypeMixin.handles(PostgresXMLType) + def format_data_type_xml(self, data_type) -> Tuple[str, tuple]: + return "XML", () + + @DDLTypeMixin.handles(PostgresCharacterVaryingType) + def format_data_type_character_varying(self, data_type: PostgresCharacterVaryingType) -> Tuple[str, tuple]: + if data_type.length is not None: + return f"CHARACTER VARYING({data_type.length})", () + return "CHARACTER VARYING", () + + @DDLTypeMixin.handles(PostgresTSVectorType) + def format_data_type_ts_vector(self, data_type) -> Tuple[str, tuple]: + return "TSVECTOR", () + + @DDLTypeMixin.handles(PostgresTSQueryType) + def format_data_type_ts_query(self, data_type) -> Tuple[str, tuple]: + return "TSQUERY", () + + @DDLTypeMixin.handles(PostgresJsonPathType) + def format_data_type_json_path(self, data_type) -> Tuple[str, tuple]: + return "JSONPATH", () + + @DDLTypeMixin.handles(PostgresBitType) + def format_data_type_bit(self, data_type) -> Tuple[str, tuple]: + if data_type.n is not None: + return f"BIT({data_type.n})", () + return "BIT", () + + @DDLTypeMixin.handles(PostgresVarBitType) + def format_data_type_var_bit(self, data_type) -> Tuple[str, tuple]: + if data_type.n is not None: + return f"VARBIT({data_type.n})", () + return "VARBIT", () + + @DDLTypeMixin.handles(PostgresInetType) + def format_data_type_inet(self, data_type) -> Tuple[str, tuple]: + return "INET", () + + @DDLTypeMixin.handles(PostgresCidrType) + def format_data_type_cidr(self, data_type) -> Tuple[str, tuple]: + return "CIDR", () + + @DDLTypeMixin.handles(PostgresMacAddrType) + def format_data_type_mac_addr(self, data_type) -> Tuple[str, tuple]: + return "MACADDR", () + + @DDLTypeMixin.handles(PostgresMacAddr8Type) + def format_data_type_mac_addr8(self, data_type) -> Tuple[str, tuple]: + return "MACADDR8", () + + @DDLTypeMixin.handles(PostgresPointType) + def format_data_type_point(self, data_type) -> Tuple[str, tuple]: + return "POINT", () + + @DDLTypeMixin.handles(PostgresLineType) + def format_data_type_line(self, data_type) -> Tuple[str, tuple]: + return "LINE", () + + @DDLTypeMixin.handles(PostgresLineSegmentType) + def format_data_type_line_segment(self, data_type) -> Tuple[str, tuple]: + return "LSEG", () + + @DDLTypeMixin.handles(PostgresBoxType) + def format_data_type_box(self, data_type) -> Tuple[str, tuple]: + return "BOX", () + + @DDLTypeMixin.handles(PostgresPathType) + def format_data_type_path(self, data_type) -> Tuple[str, tuple]: + return "PATH", () + + @DDLTypeMixin.handles(PostgresPolygonType) + def format_data_type_polygon(self, data_type) -> Tuple[str, tuple]: + return "POLYGON", () + + @DDLTypeMixin.handles(PostgresCircleType) + def format_data_type_circle(self, data_type) -> Tuple[str, tuple]: + return "CIRCLE", () + + @DDLTypeMixin.handles(PostgresMoneyType) + def format_data_type_money(self, data_type) -> Tuple[str, tuple]: + return "MONEY", () + + @DDLTypeMixin.handles(PostgresInt4RangeType) + def format_data_type_int4_range(self, data_type) -> Tuple[str, tuple]: + return "INT4RANGE", () + + @DDLTypeMixin.handles(PostgresInt8RangeType) + def format_data_type_int8_range(self, data_type) -> Tuple[str, tuple]: + return "INT8RANGE", () + + @DDLTypeMixin.handles(PostgresNumRangeType) + def format_data_type_num_range(self, data_type) -> Tuple[str, tuple]: + return "NUMRANGE", () + + @DDLTypeMixin.handles(PostgresTsRangeType) + def format_data_type_ts_range(self, data_type) -> Tuple[str, tuple]: + return "TSRANGE", () + + @DDLTypeMixin.handles(PostgresTsTzRangeType) + def format_data_type_ts_tz_range(self, data_type) -> Tuple[str, tuple]: + return "TSTZRANGE", () + + @DDLTypeMixin.handles(PostgresDateRangeType) + def format_data_type_date_range(self, data_type) -> Tuple[str, tuple]: + return "DATERANGE", () + + @DDLTypeMixin.handles(PostgresInt4MultirangeType) + def format_data_type_int4_multirange(self, data_type) -> Tuple[str, tuple]: + return "INT4MULTIRANGE", () + + @DDLTypeMixin.handles(PostgresInt8MultirangeType) + def format_data_type_int8_multirange(self, data_type) -> Tuple[str, tuple]: + return "INT8MULTIRANGE", () + + @DDLTypeMixin.handles(PostgresNumMultirangeType) + def format_data_type_num_multirange(self, data_type) -> Tuple[str, tuple]: + return "NUMMULTIRANGE", () + + @DDLTypeMixin.handles(PostgresTsMultirangeType) + def format_data_type_ts_multirange(self, data_type) -> Tuple[str, tuple]: + return "TSMULTIRANGE", () + + @DDLTypeMixin.handles(PostgresTsTzMultirangeType) + def format_data_type_ts_tz_multirange(self, data_type) -> Tuple[str, tuple]: + return "TSTZMULTIRANGE", () + + @DDLTypeMixin.handles(PostgresDateMultirangeType) + def format_data_type_date_multirange(self, data_type) -> Tuple[str, tuple]: + return "DATEMULTIRANGE", () + + @DDLTypeMixin.handles(PostgresOIDType) + def format_data_type_oid(self, data_type) -> Tuple[str, tuple]: + return "OID", () + + @DDLTypeMixin.handles(PostgresRegClassType) + def format_data_type_reg_class(self, data_type) -> Tuple[str, tuple]: + return "REGCLASS", () + + @DDLTypeMixin.handles(PostgresRegTypeType) + def format_data_type_reg_type(self, data_type) -> Tuple[str, tuple]: + return "REGTYPE", () + + @DDLTypeMixin.handles(PostgresXIDType) + def format_data_type_xid(self, data_type) -> Tuple[str, tuple]: + return "XID", () + + @DDLTypeMixin.handles(PostgresXID8Type) + def format_data_type_xid8(self, data_type) -> Tuple[str, tuple]: + return "XID8", () + + @DDLTypeMixin.handles(PostgresCIDType) + def format_data_type_cid(self, data_type) -> Tuple[str, tuple]: + return "CID", () + + @DDLTypeMixin.handles(PostgresTIDType) + def format_data_type_tid(self, data_type) -> Tuple[str, tuple]: + return "TID", () + + @DDLTypeMixin.handles(PostgresPgLSNType) + def format_data_type_pg_lsn(self, data_type) -> Tuple[str, tuple]: + return "PG_LSN", () + + @DDLTypeMixin.handles(PostgresHstoreType) + def format_data_type_hstore(self, data_type) -> Tuple[str, tuple]: + return "HSTORE", () + + @DDLTypeMixin.handles(PostgresGeometryType) + def format_data_type_geometry(self, data_type) -> Tuple[str, tuple]: + return "GEOMETRY", () + + @DDLTypeMixin.handles(PostgresGeographyType) + def format_data_type_geography(self, data_type) -> Tuple[str, tuple]: + return "GEOGRAPHY", () + + @DDLTypeMixin.handles(PostgresVectorType) + def format_data_type_vector(self, data_type) -> Tuple[str, tuple]: + return f"VECTOR({data_type.dim})", () + + @DDLTypeMixin.handles(PostgresCitextType) + def format_data_type_citext(self, data_type) -> Tuple[str, tuple]: + return "CITEXT", () + + @DDLTypeMixin.handles(PostgresCubeType) + def format_data_type_cube(self, data_type) -> Tuple[str, tuple]: + return "CUBE", () + + @DDLTypeMixin.handles(PostgresLtreeType) + def format_data_type_ltree(self, data_type) -> Tuple[str, tuple]: + return "LTREE", () + + @DDLTypeMixin.handles(PostgresRasterType) + def format_data_type_raster(self, data_type) -> Tuple[str, tuple]: + return "RASTER", () + + @DDLTypeMixin.handles(ArrayType) + def format_data_type_array(self, data_type: ArrayType) -> Tuple[str, tuple]: + elem = data_type.element_type + elem_cls = type(elem) + if elem_cls not in self._type_formatters: + raise TypeError( + f"ArrayType element type {elem_cls.__name__!r} is not supported " + f"by {type(self).__name__}. Use a PostgreSQL-specific DataType " + f"as the element type (e.g. PostgresIntegerType)." + ) + element_sql, _ = self.format_data_type(elem) + return element_sql + "[]" * data_type.dimensions, () + + # --- Core formatters (PostgreSQL specialized) --- + + @DDLTypeMixin.handles(TinyIntType) + def format_data_type_tiny_int(self, data_type: TinyIntType) -> Tuple[str, tuple]: + return "SMALLINT", () + + @DDLTypeMixin.handles(SmallIntType) + def format_data_type_small_int(self, data_type: SmallIntType) -> Tuple[str, tuple]: + return "SMALLINT", () + + @DDLTypeMixin.handles(IntType) + def format_data_type_int(self, data_type: IntType) -> Tuple[str, tuple]: + return "INTEGER", () + + @DDLTypeMixin.handles(IntegerType) + def format_data_type_integer(self, data_type: IntegerType) -> Tuple[str, tuple]: + return "INTEGER", () + + @DDLTypeMixin.handles(BigIntType) + def format_data_type_big_int(self, data_type: BigIntType) -> Tuple[str, tuple]: + return "BIGINT", () + + @DDLTypeMixin.handles(FloatType) + def format_data_type_float(self, data_type: FloatType) -> Tuple[str, tuple]: + if data_type.precision is not None: + return f"FLOAT({data_type.precision})", () + return "REAL", () + + @DDLTypeMixin.handles(RealType) + def format_data_type_real(self, data_type: RealType) -> Tuple[str, tuple]: + return "REAL", () + + @DDLTypeMixin.handles(DoubleType) + def format_data_type_double(self, data_type: DoubleType) -> Tuple[str, tuple]: + return "DOUBLE PRECISION", () + + @DDLTypeMixin.handles(DecimalType) + def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: + if data_type.precision is not None and data_type.scale is not None: + return f"DECIMAL({data_type.precision},{data_type.scale})", () + if data_type.precision is not None: + return f"DECIMAL({data_type.precision})", () + return "DECIMAL", () + + @DDLTypeMixin.handles(CharType) + def format_data_type_char(self, data_type: CharType) -> Tuple[str, tuple]: + if data_type.length is not None: + return f"CHAR({data_type.length})", () + return "CHAR", () + + @DDLTypeMixin.handles(VarCharType) + def format_data_type_var_char(self, data_type: VarCharType) -> Tuple[str, tuple]: + if data_type.length is not None: + return f"VARCHAR({data_type.length})", () + return "VARCHAR", () + + @DDLTypeMixin.handles(TextType) + def format_data_type_text(self, data_type: TextType) -> Tuple[str, tuple]: + return "TEXT", () + + @DDLTypeMixin.handles(BooleanType) + def format_data_type_boolean(self, data_type: BooleanType) -> Tuple[str, tuple]: + return "BOOLEAN", () + + @DDLTypeMixin.handles(BlobType) + def format_data_type_blob(self, data_type: BlobType) -> Tuple[str, tuple]: + return "BYTEA", () + + @DDLTypeMixin.handles(DateType) + def format_data_type_date(self, data_type: DateType) -> Tuple[str, tuple]: + return "DATE", () + + @DDLTypeMixin.handles(TimeType) + def format_data_type_time(self, data_type: TimeType) -> Tuple[str, tuple]: + if data_type.precision is not None: + return f"TIME({data_type.precision})", () + return "TIME", () + + @DDLTypeMixin.handles(TimeTzType) + def format_data_type_time_tz(self, data_type: TimeTzType) -> Tuple[str, tuple]: + if data_type.precision is not None: + return f"TIME({data_type.precision}) WITH TIME ZONE", () + return "TIME WITH TIME ZONE", () + + @DDLTypeMixin.handles(DateTimeType) + def format_data_type_date_time(self, data_type: DateTimeType) -> Tuple[str, tuple]: + return "TIMESTAMP", () + + @DDLTypeMixin.handles(TimestampType) + def format_data_type_timestamp(self, data_type: TimestampType) -> Tuple[str, tuple]: + if data_type.precision is not None: + return f"TIMESTAMP({data_type.precision})", () + return "TIMESTAMP", () + + @DDLTypeMixin.handles(TimestampTzType) + def format_data_type_timestamp_tz(self, data_type: TimestampTzType) -> Tuple[str, tuple]: + if data_type.precision is not None: + return f"TIMESTAMP({data_type.precision}) WITH TIME ZONE", () + return "TIMESTAMP WITH TIME ZONE", () + + @DDLTypeMixin.handles(IntervalType) + def format_data_type_interval(self, data_type: IntervalType) -> Tuple[str, tuple]: + if data_type.fields is not None: + return f"INTERVAL {data_type.fields}", () + return "INTERVAL", () + + @DDLTypeMixin.handles(JsonType) + def format_data_type_json(self, data_type: JsonType) -> Tuple[str, tuple]: + return "JSON", () + + @DDLTypeMixin.handles(JsonBType) + def format_data_type_json_b(self, data_type: JsonBType) -> Tuple[str, tuple]: + return "JSONB", () + + # ------------------------------------------------------------------ + # DDLTypeSupport — parsing + # ------------------------------------------------------------------ + + _PG_INTEGER_TYPES = re.compile( + r"^(?:SMALLINT|INT2|INT|INTEGER|INT4|BIGINT|INT8)\b", + re.IGNORECASE, + ) + _PG_SERIAL_TYPES = re.compile( + r"^(?:SMALLSERIAL|SERIAL2|SERIAL|SERIAL4|BIGSERIAL|SERIAL8)\b", + re.IGNORECASE, + ) + _PG_FLOAT_TYPES = re.compile( + r"^(?:REAL|FLOAT4|FLOAT|DOUBLE)\b", + re.IGNORECASE, + ) + _PG_DECIMAL_TYPES = re.compile( + r"^(?:DECIMAL|NUMERIC)\b", + re.IGNORECASE, + ) + _PG_STRING_TYPES = re.compile( + r"^(?:CHAR|VARCHAR|TEXT)\b", + re.IGNORECASE, + ) + _PG_BINARY_TYPES = re.compile( + r"^(?:BYTEA|BLOB)\b", + re.IGNORECASE, + ) + _PG_DATE_TYPES = re.compile( + r"^(?:DATE|DATETIME|TIMESTAMP|TIME|INTERVAL)\b", + re.IGNORECASE, + ) + _PG_JSON_TYPES = re.compile( + r"^(?:JSON|JSONB|JSONPATH)\b", + re.IGNORECASE, + ) + _PG_UUID_TYPES = re.compile( + r"^(?:UUID)\b", + re.IGNORECASE, + ) + _PG_NET_TYPES = re.compile( + r"^(?:INET|CIDR|MACADDR|MACADDR8)\b", + re.IGNORECASE, + ) + _PG_GEOM_TYPES = re.compile( + r"^(?:POINT|LINE|LSEG|BOX|PATH|POLYGON|CIRCLE)\b", + re.IGNORECASE, + ) + _PG_BIT_TYPES = re.compile( + r"^(?:BIT|VARBIT)\b", + re.IGNORECASE, + ) + _PG_RANGE_TYPES = re.compile( + r"^(?:INT4RANGE|INT8RANGE|NUMRANGE|TSRANGE|TSTZRANGE|DATERANGE)\b", + re.IGNORECASE, + ) + _PG_MULTIRANGE_TYPES = re.compile( + r"^(?:INT4MULTIRANGE|INT8MULTIRANGE|NUMMULTIRANGE|" + r"TSMULTIRANGE|TSTZMULTIRANGE|DATEMULTIRANGE)\b", + re.IGNORECASE, + ) + _PG_OID_TYPES = re.compile( + r"^(?:OID|REGCLASS|REGTYPE|XID|XID8|CID|TID)\b", + re.IGNORECASE, + ) + _PG_TS_TYPES = re.compile( + r"^(?:TSVECTOR|TSQUERY)\b", + re.IGNORECASE, + ) + _PG_MISC_TYPES = re.compile( + r"^(?:MONEY|XML|PG_LSN|HSTORE|GEOMETRY|GEOGRAPHY)\b", + re.IGNORECASE, + ) + _PG_ARRAY_SUFFIX = re.compile(r"(\[(\d*)\])+\s*$") + _PG_ARRAY_KEYWORD = re.compile(r"\s+ARRAY(?:\s*\[\s*(\d+)\s*\])?\s*$", re.IGNORECASE) + + def parse_type(self, raw: str) -> DataType: + stripped = raw.strip() + upper = stripped.upper() + + # ---- Array detection (must run before any type-specific match) ---- + + # Check for ARRAY keyword: e.g. "INTEGER ARRAY" or "INTEGER ARRAY[3]" + kw_match = self._PG_ARRAY_KEYWORD.search(upper) + if kw_match: + base_raw = stripped[:kw_match.start()] + element_type = self.parse_type(base_raw) + # PG normalises all array declarations to 1-D internally + return PostgresArrayType(element_type, dimensions=1) + + # Check for array bracket suffix: e.g. "INTEGER[]", "INTEGER[][]" + remaining = stripped + while remaining.endswith("[]"): + remaining = remaining[:-2] + while remaining.endswith("]"): + m = self._PG_ARRAY_SUFFIX.search(remaining) + if m: + remaining = remaining[:m.start()] + else: + break + + if remaining != stripped: + element_type = self.parse_type(remaining) + # PG normalises all array declarations to 1-D internally + return PostgresArrayType(element_type, dimensions=1) + + # ---- Standard type dispatch ---- + + # Serial family + if self._PG_SERIAL_TYPES.match(upper): + from ..expression.types import ( + PostgresBigSerialType, + PostgresSerialType, + PostgresSmallSerialType, + ) + if upper.startswith("BIGSERIAL") or upper.startswith("SERIAL8"): + return PostgresBigSerialType() + if upper.startswith("SMALLSERIAL") or upper.startswith("SERIAL2"): + return PostgresSmallSerialType() + return PostgresSerialType() + + # Integer family + if self._PG_INTEGER_TYPES.match(upper): + if upper.startswith("SMALLINT") or upper.startswith("INT2"): + return SmallIntType() + if upper.startswith("BIGINT") or upper.startswith("INT8"): + return BigIntType() + return IntegerType() + + # Float family + if self._PG_FLOAT_TYPES.match(upper): + if upper.startswith("DOUBLE"): + return DoubleType() + if upper.startswith("REAL") or upper.startswith("FLOAT4"): + return RealType() + nums = re.findall(r"\d+", stripped) + precision = int(nums[0]) if nums else None + return FloatType(precision) + + # Decimal family + if self._PG_DECIMAL_TYPES.match(upper): + nums = re.findall(r"\d+", stripped) + if len(nums) >= 2: + return DecimalType(int(nums[0]), int(nums[1])) + if len(nums) == 1: + return DecimalType(int(nums[0])) + return DecimalType() + + # String family + if self._PG_STRING_TYPES.match(upper): + length_match = re.search(r"\((\d+)\)", stripped) + length = int(length_match.group(1)) if length_match else None + if upper.startswith("VARCHAR") or upper.startswith("CHARACTER VARYING"): + return VarCharType(length) + if upper.startswith("CHAR") or upper.startswith("CHARACTER"): + return CharType(length) + return TextType() + + # Binary + if self._PG_BINARY_TYPES.match(upper): + from ..expression.types import PostgresByteaType + return PostgresByteaType() + + # Date/time + if self._PG_DATE_TYPES.match(upper): + if upper.startswith("DATE"): + return DateType() + if upper.startswith("DATETIME"): + return DateTimeType() + if upper.startswith("TIMESTAMP"): + nums = re.findall(r"\d+", stripped) + precision = int(nums[0]) if nums else None + if "WITH TIME ZONE" in upper or upper.startswith("TIMESTAMPTZ"): + return TimestampTzType(precision) + return TimestampType(precision) + if upper.startswith("TIME"): + nums = re.findall(r"\d+", stripped) + precision = int(nums[0]) if nums else None + if "WITH TIME ZONE" in upper or upper.startswith("TIMETZ"): + return TimeTzType(precision) + return TimeType(precision) + if upper.startswith("INTERVAL"): + fields_match = re.search(r"INTERVAL\s+(.*)", upper) + fields = fields_match.group(1).strip() if fields_match else None + return IntervalType(fields) + + # JSON + if self._PG_JSON_TYPES.match(upper): + if upper.startswith("JSONB"): + return JsonBType() + if upper.startswith("JSONPATH"): + from ..expression.types import PostgresJsonPathType + return PostgresJsonPathType() + return JsonType() + + # UUID + if self._PG_UUID_TYPES.match(upper): + from ..expression.types import PostgresUUIDType + return PostgresUUIDType() + + # Boolean + if upper.startswith("BOOLEAN") or upper.startswith("BOOL"): + return BooleanType() + + # Bit string + if self._PG_BIT_TYPES.match(upper): + nums = re.findall(r"\d+", stripped) + n = int(nums[0]) if nums else None + if upper.startswith("BIT"): + from ..expression.types import PostgresBitType + return PostgresBitType(n) + from ..expression.types import PostgresVarBitType + return PostgresVarBitType(n) + + # Network address + if self._PG_NET_TYPES.match(upper): + from ..expression.types import ( + PostgresCidrType, + PostgresInetType, + PostgresMacAddr8Type, + PostgresMacAddrType, + ) + if upper.startswith("CIDR"): + return PostgresCidrType() + if upper.startswith("MACADDR8"): + return PostgresMacAddr8Type() + if upper.startswith("MACADDR"): + return PostgresMacAddrType() + return PostgresInetType() + + # Geometric + if self._PG_GEOM_TYPES.match(upper): + from ..expression.types import ( + PostgresBoxType, + PostgresCircleType, + PostgresLineSegmentType, + PostgresLineType, + PostgresPathType, + PostgresPointType, + PostgresPolygonType, + ) + geom_map = { + "POINT": PostgresPointType, + "LINE": PostgresLineType, + "LSEG": PostgresLineSegmentType, + "BOX": PostgresBoxType, + "PATH": PostgresPathType, + "POLYGON": PostgresPolygonType, + "CIRCLE": PostgresCircleType, + } + for name, cls in geom_map.items(): + if upper.startswith(name): + return cls() + return PostgresPointType() + + # Range types + if self._PG_RANGE_TYPES.match(upper): + from ..expression.types import ( + PostgresDateRangeType, + PostgresInt4RangeType, + PostgresInt8RangeType, + PostgresNumRangeType, + PostgresTsRangeType, + PostgresTsTzRangeType, + ) + range_map = { + "INT4RANGE": PostgresInt4RangeType, + "INT8RANGE": PostgresInt8RangeType, + "NUMRANGE": PostgresNumRangeType, + "TSRANGE": PostgresTsRangeType, + "TSTZRANGE": PostgresTsTzRangeType, + "DATERANGE": PostgresDateRangeType, + } + for name, cls in range_map.items(): + if upper.startswith(name): + return cls() + return PostgresInt4RangeType() + + # Multirange types + if self._PG_MULTIRANGE_TYPES.match(upper): + from ..expression.types import ( + PostgresDateMultirangeType, + PostgresInt4MultirangeType, + PostgresInt8MultirangeType, + PostgresNumMultirangeType, + PostgresTsMultirangeType, + PostgresTsTzMultirangeType, + ) + mr_map = { + "INT4MULTIRANGE": PostgresInt4MultirangeType, + "INT8MULTIRANGE": PostgresInt8MultirangeType, + "NUMMULTIRANGE": PostgresNumMultirangeType, + "TSMULTIRANGE": PostgresTsMultirangeType, + "TSTZMULTIRANGE": PostgresTsTzMultirangeType, + "DATEMULTIRANGE": PostgresDateMultirangeType, + } + for name, cls in mr_map.items(): + if upper.startswith(name): + return cls() + return PostgresInt4MultirangeType() + + # OID types + if self._PG_OID_TYPES.match(upper): + from ..expression.types import ( + PostgresCIDType, + PostgresOIDType, + PostgresRegClassType, + PostgresRegTypeType, + PostgresTIDType, + PostgresXID8Type, + PostgresXIDType, + ) + oid_map = { + "OID": PostgresOIDType, + "REGCLASS": PostgresRegClassType, + "REGTYPE": PostgresRegTypeType, + "XID8": PostgresXID8Type, + "XID": PostgresXIDType, + "CID": PostgresCIDType, + "TID": PostgresTIDType, + } + for name, cls in oid_map.items(): + if upper.startswith(name): + return cls() + return PostgresOIDType() + + # Text search + if self._PG_TS_TYPES.match(upper): + from ..expression.types import ( + PostgresTSQueryType, + PostgresTSVectorType, + ) + if upper.startswith("TSVECTOR"): + return PostgresTSVectorType() + return PostgresTSQueryType() + + # Miscellaneous + if self._PG_MISC_TYPES.match(upper): + from ..expression.types import ( + PostgresGeographyType, + PostgresGeometryType, + PostgresHstoreType, + PostgresMoneyType, + PostgresPgLSNType, + PostgresXMLType, + ) + if upper.startswith("MONEY"): + return PostgresMoneyType() + if upper.startswith("XML"): + return PostgresXMLType() + if upper.startswith("PG_LSN"): + return PostgresPgLSNType() + if upper.startswith("HSTORE"): + return PostgresHstoreType() + if upper.startswith("GEOGRAPHY"): + return PostgresGeographyType() + if upper.startswith("GEOMETRY"): + return PostgresGeometryType() + + # Vector type (pgvector) + if upper.startswith("VECTOR"): + nums = re.findall(r"\d+", stripped) + dim = int(nums[0]) if nums else 0 + from ..expression.types import PostgresVectorType + return PostgresVectorType(dim) + + # Fallback + from rhosocial.activerecord.backend.expression.types import CustomType + return CustomType(stripped) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/schema/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/schema/__init__.py new file mode 100644 index 00000000..5da28ebe --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/schema/__init__.py @@ -0,0 +1,6 @@ +# src/rhosocial/activerecord/backend/impl/postgres/schema/__init__.py +"""PostgreSQL schema differ.""" + +from .differ import PostgresSchemaDiffer + +__all__ = ["PostgresSchemaDiffer"] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/schema/differ.py b/src/rhosocial/activerecord/backend/impl/postgres/schema/differ.py new file mode 100644 index 00000000..cc5f46c7 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/schema/differ.py @@ -0,0 +1,15 @@ +# src/rhosocial/activerecord/backend/impl/postgres/schema/differ.py +"""PostgreSQL schema differ — column order has no semantic meaning.""" + +from rhosocial.activerecord.backend.schema.differ import SchemaDiffer + + +class PostgresSchemaDiffer(SchemaDiffer): + """PostgreSQL schema differ. + + Column order has no semantic meaning in PostgreSQL, and array + dimension equivalence is already handled by + ``PostgresArrayType.is_equivalent()``, so the default + ``_columns_equivalent`` is sufficient. + """ + pass diff --git a/tests/providers/partition.py b/tests/providers/partition.py index 2f99935c..64a73d68 100644 --- a/tests/providers/partition.py +++ b/tests/providers/partition.py @@ -25,6 +25,13 @@ from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord from rhosocial.activerecord.testsuite.feature.partition.interfaces import IPartitionProvider +from rhosocial.activerecord.backend.expression.types import ( + BigIntType, IntegerType, TextType, TimestampType, DecimalType, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, ColumnConstraintType, +) + from .scenarios import get_enabled_scenarios, get_scenario @@ -245,11 +252,11 @@ def _create_parent_sql(self, dialect): dialect=dialect, table=self.TABLE_NAME, columns=[ - ColumnDefinition("id", "BIGINT GENERATED BY DEFAULT AS IDENTITY"), - ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), - ColumnDefinition("tenant_id", "INTEGER NOT NULL"), - ColumnDefinition("payload", "TEXT"), - ColumnDefinition("amount", "NUMERIC(12, 2)"), + ColumnDefinition("id", BigIntType(), dialect_options={"identity": "BY DEFAULT"}), + ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("tenant_id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("payload", TextType()), + ColumnDefinition("amount", DecimalType(12, 2)), ], partition=PartitionClause( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom_integration.py index 814d970b..b0509aff 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom_integration.py @@ -33,6 +33,13 @@ from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) + TABLE_NAME = "test_bloom" ASYNC_TABLE_NAME = "test_bloom_async" @@ -55,13 +62,13 @@ def bloom_env(postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="email", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="email", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -159,12 +166,12 @@ def test_bloom_index_with_options(self, bloom_env): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="code", data_type="TEXT"), + ColumnDefinition(name="code", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -232,13 +239,13 @@ async def async_bloom_env(async_postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="email", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="email", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -336,12 +343,12 @@ async def test_async_bloom_index_with_options(self, async_bloom_env): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="code", data_type="TEXT"), + ColumnDefinition(name="code", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin_integration.py index a94ded0a..a6fd3461 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin_integration.py @@ -39,6 +39,15 @@ from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, + TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, + PostgresArrayType, +) + @pytest.fixture def btree_gin_env(postgres_backend_single): @@ -51,14 +60,14 @@ def btree_gin_env(postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="value", data_type="INTEGER"), - ColumnDefinition(name="tags", data_type="TEXT[]"), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="value", data_type=IntegerType()), + ColumnDefinition(name="tags", data_type=PostgresArrayType(TextType())), ] create_expr = CreateTableExpression( dialect=dialect, @@ -206,14 +215,14 @@ async def async_btree_gin_env(async_postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="value", data_type="INTEGER"), - ColumnDefinition(name="tags", data_type="TEXT[]"), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="value", data_type=IntegerType()), + ColumnDefinition(name="tags", data_type=PostgresArrayType(TextType())), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist_integration.py index 39636320..8e6fc040 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist_integration.py @@ -41,6 +41,15 @@ from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, + TextType, + TimestampType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) + @pytest.fixture def btree_gist_env(postgres_backend_single): @@ -53,14 +62,14 @@ def btree_gist_env(postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="created_at", data_type="TIMESTAMP"), - ColumnDefinition(name="value", data_type="INTEGER"), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="created_at", data_type=TimestampType()), + ColumnDefinition(name="value", data_type=IntegerType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -217,14 +226,14 @@ async def async_btree_gist_env(async_postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="created_at", data_type="TIMESTAMP"), - ColumnDefinition(name="value", data_type="INTEGER"), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="created_at", data_type=TimestampType()), + ColumnDefinition(name="value", data_type=IntegerType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_citext_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_citext_integration.py index c87b27d2..19277faf 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_citext_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_citext_integration.py @@ -38,6 +38,14 @@ from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, + PostgresCitextType, +) + # --- Helper functions --- @@ -47,12 +55,12 @@ def _setup_citext_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="CITEXT"), + ColumnDefinition(name="name", data_type=PostgresCitextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -81,14 +89,14 @@ def _setup_citext_unique_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), ColumnDefinition( name="email", - data_type="CITEXT", + data_type=PostgresCitextType(), constraints=[ ColumnConstraint(ColumnConstraintType.UNIQUE), ], @@ -121,12 +129,12 @@ def _setup_citext_like_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="CITEXT"), + ColumnDefinition(name="name", data_type=PostgresCitextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -158,12 +166,12 @@ def _setup_citext_users_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="username", data_type="CITEXT"), + ColumnDefinition(name="username", data_type=PostgresCitextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -192,13 +200,13 @@ def _setup_citext_roles_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="username", data_type="CITEXT"), - ColumnDefinition(name="role", data_type="TEXT"), + ColumnDefinition(name="username", data_type=PostgresCitextType()), + ColumnDefinition(name="role", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -238,12 +246,12 @@ async def _async_setup_citext_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="CITEXT"), + ColumnDefinition(name="name", data_type=PostgresCitextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -272,14 +280,14 @@ async def _async_setup_citext_unique_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), ColumnDefinition( name="email", - data_type="CITEXT", + data_type=PostgresCitextType(), constraints=[ ColumnConstraint(ColumnConstraintType.UNIQUE), ], @@ -312,12 +320,12 @@ async def _async_setup_citext_like_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="CITEXT"), + ColumnDefinition(name="name", data_type=PostgresCitextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -349,12 +357,12 @@ async def _async_setup_citext_users_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="username", data_type="CITEXT"), + ColumnDefinition(name="username", data_type=PostgresCitextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -383,13 +391,13 @@ async def _async_setup_citext_roles_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="username", data_type="CITEXT"), - ColumnDefinition(name="role", data_type="TEXT"), + ColumnDefinition(name="username", data_type=PostgresCitextType()), + ColumnDefinition(name="role", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_cube_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_cube_integration.py index a7be5d5f..d2966b0d 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_cube_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_cube_integration.py @@ -38,6 +38,11 @@ from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresCubeType, + PostgresSerialType, +) + # --- Sync fixture and tests --- @@ -62,12 +67,12 @@ def cube_env(postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="c", data_type="CUBE"), + ColumnDefinition(name="c", data_type=PostgresCubeType()), ], if_not_exists=True, ) @@ -97,12 +102,12 @@ def cube_env(postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="c", data_type="CUBE"), + ColumnDefinition(name="c", data_type=PostgresCubeType()), ], if_not_exists=True, ) @@ -132,12 +137,12 @@ def cube_env(postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="c", data_type="CUBE"), + ColumnDefinition(name="c", data_type=PostgresCubeType()), ], if_not_exists=True, ) @@ -348,12 +353,12 @@ async def async_cube_env(async_postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="c", data_type="CUBE"), + ColumnDefinition(name="c", data_type=PostgresCubeType()), ], if_not_exists=True, ) @@ -383,12 +388,12 @@ async def async_cube_env(async_postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="c", data_type="CUBE"), + ColumnDefinition(name="c", data_type=PostgresCubeType()), ], if_not_exists=True, ) @@ -418,12 +423,12 @@ async def async_cube_env(async_postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="c", data_type="CUBE"), + ColumnDefinition(name="c", data_type=PostgresCubeType()), ], if_not_exists=True, ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_earthdistance_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_earthdistance_integration.py index 3af2872a..90298a2c 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_earthdistance_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_earthdistance_integration.py @@ -29,6 +29,11 @@ from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresPointType, + PostgresSerialType, +) + TABLE_NAME = "test_earth" ASYNC_TABLE_NAME = "test_earth_async" @@ -51,12 +56,12 @@ def earthdistance_env(postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="loc", data_type="POINT"), + ColumnDefinition(name="loc", data_type=PostgresPointType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -163,12 +168,12 @@ async def async_earthdistance_env(async_postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="loc", data_type="POINT"), + ColumnDefinition(name="loc", data_type=PostgresPointType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_fuzzystrmatch_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_fuzzystrmatch_integration.py index 311d54ee..64be7549 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_fuzzystrmatch_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_fuzzystrmatch_integration.py @@ -36,6 +36,12 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) TABLE_NAME = "test_fuzzy_lev" @@ -49,12 +55,12 @@ def _setup_lev_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="word", data_type="TEXT"), + ColumnDefinition(name="word", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -86,12 +92,12 @@ def _setup_diff_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -133,12 +139,12 @@ async def _async_setup_lev_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="word", data_type="TEXT"), + ColumnDefinition(name="word", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -170,12 +176,12 @@ async def _async_setup_diff_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hstore_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hstore_integration.py index f8b99261..1530b978 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hstore_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hstore_integration.py @@ -38,6 +38,10 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, + PostgresHstoreType, +) # --- Helper functions --- @@ -48,12 +52,12 @@ def _make_hstore_columns(): return [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="data", data_type="HSTORE"), + ColumnDefinition(name="data", data_type=PostgresHstoreType()), ] diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hypopg_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hypopg_integration.py index d477159b..7cf7ab52 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hypopg_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hypopg_integration.py @@ -33,6 +33,12 @@ ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) @pytest.fixture @@ -46,13 +52,13 @@ def hypopg_env(postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="email", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="email", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -262,13 +268,13 @@ async def async_hypopg_env(async_postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="email", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="email", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_intarray_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_intarray_integration.py index 8a70b4ff..ccd5a613 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_intarray_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_intarray_integration.py @@ -39,6 +39,13 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, + PostgresArrayType, +) # --- Helper functions --- @@ -49,12 +56,12 @@ def _setup_intarray_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type="INTEGER[]"), + ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), ] create_expr = CreateTableExpression( dialect=dialect, @@ -87,12 +94,12 @@ def _setup_intarray_overlap_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type="INTEGER[]"), + ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), ] create_expr = CreateTableExpression( dialect=dialect, @@ -125,12 +132,12 @@ def _setup_intarray_contained_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type="INTEGER[]"), + ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), ] create_expr = CreateTableExpression( dialect=dialect, @@ -163,12 +170,12 @@ def _setup_intarray_idx_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type="INTEGER[]"), + ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), ] create_expr = CreateTableExpression( dialect=dialect, @@ -201,12 +208,12 @@ def _setup_intarray_gin_table(backend, dialect, table, index_name): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type="INTEGER[]"), + ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), ] create_expr = CreateTableExpression( dialect=dialect, @@ -253,12 +260,12 @@ async def _async_setup_intarray_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type="INTEGER[]"), + ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), ] create_expr = CreateTableExpression( dialect=dialect, @@ -291,12 +298,12 @@ async def _async_setup_intarray_overlap_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type="INTEGER[]"), + ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), ] create_expr = CreateTableExpression( dialect=dialect, @@ -329,12 +336,12 @@ async def _async_setup_intarray_contained_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type="INTEGER[]"), + ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), ] create_expr = CreateTableExpression( dialect=dialect, @@ -367,12 +374,12 @@ async def _async_setup_intarray_idx_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type="INTEGER[]"), + ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), ] create_expr = CreateTableExpression( dialect=dialect, @@ -405,12 +412,12 @@ async def _async_setup_intarray_gin_table(backend, dialect, table, index_name): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type="INTEGER[]"), + ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_ltree_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_ltree_integration.py index d0c56ecc..6a968c93 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_ltree_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_ltree_integration.py @@ -37,6 +37,13 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, + PostgresLtreeType, +) # --- Sync fixture and tests --- @@ -63,12 +70,12 @@ def ltree_env(postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type="LTREE"), + ColumnDefinition(name="path", data_type=PostgresLtreeType()), ], if_not_exists=True, ) @@ -101,13 +108,13 @@ def ltree_env(postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type="LTREE"), - ColumnDefinition(name="label", data_type="TEXT"), + ColumnDefinition(name="path", data_type=PostgresLtreeType()), + ColumnDefinition(name="label", data_type=TextType()), ], if_not_exists=True, ) @@ -140,12 +147,12 @@ def ltree_env(postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type="LTREE"), + ColumnDefinition(name="path", data_type=PostgresLtreeType()), ], if_not_exists=True, ) @@ -172,12 +179,12 @@ def ltree_env(postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type="LTREE"), + ColumnDefinition(name="path", data_type=PostgresLtreeType()), ], if_not_exists=True, ) @@ -220,12 +227,12 @@ def ltree_env(postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type="LTREE"), + ColumnDefinition(name="path", data_type=PostgresLtreeType()), ], if_not_exists=True, ) @@ -569,12 +576,12 @@ async def async_ltree_env(async_postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type="LTREE"), + ColumnDefinition(name="path", data_type=PostgresLtreeType()), ], if_not_exists=True, ) @@ -607,13 +614,13 @@ async def async_ltree_env(async_postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type="LTREE"), - ColumnDefinition(name="label", data_type="TEXT"), + ColumnDefinition(name="path", data_type=PostgresLtreeType()), + ColumnDefinition(name="label", data_type=TextType()), ], if_not_exists=True, ) @@ -646,12 +653,12 @@ async def async_ltree_env(async_postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type="LTREE"), + ColumnDefinition(name="path", data_type=PostgresLtreeType()), ], if_not_exists=True, ) @@ -678,12 +685,12 @@ async def async_ltree_env(async_postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type="LTREE"), + ColumnDefinition(name="path", data_type=PostgresLtreeType()), ], if_not_exists=True, ) @@ -726,12 +733,12 @@ async def async_ltree_env(async_postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type="LTREE"), + ColumnDefinition(name="path", data_type=PostgresLtreeType()), ], if_not_exists=True, ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_orafce_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_orafce_integration.py index 59463877..eb0243b8 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_orafce_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_orafce_integration.py @@ -38,6 +38,14 @@ ensure_extension_installed, async_ensure_extension_installed, ) +from rhosocial.activerecord.backend.expression.types import ( + TextType, + DateType, + DecimalType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) TABLE_NAME = "test_orafce_dates" @@ -55,16 +63,16 @@ def orafce_backend(postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="dt", data_type="DATE"), - ColumnDefinition(name="ref_dt", data_type="DATE"), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="discount", data_type="NUMERIC"), - ColumnDefinition(name="description", data_type="TEXT"), + ColumnDefinition(name="dt", data_type=DateType()), + ColumnDefinition(name="ref_dt", data_type=DateType()), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="discount", data_type=DecimalType()), + ColumnDefinition(name="description", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -316,16 +324,16 @@ async def async_orafce_backend(async_postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="dt", data_type="DATE"), - ColumnDefinition(name="ref_dt", data_type="DATE"), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="discount", data_type="NUMERIC"), - ColumnDefinition(name="description", data_type="TEXT"), + ColumnDefinition(name="dt", data_type=DateType()), + ColumnDefinition(name="ref_dt", data_type=DateType()), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="discount", data_type=DecimalType()), + ColumnDefinition(name="description", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_trgm_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_trgm_integration.py index 29c0be98..b022fb98 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_trgm_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_trgm_integration.py @@ -36,6 +36,12 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) # Table names for sync tests @@ -56,12 +62,12 @@ def _setup_trgm_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -93,12 +99,12 @@ def _setup_trgm_ops_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -134,12 +140,12 @@ def _setup_trgm_idx_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -174,12 +180,12 @@ def _setup_trgm_word_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="description", data_type="TEXT"), + ColumnDefinition(name="description", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -219,12 +225,12 @@ async def _async_setup_trgm_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -256,12 +262,12 @@ async def _async_setup_trgm_ops_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -297,12 +303,12 @@ async def _async_setup_trgm_idx_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -337,12 +343,12 @@ async def _async_setup_trgm_word_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="description", data_type="TEXT"), + ColumnDefinition(name="description", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgcrypto_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgcrypto_integration.py index 5a6e331f..7005a7dd 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgcrypto_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgcrypto_integration.py @@ -36,6 +36,13 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, + PostgresByteaType, +) # Table names for sync tests @@ -52,13 +59,13 @@ def _setup_users_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="username", data_type="TEXT"), - ColumnDefinition(name="password_hash", data_type="TEXT"), + ColumnDefinition(name="username", data_type=TextType()), + ColumnDefinition(name="password_hash", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -93,12 +100,12 @@ def _setup_encrypt_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="secret_data", data_type="BYTEA"), + ColumnDefinition(name="secret_data", data_type=PostgresByteaType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -144,13 +151,13 @@ async def _async_setup_users_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="username", data_type="TEXT"), - ColumnDefinition(name="password_hash", data_type="TEXT"), + ColumnDefinition(name="username", data_type=TextType()), + ColumnDefinition(name="password_hash", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -185,12 +192,12 @@ async def _async_setup_encrypt_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="secret_data", data_type="BYTEA"), + ColumnDefinition(name="secret_data", data_type=PostgresByteaType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgrouting_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgrouting_integration.py index 4452dc02..622b86d7 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgrouting_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgrouting_integration.py @@ -30,6 +30,13 @@ ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, + FloatType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) @pytest.fixture @@ -43,19 +50,19 @@ def pgrouting_env(postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="source", data_type="INT"), - ColumnDefinition(name="target", data_type="INT"), - ColumnDefinition(name="cost", data_type="FLOAT"), - ColumnDefinition(name="reverse_cost", data_type="FLOAT"), - ColumnDefinition(name="x1", data_type="FLOAT"), - ColumnDefinition(name="y1", data_type="FLOAT"), - ColumnDefinition(name="x2", data_type="FLOAT"), - ColumnDefinition(name="y2", data_type="FLOAT"), + ColumnDefinition(name="source", data_type=IntegerType()), + ColumnDefinition(name="target", data_type=IntegerType()), + ColumnDefinition(name="cost", data_type=FloatType()), + ColumnDefinition(name="reverse_cost", data_type=FloatType()), + ColumnDefinition(name="x1", data_type=FloatType()), + ColumnDefinition(name="y1", data_type=FloatType()), + ColumnDefinition(name="x2", data_type=FloatType()), + ColumnDefinition(name="y2", data_type=FloatType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -172,19 +179,19 @@ async def async_pgrouting_env(async_postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="source", data_type="INT"), - ColumnDefinition(name="target", data_type="INT"), - ColumnDefinition(name="cost", data_type="FLOAT"), - ColumnDefinition(name="reverse_cost", data_type="FLOAT"), - ColumnDefinition(name="x1", data_type="FLOAT"), - ColumnDefinition(name="y1", data_type="FLOAT"), - ColumnDefinition(name="x2", data_type="FLOAT"), - ColumnDefinition(name="y2", data_type="FLOAT"), + ColumnDefinition(name="source", data_type=IntegerType()), + ColumnDefinition(name="target", data_type=IntegerType()), + ColumnDefinition(name="cost", data_type=FloatType()), + ColumnDefinition(name="reverse_cost", data_type=FloatType()), + ColumnDefinition(name="x1", data_type=FloatType()), + ColumnDefinition(name="y1", data_type=FloatType()), + ColumnDefinition(name="x2", data_type=FloatType()), + ColumnDefinition(name="y2", data_type=FloatType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgvector_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgvector_integration.py index 8a775349..1541c41b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgvector_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgvector_integration.py @@ -37,6 +37,10 @@ from rhosocial.activerecord.backend.expression.query_parts import OrderByClause from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, + PostgresVectorType, +) # --- Helper: common VECTOR(3) column definitions --- @@ -45,7 +49,7 @@ def _id_column(): return ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], @@ -53,7 +57,7 @@ def _id_column(): def _embedding_column(dim=3): - return ColumnDefinition(name="embedding", data_type=f"VECTOR({dim})") + return ColumnDefinition(name="embedding", data_type=PostgresVectorType(dim)) def _vector_literal(value, vector_type="vector"): diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_integration.py index aefaf055..a0a89473 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_integration.py @@ -33,6 +33,11 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, + PostgresGeometryType, + PostgresGeographyType, +) # --- Helper functions --- @@ -43,12 +48,12 @@ def _setup_geo_points_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type="GEOMETRY(POINT, 4326)"), + ColumnDefinition(name="geom", data_type=PostgresGeometryType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -80,12 +85,12 @@ def _setup_geo_distance_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type="GEOMETRY(POINT, 4326)"), + ColumnDefinition(name="geom", data_type=PostgresGeometryType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -117,12 +122,12 @@ def _setup_geo_contains_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type="GEOMETRY(POLYGON, 4326)"), + ColumnDefinition(name="geom", data_type=PostgresGeometryType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -160,12 +165,12 @@ def _setup_geo_index_table(backend, dialect, table, index_name): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type="GEOMETRY(POINT, 4326)"), + ColumnDefinition(name="geom", data_type=PostgresGeometryType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -207,12 +212,12 @@ def _setup_geog_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geog", data_type="GEOGRAPHY(POINT, 4326)"), + ColumnDefinition(name="geog", data_type=PostgresGeographyType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -254,12 +259,12 @@ async def _async_setup_geo_points_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type="GEOMETRY(POINT, 4326)"), + ColumnDefinition(name="geom", data_type=PostgresGeometryType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -291,12 +296,12 @@ async def _async_setup_geo_distance_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type="GEOMETRY(POINT, 4326)"), + ColumnDefinition(name="geom", data_type=PostgresGeometryType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -328,12 +333,12 @@ async def _async_setup_geo_contains_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type="GEOMETRY(POLYGON, 4326)"), + ColumnDefinition(name="geom", data_type=PostgresGeometryType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -371,12 +376,12 @@ async def _async_setup_geo_index_table(backend, dialect, table, index_name): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type="GEOMETRY(POINT, 4326)"), + ColumnDefinition(name="geom", data_type=PostgresGeometryType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -418,12 +423,12 @@ async def _async_setup_geog_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geog", data_type="GEOGRAPHY(POINT, 4326)"), + ColumnDefinition(name="geog", data_type=PostgresGeographyType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_raster_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_raster_integration.py index 0f1fef5d..e5b462e2 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_raster_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_raster_integration.py @@ -36,6 +36,13 @@ ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, + PostgresRasterType, +) TABLE_NAME = "test_raster_data" @@ -83,13 +90,13 @@ def raster_env(postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="rast", data_type="RASTER"), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="rast", data_type=PostgresRasterType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -202,13 +209,13 @@ async def async_raster_env(async_postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="rast", data_type="RASTER"), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="rast", data_type=PostgresRasterType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_tablefunc_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_tablefunc_integration.py index b5e58b99..7b1fa9df 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_tablefunc_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_tablefunc_integration.py @@ -36,6 +36,12 @@ ) from rhosocial.activerecord.backend.expression.statements import ValuesSource from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.types import ( + TextType, IntegerType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) from rhosocial.activerecord.backend.impl.postgres.functions.tablefunc import ( normal_rand, crosstab, @@ -56,14 +62,14 @@ def tablefunc_env(postgres_backend_single): sales_columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="month", data_type="TEXT"), - ColumnDefinition(name="category", data_type="TEXT"), - ColumnDefinition(name="amount", data_type="INT"), + ColumnDefinition(name="month", data_type=TextType()), + ColumnDefinition(name="category", data_type=TextType()), + ColumnDefinition(name="amount", data_type=IntegerType()), ] create_sales = CreateTableExpression( dialect=dialect, @@ -96,13 +102,13 @@ def tablefunc_env(postgres_backend_single): tree_columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="node_name", data_type="TEXT"), - ColumnDefinition(name="parent_id", data_type="INT"), + ColumnDefinition(name="node_name", data_type=TextType()), + ColumnDefinition(name="parent_id", data_type=IntegerType()), ] create_tree = CreateTableExpression( dialect=dialect, @@ -334,14 +340,14 @@ async def async_tablefunc_env(async_postgres_backend_single): sales_columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="month", data_type="TEXT"), - ColumnDefinition(name="category", data_type="TEXT"), - ColumnDefinition(name="amount", data_type="INT"), + ColumnDefinition(name="month", data_type=TextType()), + ColumnDefinition(name="category", data_type=TextType()), + ColumnDefinition(name="amount", data_type=IntegerType()), ] create_sales = CreateTableExpression( dialect=dialect, @@ -374,13 +380,13 @@ async def async_tablefunc_env(async_postgres_backend_single): tree_columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="node_name", data_type="TEXT"), - ColumnDefinition(name="parent_id", data_type="INT"), + ColumnDefinition(name="node_name", data_type=TextType()), + ColumnDefinition(name="parent_id", data_type=IntegerType()), ] create_tree = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_uuid_ossp_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_uuid_ossp_integration.py index 9f409c00..388bee98 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_uuid_ossp_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_uuid_ossp_integration.py @@ -33,6 +33,12 @@ from rhosocial.activerecord.backend.expression.statements import ValuesSource from rhosocial.activerecord.backend.expression.core import Literal, FunctionCall from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.types import ( + TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresUUIDType, +) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -71,7 +77,7 @@ def uuid_ossp_env(postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="UUID", + data_type=PostgresUUIDType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ColumnConstraint( @@ -80,7 +86,7 @@ def uuid_ossp_env(postgres_backend_single): ), ], ), - ColumnDefinition(name="name", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), ], if_not_exists=True, ) @@ -107,12 +113,12 @@ def uuid_ossp_env(postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="UUID", + data_type=PostgresUUIDType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="label", data_type="TEXT"), + ColumnDefinition(name="label", data_type=TextType()), ], if_not_exists=True, ) @@ -276,7 +282,7 @@ async def async_uuid_ossp_env(async_postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="UUID", + data_type=PostgresUUIDType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ColumnConstraint( @@ -285,7 +291,7 @@ async def async_uuid_ossp_env(async_postgres_backend_single): ), ], ), - ColumnDefinition(name="name", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), ], if_not_exists=True, ) @@ -312,12 +318,12 @@ async def async_uuid_ossp_env(async_postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="UUID", + data_type=PostgresUUIDType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="label", data_type="TEXT"), + ColumnDefinition(name="label", data_type=TextType()), ], if_not_exists=True, ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_core_type_mappings.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_core_type_mappings.py new file mode 100644 index 00000000..2c8914ed --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_core_type_mappings.py @@ -0,0 +1,212 @@ +"""Tests for PostgreSQL core type → SQL string mappings.""" + +import pytest + +from rhosocial.activerecord.backend.dialect.mixins.ddl_type import DDLTypeMixin + + +@pytest.fixture +def dialect(): + # Import directly to avoid triggering backend init which requires psycopg + from rhosocial.activerecord.backend.impl.postgres.mixins.types import ( + PostgresTypeFormatSupportMixin, + ) + from rhosocial.activerecord.backend.dialect.base import SQLDialectBase + + # Build a minimal dialect class that only has the type formatting + class TestPGDialect(PostgresTypeFormatSupportMixin, SQLDialectBase): + pass + + d = TestPGDialect() + d.version = (16, 0, 0) + return d + + +class TestCoreTypeMappings: + """Verify core DataType → SQL rendering via PostgreSQL dialect.""" + + # The five critical mappings + def test_boolean_to_boolean(self, dialect): + from rhosocial.activerecord.backend.expression.types import BooleanType + assert BooleanType().to_sql(dialect) == ("BOOLEAN", ()) + + def test_tinyint_to_smallint(self, dialect): + from rhosocial.activerecord.backend.expression.types import TinyIntType + assert TinyIntType().to_sql(dialect) == ("SMALLINT", ()) + + def test_blob_to_bytea(self, dialect): + from rhosocial.activerecord.backend.expression.types import BlobType + assert BlobType().to_sql(dialect) == ("BYTEA", ()) + + def test_datetime_to_timestamp(self, dialect): + from rhosocial.activerecord.backend.expression.types import DateTimeType + assert DateTimeType().to_sql(dialect) == ("TIMESTAMP", ()) + + def test_int_to_integer(self, dialect): + from rhosocial.activerecord.backend.expression.types import IntType + assert IntType().to_sql(dialect) == ("INTEGER", ()) + + # Standard integer family + def test_smallint(self, dialect): + from rhosocial.activerecord.backend.expression.types import SmallIntType + assert SmallIntType().to_sql(dialect) == ("SMALLINT", ()) + + def test_integer(self, dialect): + from rhosocial.activerecord.backend.expression.types import IntegerType + assert IntegerType().to_sql(dialect) == ("INTEGER", ()) + + def test_bigint(self, dialect): + from rhosocial.activerecord.backend.expression.types import BigIntType + assert BigIntType().to_sql(dialect) == ("BIGINT", ()) + + # Numeric family + def test_real(self, dialect): + from rhosocial.activerecord.backend.expression.types import RealType + assert RealType().to_sql(dialect) == ("REAL", ()) + + def test_double(self, dialect): + from rhosocial.activerecord.backend.expression.types import DoubleType + assert DoubleType().to_sql(dialect) == ("DOUBLE PRECISION", ()) + + def test_numeric(self, dialect): + from rhosocial.activerecord.backend.expression.types import DecimalType + assert DecimalType(10, 2).to_sql(dialect) == ("DECIMAL(10,2)", ()) + assert DecimalType().to_sql(dialect) == ("DECIMAL", ()) + + # String family + def test_varchar(self, dialect): + from rhosocial.activerecord.backend.expression.types import VarCharType + assert VarCharType(255).to_sql(dialect) == ("VARCHAR(255)", ()) + assert VarCharType().to_sql(dialect) == ("VARCHAR", ()) + + def test_text(self, dialect): + from rhosocial.activerecord.backend.expression.types import TextType + assert TextType().to_sql(dialect) == ("TEXT", ()) + + # JSON family + def test_json(self, dialect): + from rhosocial.activerecord.backend.expression.types import JsonType + assert JsonType().to_sql(dialect) == ("JSON", ()) + + def test_jsonb(self, dialect): + from rhosocial.activerecord.backend.expression.types import JsonBType + assert JsonBType().to_sql(dialect) == ("JSONB", ()) + + +class TestArrayType: + """PostgreSQL array type (T[]) rendering and parsing.""" + + def test_array_1d_rendering(self, dialect): + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + arr = PostgresArrayType(IntegerType()) + assert arr.to_sql(dialect) == ("INTEGER[]", ()) + + def test_array_2d_rendering(self, dialect): + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + arr = PostgresArrayType(IntegerType(), dimensions=2) + assert arr.to_sql(dialect) == ("INTEGER[][]", ()) + + def test_array_varchar_rendering(self, dialect): + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + ) + from rhosocial.activerecord.backend.expression.types import VarCharType + arr = PostgresArrayType(VarCharType(255)) + assert arr.to_sql(dialect) == ("VARCHAR(255)[]", ()) + + def test_array_boolean_rendering(self, dialect): + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + ) + from rhosocial.activerecord.backend.expression.types import BooleanType + arr = PostgresArrayType(BooleanType()) + assert arr.to_sql(dialect) == ("BOOLEAN[]", ()) + + def test_array_equality(self, dialect): + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + ) + from rhosocial.activerecord.backend.expression.types import ( + ArrayType, IntegerType, VarCharType, + ) + a1 = PostgresArrayType(IntegerType(), 2) + a2 = PostgresArrayType(IntegerType(), 2) + a3 = PostgresArrayType(VarCharType(255), 2) + assert a1 == a2 + assert a1 != a3 + assert hash(a1) == hash(a2) + # PostgresArrayType.is_equivalent ignores dimensions + a4 = PostgresArrayType(IntegerType(), 1) + assert a1.is_equivalent(a4) + + def test_parse_array_bracket_suffix(self, dialect): + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + result = dialect.parse_type("INTEGER[]") + assert isinstance(result, PostgresArrayType) + assert result.dimensions == 1 + assert isinstance(result.element_type, IntegerType) + + def test_parse_array_2d_bracket_suffix(self, dialect): + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + result = dialect.parse_type("INTEGER[][]") + assert isinstance(result, PostgresArrayType) + # PG normalises all array declarations to 1-D internally + assert result.dimensions == 1 + assert isinstance(result.element_type, IntegerType) + + def test_parse_array_keyword(self, dialect): + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + result = dialect.parse_type("INTEGER ARRAY") + assert isinstance(result, PostgresArrayType) + assert result.dimensions == 1 + assert isinstance(result.element_type, IntegerType) + + def test_parse_array_varchar(self, dialect): + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + ) + from rhosocial.activerecord.backend.expression.types import VarCharType + result = dialect.parse_type("VARCHAR(255)[]") + assert isinstance(result, PostgresArrayType) + assert result.dimensions == 1 + assert isinstance(result.element_type, VarCharType) + assert result.element_type.length == 255 + + def test_parse_non_array_passthrough(self, dialect): + from rhosocial.activerecord.backend.expression.types import IntegerType + result = dialect.parse_type("INTEGER") + assert isinstance(result, IntegerType) + + def test_is_element_type_equivalent(self, dialect): + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + ) + from rhosocial.activerecord.backend.expression.types import ( + IntegerType, SmallIntType, + ) + a1 = PostgresArrayType(IntegerType(), 2) + a2 = PostgresArrayType(IntegerType(), 1) # different dimension + a3 = PostgresArrayType(SmallIntType()) # different element type + # Same element, different dimension → equivalent + assert a1.is_element_type_equivalent(a2) + # Different element → not equivalent + assert not a1.is_element_type_equivalent(a3) + # Plain IntegerType (not array) as other → matches element + assert a1.is_element_type_equivalent(IntegerType()) + # Plain SmallIntType as other → doesn't match Integer element + assert not a1.is_element_type_equivalent(SmallIntType()) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py index 28c02950..e304adc8 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py @@ -32,6 +32,12 @@ PostgresVacuumExpression, # noqa: F401 PostgresAnalyzeExpression, # noqa: F401 ) +from rhosocial.activerecord.backend.expression.types import ( + BigIntType, TextType, TimestampType, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, ColumnConstraintType, +) from rhosocial.activerecord.backend.impl.postgres.mixins.dml.extended_statistics import ( PostgresExtendedStatisticsMixin, # noqa: F401 ) @@ -242,8 +248,8 @@ def test_create_range_partitioned_parent_table(self, dialect): dialect=dialect, table="events", columns=[ - ColumnDefinition("id", "BIGINT"), - ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), + ColumnDefinition("id", BigIntType()), + ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), ], partition=PartitionClause( dialect=dialect, @@ -263,8 +269,8 @@ def test_create_list_partitioned_parent_table(self, dialect): dialect=dialect, table="events", columns=[ - ColumnDefinition("id", "BIGINT"), - ColumnDefinition("status", "TEXT NOT NULL"), + ColumnDefinition("id", BigIntType()), + ColumnDefinition("status", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), ], partition=PartitionClause( dialect=dialect, @@ -283,7 +289,7 @@ def test_create_hash_partitioned_parent_table_pg10(self): expr = CreateTableExpression( dialect=dialect, table="events", - columns=[ColumnDefinition("tenant_id", "BIGINT NOT NULL")], + columns=[ColumnDefinition("tenant_id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)])], partition=PartitionClause( dialect=dialect, method=PartitionStrategy.HASH, @@ -300,7 +306,7 @@ def test_create_hash_partitioned_parent_table_pg11(self): expr = CreateTableExpression( dialect=dialect, table="events", - columns=[ColumnDefinition("tenant_id", "BIGINT NOT NULL")], + columns=[ColumnDefinition("tenant_id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)])], partition=PartitionClause( dialect=dialect, method=PartitionStrategy.HASH, @@ -318,7 +324,7 @@ def test_create_partitioned_parent_table_pg9(self): expr = CreateTableExpression( dialect=dialect, table="events", - columns=[ColumnDefinition("created_at", "TIMESTAMP NOT NULL")], + columns=[ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)])], partition=PartitionClause( dialect=dialect, method=PartitionStrategy.RANGE, @@ -344,9 +350,9 @@ def test_multi_column_range_partitioned_parent_table(self, dialect): dialect=dialect, table="tenanted_events", columns=[ - ColumnDefinition("tenant_id", "BIGINT NOT NULL"), - ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), - ColumnDefinition("payload", "TEXT"), + ColumnDefinition("tenant_id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("payload", TextType()), ], partition=PartitionClause( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_dialect_security_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_dialect_security_integration.py index 6f03f418..f8fa1597 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_dialect_security_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_dialect_security_integration.py @@ -62,20 +62,16 @@ def test_valid_data_type_works(self, postgres_backend, test_table_for_data_type) """) # noqa: F541 assert result is not None - def test_malicious_data_type_rejected_at_dialect_level(self, postgres_backend): - """Test that malicious data type is rejected at dialect level before DB execution.""" + def test_malicious_data_type_rejected_at_construction(self, postgres_backend): + """Test that malicious data type string is rejected at ColumnDefinition construction.""" from rhosocial.activerecord.backend.expression.statements import ColumnDefinition - dialect = postgres_backend.dialect - - # This should raise ValueError before reaching the database - col_def = ColumnDefinition( - name="test_col", - data_type="VARCHAR(255); DROP TABLE users--", - ) - - with pytest.raises(ValueError, match="Invalid data type"): - dialect.format_column_definition(col_def) + # ColumnDefinition now rejects string data_type values at construction time + with pytest.raises(TypeError, match="data_type must be a DataType instance"): + ColumnDefinition( + name="test_col", + data_type="VARCHAR(255); DROP TABLE users--", + ) class TestPostgresExcludeConstraintSecurityIntegration: diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py index be4e111c..49f5e03c 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py @@ -26,6 +26,9 @@ from rhosocial.activerecord.backend.expression.core import Column from rhosocial.activerecord.backend.expression.query_parts import JoinExpression, JoinType from rhosocial.activerecord.backend.expression.literals import Identifier +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, TextType, +) GRAPH_NAME = "social_graph" @@ -42,48 +45,48 @@ def social_data(postgres_backend): backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) people_cols = [ - ColumnDefinition("id", "INTEGER", + ColumnDefinition("id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", "TEXT"), - ColumnDefinition("email", "TEXT"), - ColumnDefinition("city", "TEXT"), + ColumnDefinition("name", TextType()), + ColumnDefinition("email", TextType()), + ColumnDefinition("city", TextType()), ] backend.execute(*CreateTableExpression(dialect, "people", people_cols).to_sql()) follows_cols = [ - ColumnDefinition("id", "INTEGER", + ColumnDefinition("id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("follower_id", "INTEGER", + ColumnDefinition("follower_id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("people", ["id"]))]), - ColumnDefinition("followed_id", "INTEGER", + ColumnDefinition("followed_id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("people", ["id"]))]), - ColumnDefinition("since", "TEXT"), + ColumnDefinition("since", TextType()), ] backend.execute(*CreateTableExpression(dialect, "follows", follows_cols).to_sql()) posts_cols = [ - ColumnDefinition("id", "INTEGER", + ColumnDefinition("id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("author_id", "INTEGER", + ColumnDefinition("author_id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("people", ["id"]))]), - ColumnDefinition("content", "TEXT"), - ColumnDefinition("created_at", "TEXT"), + ColumnDefinition("content", TextType()), + ColumnDefinition("created_at", TextType()), ] backend.execute(*CreateTableExpression(dialect, "posts", posts_cols).to_sql()) likes_cols = [ - ColumnDefinition("id", "INTEGER", + ColumnDefinition("id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("user_id", "INTEGER", + ColumnDefinition("user_id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("people", ["id"]))]), - ColumnDefinition("post_id", "INTEGER", + ColumnDefinition("post_id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("posts", ["id"]))]), - ColumnDefinition("created_at", "TEXT"), + ColumnDefinition("created_at", TextType()), ] backend.execute(*CreateTableExpression(dialect, "likes", likes_cols).to_sql()) @@ -324,24 +327,24 @@ async def async_social_data(self, async_postgres_backend): await backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) people_cols = [ - ColumnDefinition("id", "INTEGER", + ColumnDefinition("id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", "TEXT"), - ColumnDefinition("email", "TEXT"), - ColumnDefinition("city", "TEXT"), + ColumnDefinition("name", TextType()), + ColumnDefinition("email", TextType()), + ColumnDefinition("city", TextType()), ] await backend.execute(*CreateTableExpression(dialect, "people", people_cols).to_sql()) follows_cols = [ - ColumnDefinition("id", "INTEGER", + ColumnDefinition("id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("follower_id", "INTEGER", + ColumnDefinition("follower_id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("people", ["id"]))]), - ColumnDefinition("followed_id", "INTEGER", + ColumnDefinition("followed_id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("people", ["id"]))]), - ColumnDefinition("since", "TEXT"), + ColumnDefinition("since", TextType()), ] await backend.execute(*CreateTableExpression(dialect, "follows", follows_cols).to_sql()) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_create_table_like.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_create_table_like.py index 2b9afaff..f8485637 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_create_table_like.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_create_table_like.py @@ -8,6 +8,9 @@ import pytest # noqa: F401 from rhosocial.activerecord.backend.expression import CreateTableExpression, ColumnDefinition from rhosocial.activerecord.backend.expression.statements import ColumnConstraint, ColumnConstraintType +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, VarCharType, +) from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect @@ -204,10 +207,10 @@ def test_like_ignores_columns(self): """Test that LIKE syntax ignores columns parameter.""" dialect = PostgresDialect() columns = [ - ColumnDefinition("id", "INTEGER", constraints=[ + ColumnDefinition("id", IntegerType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY) ]), - ColumnDefinition("name", "VARCHAR(255)") + ColumnDefinition("name", VarCharType(255)) ] create_expr = CreateTableExpression( dialect=dialect, @@ -258,10 +261,10 @@ def test_fallback_to_base_when_no_like(self): """Test that base implementation is used when LIKE is not specified.""" dialect = PostgresDialect() columns = [ - ColumnDefinition("id", "INTEGER", constraints=[ + ColumnDefinition("id", IntegerType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY) ]), - ColumnDefinition("name", "VARCHAR(255)", constraints=[ + ColumnDefinition("name", VarCharType(255), constraints=[ ColumnConstraint(ColumnConstraintType.NOT_NULL) ]) ] diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_advanced_operations.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_advanced_operations.py index 6f0a7343..fd4677d8 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_advanced_operations.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_advanced_operations.py @@ -32,6 +32,12 @@ PostgresDetachPartitionExpression, PostgresPartitionMetadataExpression, ) +from rhosocial.activerecord.backend.expression.types import ( + BigIntType, TextType, TimestampType, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, ColumnConstraintType, +) LIST_PARENT = "ar_partition_adv_list_events" @@ -59,9 +65,9 @@ def _create_list_parent_expression(dialect): dialect=dialect, table=LIST_PARENT, columns=[ - ColumnDefinition("id", "BIGINT NOT NULL"), - ColumnDefinition("status", "TEXT NOT NULL"), - ColumnDefinition("payload", "TEXT NOT NULL"), + ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("status", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("payload", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), ], partition=PartitionClause( dialect=dialect, @@ -87,9 +93,9 @@ def _create_hash_parent_expression(dialect): dialect=dialect, table=HASH_PARENT, columns=[ - ColumnDefinition("id", "BIGINT NOT NULL"), - ColumnDefinition("bucket", "BIGINT NOT NULL"), - ColumnDefinition("payload", "TEXT NOT NULL"), + ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("bucket", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("payload", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), ], partition=PartitionClause( dialect=dialect, @@ -115,9 +121,9 @@ def _create_range_parent_expression(dialect): dialect=dialect, table=RANGE_PARENT, columns=[ - ColumnDefinition("id", "BIGINT NOT NULL"), - ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), - ColumnDefinition("payload", "TEXT NOT NULL"), + ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("payload", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), ], partition=PartitionClause( dialect=dialect, @@ -318,9 +324,9 @@ def test_default_attach_and_detach_workflow(self, postgres_backend, postgres_ran dialect=dialect, table=default_partition_name, columns=[ - ColumnDefinition("id", "BIGINT NOT NULL"), - ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), - ColumnDefinition("payload", "TEXT NOT NULL"), + ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("payload", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), ], ) postgres_backend.execute(*create_default.to_sql()) @@ -378,8 +384,8 @@ def test_metadata_include_partitions_false_and_schema_filter(self, postgres_back dialect=dialect, table=TableExpression(dialect, SCHEMA_PARENT, schema_name=schema), columns=[ - ColumnDefinition("id", "BIGINT NOT NULL"), - ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), + ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), ], partition=PartitionClause( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py index 5ded526f..fed659ae 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py @@ -51,6 +51,12 @@ ensure_extension_installed, async_ensure_extension_installed, ) +from rhosocial.activerecord.backend.expression.types import ( + BigIntType, TextType, TimestampType, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, ColumnConstraintType, +) PARTITION_TABLES = ( @@ -76,9 +82,9 @@ def _create_partitioned_parent_sql(dialect, table_name: str): dialect=dialect, table=table_name, columns=[ - ColumnDefinition("id", "BIGINT NOT NULL"), - ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), - ColumnDefinition("payload", "TEXT"), + ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("payload", TextType()), ], partition=PartitionClause( dialect=dialect, @@ -200,10 +206,10 @@ def _create_production_parent_sql(dialect): dialect=dialect, table=PRODUCTION_PARTITION_TABLE, columns=[ - ColumnDefinition("id", "BIGINT NOT NULL"), - ColumnDefinition("created_at", "TIMESTAMP(6) NOT NULL"), - ColumnDefinition("tenant_id", "BIGINT NOT NULL"), - ColumnDefinition("payload", "TEXT NOT NULL"), + ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("created_at", TimestampType(6), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("tenant_id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("payload", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), ], table_constraints=[ TableConstraint( diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py index fe763442..dca80715 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py @@ -17,6 +17,7 @@ TableConstraint, TableConstraintType, ) +from rhosocial.activerecord.backend.expression.types import VarCharType from typing import Tuple, Any # noqa: F401 @@ -41,25 +42,23 @@ def test_postgres_validate_data_type(dialect): def test_postgres_format_column_definition_data_type_validation(dialect): - """Test column definition validates data_type.""" + """Test column definition formats data_type correctly.""" col_def = ColumnDefinition( name="test_col", - data_type="VARCHAR(255)", + data_type=VarCharType(255), ) sql, params = dialect.format_column_definition(col_def) assert "VARCHAR(255)" in sql -def test_postgres_format_column_definition_data_type_rejects_injection(dialect): - """Test that malicious data_type is rejected.""" - col_def = ColumnDefinition( - name="test_col", - data_type="VARCHAR(255); DROP TABLE users--", - ) - - with pytest.raises(ValueError, match="Invalid data type"): - dialect.format_column_definition(col_def) +def test_postgres_column_definition_rejects_string_data_type(dialect): + """Test that ColumnDefinition rejects string data_type at construction.""" + with pytest.raises(TypeError, match="data_type must be a DataType instance"): + ColumnDefinition( + name="test_col", + data_type="VARCHAR(255); DROP TABLE users--", + ) def test_postgres_format_default_constraint_string_escaping(dialect):