]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Don't apply a length to reflected TEXT / NTEXT
authorMike Bayer <mike_mp@zzzcomputing.com>
Wed, 12 Aug 2026 19:30:42 +0000 (15:30 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Wed, 12 Aug 2026 19:30:42 +0000 (15:30 -0400)
Fixed issue in SQL Server reflection where ``TEXT`` and ``NTEXT``
columns would be reflected with a spurious length of 16 and 8,
respectively.  These are unlengthed LOB datatypes; the value
originates from the ``sys.columns.max_length`` column, which reports
the size of the in-row LOB pointer rather than a character length for
these types.  The reflected ``TEXT`` and ``NTEXT`` types now have a
``length`` of ``None``, so that a reflected table emits valid DDL when
re-created, which previously failed with "Cannot specify a column
width on data type text".

Fixes: #13451
Change-Id: I8456688fc8d21fe25f326c6bf0f7b13aa1fc838c

doc/build/changelog/unreleased_20/13451.rst [new file with mode: 0644]
lib/sqlalchemy/dialects/mssql/base.py
test/dialect/mssql/test_reflection.py

diff --git a/doc/build/changelog/unreleased_20/13451.rst b/doc/build/changelog/unreleased_20/13451.rst
new file mode 100644 (file)
index 0000000..fa0502d
--- /dev/null
@@ -0,0 +1,13 @@
+.. change::
+    :tags: bug, mssql, reflection
+    :tickets: 13451
+
+    Fixed issue in SQL Server reflection where ``TEXT`` and ``NTEXT`` columns
+    would be reflected with a spurious length of 16 and 8, respectively.  These
+    are unlengthed LOB datatypes; the value originates from the
+    ``sys.columns.max_length`` column, which reports the size of the in-row LOB
+    pointer rather than a character length for these types.  The reflected
+    :class:`_mssql.TEXT` and :class:`_mssql.NTEXT` types now have a ``length``
+    of ``None``, so that a reflected table emits valid DDL when re-created,
+    which previously failed with "Cannot specify a column width on data type
+    text".
index 647ee36b607c2bf8c6e170ae588486e0994eb153..478109914c338298d24b5c15a32ebc719c4122b7 100644 (file)
@@ -3589,14 +3589,20 @@ class MSDialect(default._BackendsMultiReflection, default.DefaultDialect):
 
         if coltype in (MSBinary, MSVarBinary, sqltypes.LargeBinary):
             kwargs["length"] = maxlen if maxlen != -1 else None
-        elif coltype in (MSString, MSChar, MSText):
+        elif coltype in (MSString, MSChar):
             kwargs["length"] = maxlen if maxlen != -1 else None
             if collation:
                 kwargs["collation"] = collation
-        elif coltype in (MSNVarchar, MSNChar, MSNText):
+        elif coltype in (MSNVarchar, MSNChar):
             kwargs["length"] = maxlen // 2 if maxlen != -1 else None
             if collation:
                 kwargs["collation"] = collation
+        elif coltype in (MSText, MSNText):
+            # TEXT / NTEXT are unlengthed LOB types.  sys.columns.max_length
+            # reports 16 for these, which is the size of the in-row LOB
+            # pointer and not a character length, so no length is applied.
+            if collation:
+                kwargs["collation"] = collation
 
         if coltype is None:
             if base_type is not None and base_type != type_:
index 59f5245e72eac748dfe114ba123c2c2be2ee47b9..60ca27e2269b3219217aee37b432bedb19ce0ac2 100644 (file)
@@ -117,6 +117,37 @@ class ReflectionTest(fixtures.TestBase, ComparesTables, AssertsCompiledSQL):
             "CREATE TABLE type_test (col1 %s NULL)" % ddl,
         )
 
+    def test_lob_types_no_length(self, metadata, connection):
+        """TEXT / NTEXT / IMAGE are unlengthed, and a reflected version of
+        such a table must remain creatable.
+
+        issue #13451
+
+        """
+        Table(
+            "lob_type_test",
+            metadata,
+            Column("id", types.Integer, primary_key=True),
+            Column("t", mssql.TEXT),
+            Column("nt", mssql.NTEXT),
+            Column("img", mssql.IMAGE),
+        )
+        metadata.create_all(connection)
+
+        m2 = MetaData()
+        table2 = Table("lob_type_test", m2, autoload_with=connection)
+        eq_(
+            {c.name: c.type.length for c in table2.c if c.name != "id"},
+            {"t": None, "nt": None, "img": None},
+        )
+
+        # the reflected types round trip back into valid DDL; a length
+        # here would be rejected with "Cannot specify a column width on
+        # data type text"
+        Table(
+            "lob_type_test_2", metadata, *[c._copy() for c in table2.c]
+        ).create(connection)
+
     def test_identity(self, metadata, connection):
         table = Table(
             "identity_test",
@@ -1263,6 +1294,74 @@ class ReflectionTest(fixtures.TestBase, ComparesTables, AssertsCompiledSQL):
         )
 
 
+class ParseColumnInfoTest(fixtures.TestBase):
+    """test translation of ``sys.columns.max_length`` into type lengths.
+
+    issue #13451
+
+    """
+
+    @testing.combinations(
+        ("varchar", 30, "VARCHAR(30)", 30),
+        ("varchar", -1, "VARCHAR(max)", None),
+        ("char", 10, "CHAR(10)", 10),
+        ("nvarchar", 60, "NVARCHAR(30)", 30),
+        ("nvarchar", -1, "NVARCHAR(max)", None),
+        ("nchar", 20, "NCHAR(10)", 10),
+        ("text", 16, "TEXT", None),
+        ("ntext", 16, "NTEXT", None),
+        ("image", 16, "IMAGE", None),
+        ("varbinary", 20, "VARBINARY(20)", 20),
+        ("varbinary", -1, "VARBINARY(max)", None),
+        ("binary", 10, "BINARY(10)", 10),
+        argnames="type_name, max_length, expected_ddl, expected_length",
+    )
+    def test_length_from_max_length(
+        self, type_name, max_length, expected_ddl, expected_length
+    ):
+        """``max_length`` is 16 for the LOB types text, ntext and image,
+        that being the size of the in-row LOB pointer rather than a
+        character length.
+
+        """
+        dialect = mssql.dialect()
+        type_ = self._parse_type(dialect, type_name, max_length, None)
+        eq_(
+            (type_.compile(dialect=dialect), type_.length),
+            (expected_ddl, expected_length),
+        )
+
+    @testing.combinations("text", "ntext", argnames="type_name")
+    def test_lob_types_retain_collation(self, type_name):
+        """dropping the bogus length must not drop the collation."""
+
+        dialect = mssql.dialect()
+        type_ = self._parse_type(
+            dialect, type_name, 16, "Latin1_General_CI_AS"
+        )
+        eq_(type_.collation, "Latin1_General_CI_AS")
+
+    def _parse_type(self, dialect, type_name, max_length, collation):
+        cdict = dialect._parse_column_info(
+            name="data",
+            type_=type_name,
+            base_type=type_name,
+            nullable=True,
+            maxlen=max_length,
+            numericprec=0,
+            numericscale=0,
+            default=None,
+            collation=collation,
+            definition=None,
+            is_persisted=None,
+            is_identity=None,
+            identity_start=None,
+            identity_increment=None,
+            comment=None,
+        )
+        return cdict["type"]
+
+
 class InfoCoerceUnicodeTest(fixtures.TestBase, AssertsCompiledSQL):
     def test_info_unicode_cast_no_2000(self):
         dialect = mssql.dialect()