]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
``Identity`` implies ``nullable=False``.
authorFederico Caselli <cfederico87@gmail.com>
Thu, 7 Jan 2021 20:22:52 +0000 (21:22 +0100)
committerMike Bayer <mike_mp@zzzcomputing.com>
Sat, 16 Jan 2021 23:39:11 +0000 (18:39 -0500)
Altered the behavior of the :class:`_schema.Identity` construct such that
when applied to a :class:`_schema.Column`, it will automatically imply that
the value of :paramref:`_sql.Column.nullable` should default to ``False``,
in a similar manner as when the :paramref:`_sql.Column.primary_key`
parameter is set to ``True``.   This matches the default behavior of all
supporting databases where ``IDENTITY`` implies ``NOT NULL``.  The
PostgreSQL backend is the only one that supports adding ``NULL`` to an
``IDENTITY`` column, which is here supported by passing a ``True`` value
for the :paramref:`_sql.Column.nullable` parameter at the same time.

Fixes: #5775
Change-Id: I0516d506ff327cff35cda605e8897a27440e0373

doc/build/changelog/unreleased_14/5775.rst [new file with mode: 0644]
lib/sqlalchemy/dialects/oracle/base.py
lib/sqlalchemy/dialects/postgresql/base.py
lib/sqlalchemy/sql/compiler.py
lib/sqlalchemy/sql/schema.py
test/dialect/postgresql/test_compiler.py
test/sql/test_identity_column.py
test/sql/test_metadata.py

diff --git a/doc/build/changelog/unreleased_14/5775.rst b/doc/build/changelog/unreleased_14/5775.rst
new file mode 100644 (file)
index 0000000..8a2786f
--- /dev/null
@@ -0,0 +1,14 @@
+.. change::
+    :tags: changed, schema
+    :tickets: 5775
+
+    Altered the behavior of the :class:`_schema.Identity` construct such that
+    when applied to a :class:`_schema.Column`, it will automatically imply that
+    the value of :paramref:`_sql.Column.nullable` should default to ``False``,
+    in a similar manner as when the :paramref:`_sql.Column.primary_key`
+    parameter is set to ``True``.   This matches the default behavior of all
+    supporting databases where ``IDENTITY`` implies ``NOT NULL``.  The
+    PostgreSQL backend is the only one that supports adding ``NULL`` to an
+    ``IDENTITY`` column, which is here supported by passing a ``True`` value
+    for the :paramref:`_sql.Column.nullable` parameter at the same time.
+
index a496f03543db0bd04a03b98bfca14c5bf70dcdb6..4754beebeb8a35efda0672c720b82e1d9322a59d 100644 (file)
@@ -38,7 +38,7 @@ The CREATE TABLE for the above :class:`_schema.Table` object would be:
 .. sourcecode:: sql
 
     CREATE TABLE mytable (
-        id INTEGER GENERATED BY DEFAULT AS IDENTITY (START WITH 3) NOT NULL,
+        id INTEGER GENERATED BY DEFAULT AS IDENTITY (START WITH 3),
         ...,
         PRIMARY KEY (id)
     )
index 7a898cb8ac8c9ae5d28af2e438f6323c4170260f..fd258bc5a201337ce9588fee6d825b991ac96050 100644 (file)
@@ -68,8 +68,7 @@ The CREATE TABLE for the above :class:`_schema.Table` object would be:
 .. sourcecode:: sql
 
     CREATE TABLE data (
-        id INTEGER GENERATED BY DEFAULT AS IDENTITY (START WITH 42 CYCLE)
-            NOT NULL,
+        id INTEGER GENERATED BY DEFAULT AS IDENTITY (START WITH 42 CYCLE),
         data VARCHAR,
         PRIMARY KEY (id)
     )
@@ -107,7 +106,7 @@ The CREATE TABLE for the above :class:`_schema.Table` object would be:
    Will generate on the backing database as::
 
        CREATE TABLE t (
-           id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
+           id INT GENERATED BY DEFAULT AS IDENTITY,
            data VARCHAR,
            PRIMARY KEY (id)
        )
@@ -2325,8 +2324,10 @@ class PGDDLCompiler(compiler.DDLCompiler):
         if column.identity is not None:
             colspec += " " + self.process(column.identity)
 
-        if not column.nullable:
+        if not column.nullable and not column.identity:
             colspec += " NOT NULL"
+        elif column.nullable and column.identity:
+            colspec += " NULL"
         return colspec
 
     def visit_check_constraint(self, constraint):
index 45cb75c2520f4f970caa6c3ecab76d25e3647d5e..ef7db0bbe56a0e62a0fa40dc26a393c1e299c928 100644 (file)
@@ -4130,7 +4130,7 @@ class DDLCompiler(Compiled):
         if column.identity is not None:
             colspec += " " + self.process(column.identity)
 
