summaryrefslogtreecommitdiff
path: root/lib/sqlalchemy
diff options
context:
space:
mode:
authormike bayer <mike_mp@zzzcomputing.com>2019-11-09 16:21:55 +0000
committerGerrit Code Review <gerrit@bbpush.zzzcomputing.com>2019-11-09 16:21:55 +0000
commited2c5f9ad1f92010e447797576ab4eef3beee21f (patch)
tree6e39e7366de4ac2fbb6a98e4e5938e33f34422a4 /lib/sqlalchemy
parent4a2dd4902a1168234f14bdd0634728086d53c406 (diff)
parent3a0e0531c179e598c345e5be24e350c375ce7e22 (diff)
downloadsqlalchemy-ed2c5f9ad1f92010e447797576ab4eef3beee21f.tar.gz
Merge "Support for generated columns"
Diffstat (limited to 'lib/sqlalchemy')
-rw-r--r--lib/sqlalchemy/__init__.py1
-rw-r--r--lib/sqlalchemy/dialects/firebird/base.py11
-rw-r--r--lib/sqlalchemy/dialects/mssql/base.py24
-rw-r--r--lib/sqlalchemy/dialects/mysql/base.py3
-rw-r--r--lib/sqlalchemy/dialects/oracle/base.py24
-rw-r--r--lib/sqlalchemy/dialects/postgresql/base.py15
-rw-r--r--lib/sqlalchemy/dialects/sqlite/base.py3
-rw-r--r--lib/sqlalchemy/schema.py1
-rw-r--r--lib/sqlalchemy/sql/compiler.py13
-rw-r--r--lib/sqlalchemy/sql/schema.py103
-rw-r--r--lib/sqlalchemy/testing/config.py7
-rw-r--r--lib/sqlalchemy/testing/requirements.py4
-rw-r--r--lib/sqlalchemy/testing/suite/test_select.py45
13 files changed, 241 insertions, 13 deletions
diff --git a/lib/sqlalchemy/__init__.py b/lib/sqlalchemy/__init__.py
index a1e166a0d..92128ed22 100644
--- a/lib/sqlalchemy/__init__.py
+++ b/lib/sqlalchemy/__init__.py
@@ -17,6 +17,7 @@ from .schema import DefaultClause # noqa
from .schema import FetchedValue # noqa
from .schema import ForeignKey # noqa
from .schema import ForeignKeyConstraint # noqa
+from .schema import Computed # noqa
from .schema import Index # noqa
from .schema import MetaData # noqa
from .schema import PrimaryKeyConstraint # noqa
diff --git a/lib/sqlalchemy/dialects/firebird/base.py b/lib/sqlalchemy/dialects/firebird/base.py
index c7c921cb4..052ecf05b 100644
--- a/lib/sqlalchemy/dialects/firebird/base.py
+++ b/lib/sqlalchemy/dialects/firebird/base.py
@@ -581,6 +581,17 @@ class FBDDLCompiler(sql.compiler.DDLCompiler):
drop.element
)
+ def visit_computed_column(self, generated):
+ if generated.persisted is not None:
+ raise exc.CompileError(
+ "Firebird computed columns do not support a persistence "
+ "method setting; set the 'persisted' flag to None for "
+ "Firebird support."
+ )
+ return "GENERATED ALWAYS AS (%s)" % self.sql_compiler.process(
+ generated.sqltext, include_table=False, literal_binds=True
+ )
+
class FBIdentifierPreparer(sql.compiler.IdentifierPreparer):
"""Install Firebird specific reserved words."""
diff --git a/lib/sqlalchemy/dialects/mssql/base.py b/lib/sqlalchemy/dialects/mssql/base.py
index 6c7b732ce..5d4de4a33 100644
--- a/lib/sqlalchemy/dialects/mssql/base.py
+++ b/lib/sqlalchemy/dialects/mssql/base.py
@@ -1942,13 +1942,15 @@ class MSSQLStrictCompiler(MSSQLCompiler):
class MSDDLCompiler(compiler.DDLCompiler):
def get_column_specification(self, column, **kwargs):
- colspec = (
- self.preparer.format_column(column)
- + " "
- + self.dialect.type_compiler.process(
+ colspec = self.preparer.format_column(column)
+
+ # type is not accepted in a computed column
+ if column.computed is not None:
+ colspec += " " + self.process(column.computed)
+ else:
+ colspec += " " + self.dialect.type_compiler.process(
column.type, type_expression=column
)
- )
if column.nullable is not None:
if (
@@ -1958,7 +1960,8 @@ class MSDDLCompiler(compiler.DDLCompiler):
or column.autoincrement is True
):
colspec += " NOT NULL"
- else:
+ elif column.computed is None:
+ # don't specify "NULL" for computed columns
colspec += " NULL"
if column.table is None:
@@ -2110,6 +2113,15 @@ class MSDDLCompiler(compiler.DDLCompiler):
text += self.define_constraint_deferrability(constraint)
return text
+ def visit_computed_column(self, generated):
+ text = "AS (%s)" % self.sql_compiler.process(
+ generated.sqltext, include_table=False, literal_binds=True
+ )
+ # explicitly check for True|False since None means server default
+ if generated.persisted is True:
+ text += " PERSISTED"
+ return text
+
class MSIdentifierPreparer(compiler.IdentifierPreparer):
reserved_words = RESERVED_WORDS
diff --git a/lib/sqlalchemy/dialects/mysql/base.py b/lib/sqlalchemy/dialects/mysql/base.py
index 73484aea1..05edb6310 100644
--- a/lib/sqlalchemy/dialects/mysql/base.py
+++ b/lib/sqlalchemy/dialects/mysql/base.py
@@ -1503,6 +1503,9 @@ class MySQLDDLCompiler(compiler.DDLCompiler):
),
]
+ if column.computed is not None:
+ colspec.append(self.process(column.computed))
+
is_timestamp = isinstance(
column.type._unwrapped_dialect_impl(self.dialect),
sqltypes.TIMESTAMP,
diff --git a/lib/sqlalchemy/dialects/oracle/base.py b/lib/sqlalchemy/dialects/oracle/base.py
index 4c5a717b9..c1e91fb12 100644
--- a/lib/sqlalchemy/dialects/oracle/base.py
+++ b/lib/sqlalchemy/dialects/oracle/base.py
@@ -446,6 +446,7 @@ columns for non-unique indexes, all but the last column for unique indexes).
from itertools import groupby
import re
+from ... import Computed
from ... import exc
from ... import schema as sa_schema
from ... import sql
@@ -905,6 +906,16 @@ class OracleCompiler(compiler.SQLCompiler):
for i, column in enumerate(
expression._select_iterables(returning_cols)
):
+ if self.isupdate and isinstance(column.server_default, Computed):
+ util.warn(
+ "Computed columns don't work with Oracle UPDATE "
+ "statements that use RETURNING; the value of the column "
+ "*before* the UPDATE takes place is returned. It is "
+ "advised to not use RETURNING with an Oracle computed "
+ "column. Consider setting implicit_returning to False on "
+ "the Table object in order to avoid implicit RETURNING "
+ "clauses from being generated for this Table."
+ )
if column.type._has_column_expression:
col_expr = column.type.column_expression(column)
else:
@@ -1186,6 +1197,19 @@ class OracleDDLCompiler(compiler.DDLCompiler):
return "".join(table_opts)
+ def visit_computed_column(self, generated):
+ text = "GENERATED ALWAYS AS (%s)" % self.sql_compiler.process(
+ generated.sqltext, include_table=False, literal_binds=True
+ )
+ if generated.persisted is True:
+ raise exc.CompileError(
+ "Oracle computed columns do not support 'stored' persistence; "
+ "set the 'persisted' flag to None or False for Oracle support."
+ )
+ elif generated.persisted is False:
+ text += " VIRTUAL"
+ return text
+
class OracleIdentifierPreparer(compiler.IdentifierPreparer):
diff --git a/lib/sqlalchemy/dialects/postgresql/base.py b/lib/sqlalchemy/dialects/postgresql/base.py
index e94f9913c..d6fd2623b 100644
--- a/lib/sqlalchemy/dialects/postgresql/base.py
+++ b/lib/sqlalchemy/dialects/postgresql/base.py
@@ -1873,6 +1873,9 @@ class PGDDLCompiler(compiler.DDLCompiler):
if default is not None:
colspec += " DEFAULT " + default
+ if column.computed is not None:
+ colspec += " " + self.process(column.computed)
+
if not column.nullable:
colspec += " NOT NULL"
return colspec
@@ -2043,6 +2046,18 @@ class PGDDLCompiler(compiler.DDLCompiler):
return "".join(table_opts)
+ def visit_computed_column(self, generated):
+ if generated.persisted is False:
+ raise exc.CompileError(
+ "PostrgreSQL computed columns do not support 'virtual' "
+ "persistence; set the 'persisted' flag to None or True for "
+ "PostgreSQL support."
+ )
+
+ return "GENERATED ALWAYS AS (%s) STORED" % self.sql_compiler.process(
+ generated.sqltext, include_table=False, literal_binds=True
+ )
+
class PGTypeCompiler(compiler.GenericTypeCompiler):
def visit_TSVECTOR(self, type_, **kw):
diff --git a/lib/sqlalchemy/dialects/sqlite/base.py b/lib/sqlalchemy/dialects/sqlite/base.py
index c1f108a0d..02d44a260 100644
--- a/lib/sqlalchemy/dialects/sqlite/base.py
+++ b/lib/sqlalchemy/dialects/sqlite/base.py
@@ -1032,6 +1032,9 @@ class SQLiteCompiler(compiler.SQLCompiler):
class SQLiteDDLCompiler(compiler.DDLCompiler):
def get_column_specification(self, column, **kwargs):
+ if column.computed is not None:
+ raise exc.CompileError("SQLite does not support computed columns")
+
coltype = self.dialect.type_compiler.process(
column.type, type_expression=column
)
diff --git a/lib/sqlalchemy/schema.py b/lib/sqlalchemy/schema.py
index f50acdb1c..6adeded36 100644
--- a/lib/sqlalchemy/schema.py
+++ b/lib/sqlalchemy/schema.py
@@ -47,6 +47,7 @@ from .sql.schema import DefaultGenerator # noqa
from .sql.schema import FetchedValue # noqa
from .sql.schema import ForeignKey # noqa
from .sql.schema import ForeignKeyConstraint # noqa
+from .sql.schema import Computed # noqa
from .sql.schema import Index # noqa
from .sql.schema import MetaData # noqa
from .sql.schema import PrimaryKeyConstraint # noqa
diff --git a/lib/sqlalchemy/sql/compiler.py b/lib/sqlalchemy/sql/compiler.py
index 546fffc6c..85c1750b7 100644
--- a/lib/sqlalchemy/sql/compiler.py
+++ b/lib/sqlalchemy/sql/compiler.py
@@ -3173,6 +3173,9 @@ class DDLCompiler(Compiled):
if default is not None:
colspec += " DEFAULT " + default
+ if column.computed is not None:
+ colspec += " " + self.process(column.computed)
+
if not column.nullable:
colspec += " NOT NULL"
return colspec
@@ -3314,6 +3317,16 @@ class DDLCompiler(Compiled):
text += " MATCH %s" % constraint.match
return text
+ def visit_computed_column(self, generated):
+ text = "GENERATED ALWAYS AS (%s)" % self.sql_compiler.process(
+ generated.sqltext, include_table=False, literal_binds=True
+ )
+ if generated.persisted is True:
+ text += " STORED"
+ elif generated.persisted is False:
+ text += " VIRTUAL"
+ return text
+
class GenericTypeCompiler(TypeCompiler):
def visit_FLOAT(self, type_, **kw):
diff --git a/lib/sqlalchemy/sql/schema.py b/lib/sqlalchemy/sql/schema.py
index ee7dc61ce..8c325538c 100644
--- a/lib/sqlalchemy/sql/schema.py
+++ b/lib/sqlalchemy/sql/schema.py
@@ -1028,9 +1028,9 @@ class Column(DialectKWArgs, SchemaItem, ColumnClause):
:class:`.SchemaItem` derived constructs which will be applied
as options to the column. These include instances of
:class:`.Constraint`, :class:`.ForeignKey`, :class:`.ColumnDefault`,
- and :class:`.Sequence`. In some cases an equivalent keyword
- argument is available such as ``server_default``, ``default``
- and ``unique``.
+ :class:`.Sequence`, :class:`.Computed`. In some cases an
+ equivalent keyword argument is available such as ``server_default``,
+ ``default`` and ``unique``.
:param autoincrement: Set up "auto increment" semantics for an integer
primary key column. The default value is the string ``"auto"``
@@ -1296,6 +1296,7 @@ class Column(DialectKWArgs, SchemaItem, ColumnClause):
self.constraints = set()
self.foreign_keys = set()
self.comment = kwargs.pop("comment", None)
+ self.computed = None
# check if this Column is proxying another column
if "_proxies" in kwargs:
@@ -1502,6 +1503,12 @@ class Column(DialectKWArgs, SchemaItem, ColumnClause):
c.copy(**kw) for c in self.constraints if not c._type_bound
] + [c.copy(**kw) for c in self.foreign_keys if not c.constraint]
+ server_default = self.server_default
+ server_onupdate = self.server_onupdate
+ if isinstance(server_default, Computed):
+ server_default = server_onupdate = None
+ args.append(self.server_default.copy(**kw))
+
type_ = self.type
if isinstance(type_, SchemaEventTarget):
type_ = type_.copy(**kw)
@@ -1518,9 +1525,9 @@ class Column(DialectKWArgs, SchemaItem, ColumnClause):
index=self.index,
autoincrement=self.autoincrement,
default=self.default,
- server_default=self.server_default,
+ server_default=server_default,
onupdate=self.onupdate,
- server_onupdate=self.server_onupdate,
+ server_onupdate=server_onupdate,
doc=self.doc,
comment=self.comment,
*args
@@ -4348,3 +4355,89 @@ class _SchemaTranslateMap(object):
_default_schema_map = _SchemaTranslateMap(None)
_schema_getter = _SchemaTranslateMap._schema_getter
+
+
+class Computed(FetchedValue, SchemaItem):
+ """Defines a generated column, i.e. "GENERATED ALWAYS AS" syntax.
+
+ The :class:`.Computed` construct is an inline construct added to the
+ argument list of a :class:`.Column` object::
+
+ from sqlalchemy import Computed
+
+ Table('square', meta,
+ Column('side', Float, nullable=False),
+ Column('area', Float, Computed('side * side'))
+ )
+
+ See the linked documentation below for complete details.
+
+ .. versionadded:: 1.3.11
+
+ .. seealso::
+
+ :ref:`computed_ddl`
+
+ """
+
+ __visit_name__ = "computed_column"
+
+ @_document_text_coercion(
+ "sqltext", ":class:`.Computed`", ":paramref:`.Computed.sqltext`"
+ )
+ def __init__(self, sqltext, persisted=None):
+ """Construct a GENERATED ALWAYS AS DDL construct to accompany a
+ :class:`.Column`.
+
+ :param sqltext:
+ A string containing the column generation expression, which will be
+ used verbatim, or a SQL expression construct, such as a :func:`.text`
+ object. If given as a string, the object is converted to a
+ :func:`.text` object.
+
+ :param persisted:
+ Optional, controls how this column should be persisted by the
+ database. Possible values are:
+
+ * None, the default, it will use the default persistence defined
+ by the database.
+ * True, will render ``GENERATED ALWAYS AS ... STORED``, or the
+ equivalent for the target database if supported
+ * False, will render ``GENERATED ALWAYS AS ... VIRTUAL``, or the
+ equivalent for the target database if supported.
+
+ Specifying ``True`` or ``False`` may raise an error when the DDL
+ is emitted to the target database if the databse does not support
+ that persistence option. Leaving this parameter at its default
+ of ``None`` is guaranteed to succeed for all databases that support
+ ``GENERATED ALWAYS AS``.
+
+ """
+ self.sqltext = coercions.expect(roles.DDLExpressionRole, sqltext)
+ self.persisted = persisted
+ self.column = None
+
+ def _set_parent(self, parent):
+ if not isinstance(
+ parent.server_default, (type(None), Computed)
+ ) or not isinstance(parent.server_onupdate, (type(None), Computed)):
+ raise exc.ArgumentError(
+ "A generated column cannot specify a server_default or a "
+ "server_onupdate argument"
+ )
+ self.column = parent
+ parent.computed = self
+ self.column.server_onupdate = self
+ self.column.server_default = self
+
+ def _as_for_update(self, for_update):
+ return self
+
+ def copy(self, target_table=None, **kw):
+ if target_table is not None:
+ sqltext = _copy_expression(self.sqltext, self.table, target_table)
+ else:
+ sqltext = self.sqltext
+ g = Computed(sqltext, persisted=self.persisted)
+
+ return self._schema_item_copy(g)
diff --git a/lib/sqlalchemy/testing/config.py b/lib/sqlalchemy/testing/config.py
index 87bbc6a0f..8262142ec 100644
--- a/lib/sqlalchemy/testing/config.py
+++ b/lib/sqlalchemy/testing/config.py
@@ -44,7 +44,7 @@ def combinations(*comb, **kw):
well as if it is included in the tokens used to create the id of the
parameter set.
- If omitted, the argment combinations are passed to parametrize as is. If
+ If omitted, the argument combinations are passed to parametrize as is. If
passed, each argument combination is turned into a pytest.param() object,
mapping the elements of the argument tuple to produce an id based on a
character value in the same position within the string template using the
@@ -59,9 +59,12 @@ def combinations(*comb, **kw):
r - the given argument should be passed and it should be added to the
id by calling repr()
- s- the given argument should be passed and it should be added to the
+ s - the given argument should be passed and it should be added to the
id by calling str()
+ a - (argument) the given argument should be passed and it should not
+ be used to generated the id
+
e.g.::
@testing.combinations(
diff --git a/lib/sqlalchemy/testing/requirements.py b/lib/sqlalchemy/testing/requirements.py
index c45156d6b..fd8d82690 100644
--- a/lib/sqlalchemy/testing/requirements.py
+++ b/lib/sqlalchemy/testing/requirements.py
@@ -1064,3 +1064,7 @@ class SuiteRequirements(Requirements):
return True
except ImportError:
return False
+
+ @property
+ def computed_columns(self):
+ return exclusions.closed()
diff --git a/lib/sqlalchemy/testing/suite/test_select.py b/lib/sqlalchemy/testing/suite/test_select.py
index 02cdcf4f5..9db2daf7a 100644
--- a/lib/sqlalchemy/testing/suite/test_select.py
+++ b/lib/sqlalchemy/testing/suite/test_select.py
@@ -9,6 +9,7 @@ from ..schema import Column
from ..schema import Table
from ... import bindparam
from ... import case
+from ... import Computed
from ... import false
from ... import func
from ... import Integer
@@ -858,3 +859,47 @@ class LikeFunctionsTest(fixtures.TablesTest):
col = self.tables.some_table.c.data
self._test(col.contains("b%cd", autoescape=True, escape="#"), {3})
self._test(col.contains("b#cd", autoescape=True, escape="#"), {7})
+
+
+class ComputedColumnTest(fixtures.TablesTest):
+ __backend__ = True
+ __requires__ = ("computed_columns",)
+
+ @classmethod
+ def define_tables(cls, metadata):
+ Table(
+ "square",
+ metadata,
+ Column("id", Integer, primary_key=True),
+ Column("side", Integer),
+ Column("area", Integer, Computed("side * side")),
+ Column("perimeter", Integer, Computed("4 * side")),
+ )
+
+ @classmethod
+ def insert_data(cls):
+ with config.db.begin() as conn:
+ conn.execute(
+ cls.tables.square.insert(),
+ [{"id": 1, "side": 10}, {"id": 10, "side": 42}],
+ )
+
+ def test_select_all(self):
+ with config.db.connect() as conn:
+ res = conn.execute(
+ select([text("*")])
+ .select_from(self.tables.square)
+ .order_by(self.tables.square.c.id)
+ ).fetchall()
+ eq_(res, [(1, 10, 100, 40), (10, 42, 1764, 168)])
+
+ def test_select_columns(self):
+ with config.db.connect() as conn:
+ res = conn.execute(
+ select(
+ [self.tables.square.c.area, self.tables.square.c.perimeter]
+ )
+ .select_from(self.tables.square)
+ .order_by(self.tables.square.c.id)
+ ).fetchall()
+ eq_(res, [(100, 40), (1764, 168)])