From: Mike Bayer Date: Tue, 21 Jul 2026 20:43:28 +0000 (-0400) Subject: Reflect schema-qualified PostgreSQL collations for columns and DOMAIN X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=b435816d5463eeb30e869a0d70a385eb2e4f5930;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git Reflect schema-qualified PostgreSQL collations for columns and DOMAIN The PostgreSQL dialect now reflects the schema of a schema-qualified column or DOMAIN collation, populating the collation_schema parameter added to String and postgresql.DOMAIN so that reflected DDL round-trips exactly. The schema is omitted from the reflected value when the collation is visible on the current search_path without qualification. Fixes: #6511 Change-Id: Id9c7016659fb0ce5b83d6be5ad172811cea7dd14 --- diff --git a/doc/build/changelog/migration_21.rst b/doc/build/changelog/migration_21.rst index e8281be0f2..41b37d0c9a 100644 --- a/doc/build/changelog/migration_21.rst +++ b/doc/build/changelog/migration_21.rst @@ -1945,12 +1945,21 @@ hand-quoting code paths that existed previously. As a side effect, simple lowercase collation names such as ``"utf8"`` are no longer unconditionally quoted in generated DDL, as this quoting was never necessary for such names. +Additionally, reflection of columns and :class:`_postgresql.DOMAIN` objects +now populates :paramref:`.String.collation_schema` / +:paramref:`_postgresql.DOMAIN.collation_schema` when the underlying +collation is schema-qualified, so that reflected DDL round-trips exactly. +The schema is omitted from the reflected value when the collation is +visible on the current ``search_path`` without qualification. + .. seealso:: :ref:`postgresql_collation` :ticket:`9693` +:ticket:`6511` + Microsoft SQL Server ==================== diff --git a/doc/build/changelog/unreleased_21/6511.rst b/doc/build/changelog/unreleased_21/6511.rst new file mode 100644 index 0000000000..621bd76a70 --- /dev/null +++ b/doc/build/changelog/unreleased_21/6511.rst @@ -0,0 +1,15 @@ +.. change:: + :tags: usecase, postgresql + :tickets: 6511 + + The PostgreSQL dialect now reflects the schema of a schema-qualified + column or :class:`_postgresql.DOMAIN` collation, populating the new + :paramref:`.String.collation_schema` / + :paramref:`_postgresql.DOMAIN.collation_schema` parameters so that + reflected DDL round-trips exactly. The schema is omitted from the + reflected value when the collation is visible on the current + ``search_path`` without qualification. + + .. seealso:: + + :ref:`postgresql_collation` diff --git a/lib/sqlalchemy/dialects/postgresql/base.py b/lib/sqlalchemy/dialects/postgresql/base.py index cf1ed28374..12e44ac65e 100644 --- a/lib/sqlalchemy/dialects/postgresql/base.py +++ b/lib/sqlalchemy/dialects/postgresql/base.py @@ -3318,6 +3318,9 @@ class ReflectedDomain(ReflectedNamedType): """ collation: Optional[str] """The collation for the domain.""" + collation_schema: Optional[str] + """The schema of the collation for the domain, if the collation is not + visible on the current search_path.""" class ReflectedEnum(ReflectedNamedType): @@ -4305,7 +4308,9 @@ class PGDialect(default._BackendsMultiReflection, default.DefaultDialect): else_=sql.null(), ).label("default") - # get the name of the collate when it's different from the default one + # get the name and schema of the collate when it's different from + # the default one; the schema is only included when the collation + # is not visible on the current search_path collate = sql.case( ( sql.and_( @@ -4319,7 +4324,29 @@ class PGDialect(default._BackendsMultiReflection, default.DefaultDialect): .scalar_subquery() != pg_catalog.pg_attribute.c.attcollation, ), - select(pg_catalog.pg_collation.c.collname) + select( + sql.func.json_build_object( + "name", + pg_catalog.pg_collation.c.collname, + "schema", + sql.case( + ( + pg_catalog.pg_collation_is_visible( + pg_catalog.pg_collation.c.oid + ), + sql.null(), + ), + else_=pg_catalog.pg_namespace.c.nspname, + ), + type_=sqltypes.JSON(), + ) + ) + .select_from(pg_catalog.pg_collation) + .join( + pg_catalog.pg_namespace, + pg_catalog.pg_namespace.c.oid + == pg_catalog.pg_collation.c.collnamespace, + ) .where( pg_catalog.pg_collation.c.oid == pg_catalog.pg_attribute.c.attcollation @@ -4402,6 +4429,7 @@ class PGDialect(default._BackendsMultiReflection, default.DefaultDialect): named_type_loader: _NamedTypeLoader, type_description: str, collation: Optional[str], + collation_schema: Optional[str] = None, ) -> sqltypes.TypeEngine[Any]: """ Attempts to reconstruct a column type defined in ischema_names based @@ -4508,10 +4536,12 @@ class PGDialect(default._BackendsMultiReflection, default.DefaultDialect): named_type_loader, type_description="DOMAIN '%s'" % domain["name"], collation=domain["collation"], + collation_schema=domain["collation_schema"], ) args = (domain["name"], data_type) kwargs["collation"] = domain["collation"] + kwargs["collation_schema"] = domain["collation_schema"] kwargs["default"] = domain["default"] kwargs["not_null"] = not domain["nullable"] kwargs["create_type"] = False @@ -4542,6 +4572,8 @@ class PGDialect(default._BackendsMultiReflection, default.DefaultDialect): if collation is not None: kwargs["collation"] = collation + if collation_schema is not None: + kwargs["collation_schema"] = collation_schema data_type = schema_type(*args, **kwargs) if array_dim >= 1: @@ -4561,13 +4593,19 @@ class PGDialect(default._BackendsMultiReflection, default.DefaultDialect): continue table_cols = columns[(schema, row_dict["table_name"])] - collation = row_dict["collation"] + collation_info = row_dict["collation"] + if collation_info is not None: + collation = collation_info["name"] + collation_schema = collation_info["schema"] + else: + collation = collation_schema = None coltype = self._reflect_type( row_dict["format_type"], named_type_loader, type_description="column '%s'" % row_dict["name"], collation=collation, + collation_schema=collation_schema, ) default = row_dict["default"] @@ -5640,6 +5678,10 @@ class PGDialect(default._BackendsMultiReflection, default.DefaultDialect): .subquery("domain_constraints") ) + collation_namespace = pg_catalog.pg_namespace.alias( + "collation_namespace" + ) + query = ( select( pg_catalog.pg_type.c.typname.label("name"), @@ -5656,6 +5698,19 @@ class PGDialect(default._BackendsMultiReflection, default.DefaultDialect): con_sq.c.condefs, con_sq.c.connames, pg_catalog.pg_collation.c.collname, + sql.case( + ( + pg_catalog.pg_collation.c.oid.is_(None), + sql.null(), + ), + ( + pg_catalog.pg_collation_is_visible( + pg_catalog.pg_collation.c.oid + ), + sql.null(), + ), + else_=collation_namespace.c.nspname, + ).label("collation_schema"), ) .join( pg_catalog.pg_namespace, @@ -5667,6 +5722,11 @@ class PGDialect(default._BackendsMultiReflection, default.DefaultDialect): pg_catalog.pg_type.c.typcollation == pg_catalog.pg_collation.c.oid, ) + .outerjoin( + collation_namespace, + collation_namespace.c.oid + == pg_catalog.pg_collation.c.collnamespace, + ) .outerjoin( con_sq, pg_catalog.pg_type.c.oid == con_sq.c.contypid, @@ -5710,6 +5770,7 @@ class PGDialect(default._BackendsMultiReflection, default.DefaultDialect): "default": domain["default"], "constraints": constraints, "collation": domain["collname"], + "collation_schema": domain["collation_schema"], } domains.append(domain_rec) diff --git a/lib/sqlalchemy/dialects/postgresql/pg_catalog.py b/lib/sqlalchemy/dialects/postgresql/pg_catalog.py index d8f9987ec2..6416f89d3a 100644 --- a/lib/sqlalchemy/dialects/postgresql/pg_catalog.py +++ b/lib/sqlalchemy/dialects/postgresql/pg_catalog.py @@ -74,6 +74,7 @@ _pg_cat = func.pg_catalog quote_ident = _pg_cat.quote_ident pg_table_is_visible = _pg_cat.pg_table_is_visible pg_type_is_visible = _pg_cat.pg_type_is_visible +pg_collation_is_visible = _pg_cat.pg_collation_is_visible pg_get_viewdef = _pg_cat.pg_get_viewdef pg_get_serial_sequence = _pg_cat.pg_get_serial_sequence format_type = _pg_cat.format_type diff --git a/test/dialect/postgresql/test_reflection.py b/test/dialect/postgresql/test_reflection.py index f3af3e1f91..aaaf818d11 100644 --- a/test/dialect/postgresql/test_reflection.py +++ b/test/dialect/postgresql/test_reflection.py @@ -515,6 +515,28 @@ class DomainReflectionTest(fixtures.TestBase, AssertsExecutionResults): yield connection.exec_driver_sql('DROP DOMAIN "SomeSchema"."Quoted.Domain"') + @testing.fixture + def some_collation(self, connection, some_schema): + connection.exec_driver_sql( + 'CREATE COLLATION "SomeSchema"."SomeCollation" ' + "(LOCALE = 'C.utf8')" + ) + yield + connection.exec_driver_sql( + 'DROP COLLATION "SomeSchema"."SomeCollation"' + ) + + @testing.fixture + def schema_collation_domain(self, connection, some_collation): + connection.exec_driver_sql( + 'CREATE DOMAIN "SomeSchema".domain_with_collation AS TEXT ' + 'COLLATE "SomeSchema"."SomeCollation"' + ) + yield + connection.exec_driver_sql( + 'DROP DOMAIN "SomeSchema".domain_with_collation' + ) + @testing.fixture def int_domain(self, connection): connection.exec_driver_sql( @@ -707,6 +729,7 @@ class DomainReflectionTest(fixtures.TestBase, AssertsExecutionResults): int_domain, testdomain, testdomain_schema, + schema_collation_domain, ): return { "public": [ @@ -719,6 +742,7 @@ class DomainReflectionTest(fixtures.TestBase, AssertsExecutionResults): "default": None, "constraints": [], "collation": None, + "collation_schema": None, }, { "visible": True, @@ -729,6 +753,7 @@ class DomainReflectionTest(fixtures.TestBase, AssertsExecutionResults): "default": None, "constraints": [], "collation": None, + "collation_schema": None, }, { "visible": True, @@ -739,6 +764,7 @@ class DomainReflectionTest(fixtures.TestBase, AssertsExecutionResults): "default": None, "constraints": [], "collation": None, + "collation_schema": None, }, { "visible": True, @@ -749,6 +775,7 @@ class DomainReflectionTest(fixtures.TestBase, AssertsExecutionResults): "default": None, "constraints": [], "collation": None, + "collation_schema": None, }, { "visible": True, @@ -764,6 +791,7 @@ class DomainReflectionTest(fixtures.TestBase, AssertsExecutionResults): {"check": "VALUE <> 22", "name": "my_int_check"}, ], "collation": None, + "collation_schema": None, }, { "visible": True, @@ -774,6 +802,7 @@ class DomainReflectionTest(fixtures.TestBase, AssertsExecutionResults): "default": None, "constraints": [], "collation": "default", + "collation_schema": None, }, { "visible": True, @@ -791,6 +820,7 @@ class DomainReflectionTest(fixtures.TestBase, AssertsExecutionResults): } ], "collation": "C", + "collation_schema": None, }, { "visible": True, @@ -801,6 +831,7 @@ class DomainReflectionTest(fixtures.TestBase, AssertsExecutionResults): "default": "42", "constraints": [], "collation": None, + "collation_schema": None, }, ], "test_schema": [ @@ -813,6 +844,7 @@ class DomainReflectionTest(fixtures.TestBase, AssertsExecutionResults): "default": "0", "constraints": [], "collation": None, + "collation_schema": None, } ], "SomeSchema": [ @@ -825,7 +857,19 @@ class DomainReflectionTest(fixtures.TestBase, AssertsExecutionResults): "default": "0", "constraints": [], "collation": None, - } + "collation_schema": None, + }, + { + "visible": False, + "name": "domain_with_collation", + "schema": "SomeSchema", + "nullable": True, + "type": "text", + "default": None, + "constraints": [], + "collation": "SomeCollation", + "collation_schema": "SomeSchema", + }, ], } @@ -1279,6 +1323,57 @@ class ReflectionTest( ) eq_(fkey_set_default.ondelete, "SET DEFAULT (fk_id_del_set_default)") + def test_column_collation_reflection_with_schema( + self, connection, metadata + ): + """test #6511 + + schema-qualified collations are a PostgreSQL-only concept; this + test lives here rather than in the cross-dialect reflection suite + since no other backend supports the feature. + + """ + connection.exec_driver_sql('CREATE SCHEMA IF NOT EXISTS "SomeSchema"') + connection.exec_driver_sql( + 'CREATE COLLATION IF NOT EXISTS "SomeSchema"."SomeCollation" ' + "(LOCALE = 'C.utf8')" + ) + Table( + "t", + metadata, + Column( + "collated", + String( + collation="SomeCollation", collation_schema="SomeSchema" + ), + ), + Column("not_collated", String()), + ) + metadata.create_all(connection) + + m2 = MetaData() + t2 = Table("t", m2, autoload_with=connection) + + eq_( + ( + t2.c.collated.type.collation, + t2.c.collated.type.collation_schema, + ), + ("SomeCollation", "SomeSchema"), + ) + is_(t2.c.not_collated.type.collation, None) + + insp = inspect(connection) + collated, not_collated = insp.get_columns("t") + eq_( + ( + collated["type"].collation, + collated["type"].collation_schema, + ), + ("SomeCollation", "SomeSchema"), + ) + is_(not_collated["type"].collation, None) + def test_pg_weirdchar_reflection(self, metadata, connection): meta1 = metadata subject = Table( @@ -3004,10 +3099,13 @@ class CustomTypeReflectionTest(fixtures.TestBase): self.domains = domains class CustomType: - def __init__(self, arg1=None, arg2=None, collation=None): + def __init__( + self, arg1=None, arg2=None, collation=None, collation_schema=None + ): self.arg1 = arg1 self.arg2 = arg2 self.collation = collation + self.collation_schema = collation_schema ischema_names = None @@ -3027,13 +3125,17 @@ class CustomTypeReflectionTest(fixtures.TestBase): ("my_custom_type(ARG1)", ("ARG1", None)), ("my_custom_type(ARG1, ARG2)", ("ARG1", "ARG2")), ]: + if sch == "my_custom_type()": + collation = {"name": "cc", "schema": "myschema"} + else: + collation = None row_dict = { "name": "colname", "table_name": "tblname", "format_type": sch, "default": None, "not_null": False, - "collation": "cc" if sch == "my_custom_type()" else None, + "collation": collation, "comment": None, "generated": "", "identity_options": None, @@ -3050,8 +3152,10 @@ class CustomTypeReflectionTest(fixtures.TestBase): eq_(column_info["type"].arg2, args[1]) if sch == "my_custom_type()": eq_(column_info["type"].collation, "cc") + eq_(column_info["type"].collation_schema, "myschema") else: eq_(column_info["type"].collation, None) + eq_(column_info["type"].collation_schema, None) def test_clslevel(self): postgresql.PGDialect.ischema_names["my_custom_type"] = self.CustomType diff --git a/test/dialect/postgresql/test_types.py b/test/dialect/postgresql/test_types.py index 5d6264d6c8..ff3e007ef0 100644 --- a/test/dialect/postgresql/test_types.py +++ b/test/dialect/postgresql/test_types.py @@ -673,6 +673,7 @@ class NamedTypeTest( } ], "collation": "default", + "collation_schema": None, } ], )