-        if not column.nullable:
+        if not column.nullable and not column.identity:
             colspec += " NOT NULL"
         return colspec
 
index 36d69456ef2f63b0dccd43de7a2b0ce2cb5c3017..f420eb62107eaa51d5b0b30f6536c7c031d950f3 100644 (file)
@@ -70,6 +70,17 @@ BLANK_SCHEMA = util.symbol(
     """,
 )
 
+NULL_UNSPECIFIED = util.symbol(
+    "NULL_UNSPECIFIED",
+    """Symbol indicating the "nullable" keyword was not passed to a Column.
+
+    Normally we would expect None to be acceptable for this but some backends
+    such as that of SQL Server place special signficance on a "nullability"
+    value of None.
+
+    """,
+)
+
 
 def _get_table_key(name, schema):
     if schema is None:
@@ -1257,11 +1268,18 @@ class Column(DialectKWArgs, SchemaItem, ColumnClause):
             phrase to be added when generating DDL for the column.   When
             ``True``, will normally generate nothing (in SQL this defaults to
             "NULL"), except in some very specific backend-specific edge cases
-            where "NULL" may render explicitly.   Defaults to ``True`` unless
-            :paramref:`_schema.Column.primary_key` is also ``True``,
-            in which case it
-            defaults to ``False``.  This parameter is only used when issuing
-            CREATE TABLE statements.
+            where "NULL" may render explicitly.
+            Defaults to ``True`` unless :paramref:`_schema.Column.primary_key`
+            is also ``True`` or the column specifies a :class:`_sql.Identity`,
+            in which case it defaults to ``False``.
+            This parameter is only used when issuing CREATE TABLE statements.
+
+            .. note::
+
+                When the column specifies a :class:`_sql.Identity` this
+                parameter is in general ignored by the DDL compiler. The
+                PostgreSQL database allows nullable identity column by
+                setting this parameter to ``True`` explicitly.
 
         :param onupdate: A scalar, Python callable, or
             :class:`~sqlalchemy.sql.expression.ClauseElement` representing a
@@ -1393,8 +1411,17 @@ class Column(DialectKWArgs, SchemaItem, ColumnClause):
 
         super(Column, self).__init__(name, type_)
         self.key = kwargs.pop("key", name)
-        self.primary_key = kwargs.pop("primary_key", False)
-        self.nullable = kwargs.pop("nullable", not self.primary_key)
+        self.primary_key = primary_key = kwargs.pop("primary_key", False)
+
+        self._user_defined_nullable = udn = kwargs.pop(
+            "nullable", NULL_UNSPECIFIED
+        )
+
+        if udn is not NULL_UNSPECIFIED:
+            self.nullable = udn
+        else:
+            self.nullable = not primary_key
+
         self.default = kwargs.pop("default", None)
         self.server_default = kwargs.pop("server_default", None)
         self.server_onupdate = kwargs.pop("server_onupdate", None)
@@ -1466,10 +1493,6 @@ class Column(DialectKWArgs, SchemaItem, ColumnClause):
     def _extra_kwargs(self, **kwargs):
         self._validate_dialect_kwargs(kwargs)
 
-    #    @property
-    #    def quote(self):
-    #        return getattr(self.name, "quote", None)
-
     def __str__(self):
         if self.name is None:
             return "(no name)"
@@ -1664,12 +1687,14 @@ class Column(DialectKWArgs, SchemaItem, ColumnClause):
         if isinstance(type_, SchemaEventTarget):
             type_ = type_.copy(**kw)
 
+        if self._user_defined_nullable is not NULL_UNSPECIFIED:
+            column_kwargs["nullable"] = self._user_defined_nullable
+
         c = self._constructor(
             name=self.name,
             type_=type_,
             key=self.key,
             primary_key=self.primary_key,
-            nullable=self.nullable,
             unique=self.unique,
             system=self.system,
             # quote=self.quote,  # disabled 2013-08-27 (commit 031ef080)
@@ -3596,7 +3621,8 @@ class PrimaryKeyConstraint(ColumnCollectionConstraint):
 
         for c in self.columns:
             c.primary_key = True
-            c.nullable = False
+            if c._user_defined_nullable is NULL_UNSPECIFIED:
+                c.nullable = False
         if table_pks:
             self.columns.extend(table_pks)
 
@@ -4777,9 +4803,12 @@ class Identity(IdentityOptions, FetchedValue, SchemaItem):
                 "autoincrement=False"
             )
         self.column = parent
+
         parent.identity = self
-        # self.column.server_onupdate = self
-        self.column.server_default = self
+        if parent._user_defined_nullable is NULL_UNSPECIFIED:
+            parent.nullable = False
+
+        parent.server_default = self
 
     def _as_for_update(self, for_update):
         return self
index b3a0b9bbded4adbd8c692e0c079ffbb8fa8517b4..ec165960f49c1043a18cfe49f80762b6d9da797b 100644 (file)
@@ -1778,6 +1778,25 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL):
             "(INCREMENT BY 7 START WITH 4))",
         )
 
+    def test_column_identity_null(self):
+        # all other tests are in test_identity_column.py
+        m = MetaData()
+        t = Table(
+            "t",
+            m,
+            Column(
+                "y",
+                Integer,
+                Identity(always=True, start=4, increment=7),
+                nullable=True,
+            ),
+        )
+        self.assert_compile(
+            schema.CreateTable(t),
+            "CREATE TABLE t (y INTEGER GENERATED ALWAYS AS IDENTITY "
+            "(INCREMENT BY 7 START WITH 4) NULL)",
+        )
+
     def test_index_extra_include_1(self):
         metadata = MetaData()
         tbl = Table(
index 3ac97db92feeb141fefe900a799fef9e06875930..1ce15f38c9fcd8a0662f1b849666e428193e8801 100644 (file)
@@ -102,7 +102,7 @@ class _IdentityDDLFixture(testing.AssertsCompiledSQL):
             CreateTable(t),
             "CREATE TABLE foo_table ("
             "foo INTEGER GENERATED ALWAYS AS IDENTITY (START "
-            "WITH 3) NOT NULL, UNIQUE (foo))",
+            "WITH 3), UNIQUE (foo))",
         )
 
     def test_autoincrement_true(self):
@@ -120,10 +120,41 @@ class _IdentityDDLFixture(testing.AssertsCompiledSQL):
         self.assert_compile(
             CreateTable(t),
             "CREATE TABLE foo_table ("
-            "foo INTEGER GENERATED ALWAYS AS IDENTITY (START WITH 3) NOT NULL"
+            "foo INTEGER GENERATED ALWAYS AS IDENTITY (START WITH 3)"
             ", PRIMARY KEY (foo))",
         )
 
+    def test_nullable_kwarg(self):
+        t = Table(
+            "t",
+            MetaData(),
+            Column("a", Integer(), Identity(), nullable=False),
+            Column("b", Integer(), Identity(), nullable=True),
+            Column("c", Integer(), Identity()),
+        )
+
+        is_(t.c.a.nullable, False)
+        is_(t.c.b.nullable, True)
+        is_(t.c.c.nullable, False)
+
+        nullable = ""
+        if getattr(self, "__dialect__", None) != "default" and testing.against(
+            "postgresql"
+        ):
+            nullable = " NULL"
+
+        self.assert_compile(
+            CreateTable(t),
+            (
+                "CREATE TABLE t ("
+                "a INTEGER GENERATED BY DEFAULT AS IDENTITY, "
+                "b INTEGER GENERATED BY DEFAULT AS IDENTITY%s, "
+                "c INTEGER GENERATED BY DEFAULT AS IDENTITY"
+                ")"
+            )
+            % nullable,
+        )
+
 
 class IdentityDDL(_IdentityDDLFixture, fixtures.TestBase):
     # this uses the connection dialect
@@ -167,7 +198,7 @@ class NotSupportingIdentityDDL(testing.AssertsCompiledSQL, fixtures.TestBase):
             Column("foo", Integer(), Identity("always", start=3)),
         )
         self.assert_compile(
-            CreateTable(t), "CREATE TABLE foo_table (foo INTEGER)"
+            CreateTable(t), "CREATE TABLE foo_table (foo INTEGER NOT NULL)"
         )
 
 
index 9bf351f5c41cc73c77bd957d4b732c51f3bb411c..17228dad6179f3f41cd318d2e53786f8ca7452e7 100644 (file)
@@ -1838,6 +1838,30 @@ class TableTest(fixtures.TestBase, AssertsCompiledSQL):
         assert not t2.c.x.nullable
         assert not t1.c.x.nullable
 
+    def test_pk_can_be_nullable(self):
+        m = MetaData()
+
+        t1 = Table(
+            "t1",
+            m,
+            Column("x", Integer, nullable=True),
+            PrimaryKeyConstraint("x"),
+        )
+
+        t2 = Table(
+            "t2", m, Column("x", Integer, primary_key=True, nullable=True)
+        )
+
+        eq_(list(t1.primary_key), [t1.c.x])
+
+        eq_(list(t2.primary_key), [t2.c.x])
+
+        assert t1.c.x.primary_key
+        assert t2.c.x.primary_key
+
+        assert t2.c.x.nullable
+        assert t1.c.x.nullable
+
     def test_must_exist(self):
         with testing.expect_raises_message(
             exc.InvalidRequestError, "Table 'foo' not defined"