]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Reflect schema-qualified PostgreSQL collations for columns and DOMAIN
authorMike Bayer <mike_mp@zzzcomputing.com>
Tue, 21 Jul 2026 20:43:28 +0000 (16:43 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Fri, 24 Jul 2026 19:02:47 +0000 (15:02 -0400)
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

doc/build/changelog/migration_21.rst
doc/build/changelog/unreleased_21/6511.rst [new file with mode: 0644]
lib/sqlalchemy/dialects/postgresql/base.py
lib/sqlalchemy/dialects/postgresql/pg_catalog.py
test/dialect/postgresql/test_reflection.py
test/dialect/postgresql/test_types.py

index e8281be0f2ab79b02d3914b261d0ea72cac1e810..41b37d0c9aa93ff4229e309ff860876d9f9fe143 100644 (file)
@@ -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 (file)
index 0000000..621bd76
--- /dev/null
@@ -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`
index cf1ed28374d047d7a6d1e5a47c9d632514bbf2e9..12e44ac65e28556a7220eeaa7a9d31b25d8b5f8f 100644 (file)
@@ -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)
 
index d8f9987ec264da8ee840ba01c16ae308de8a8e03..6416f89d3aee12f98b04575635e9d4a224b6837e 100644 (file)
@@ -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
index f3af3e1f912b18bb5403e1ae1ee1a87adc895b3f..aaaf818d114a585ac9a4f4812d0bcd5f9cb07e30 100644 (file)
@@ -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
index 5d6264d6c83e7d8e0f295d83c1afb729f9dee315..ff3e007ef02fd20b261ca6ed445e1997ca79ac15 100644 (file)
@@ -673,6 +673,7 @@ class NamedTypeTest(
                             }
                         ],
                         "collation": "default",
+                        "collation_schema": None,
                     }
                 ],
             )