Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] add 'create_if_not_exists' for views #599

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ Session.vim
# JetBrains
.idea/

# vscode
.vscode/

# Environments
.env
.venv
Expand Down
4 changes: 4 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ exclude =
.venv
build
docs/conf.py
# E501: line too long in comments
# F811: redefinition of unused (to support different dialect compiles)
per-file-ignores =
sqlalchemy_utils/view.py:E501,F811
206 changes: 172 additions & 34 deletions sqlalchemy_utils/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,149 @@


class CreateView(DDLElement):
def __init__(self, name, selectable, materialized=False):
def __init__(
self,
name,
selectable,
materialized=False,
if_not_exists=False,
or_replace=False,
):
self.name = name
self.selectable = selectable
self.materialized = materialized
self.if_not_exists = if_not_exists
self.or_replace = or_replace


@compiler.compiles(CreateView)
def compile_create_materialized_view(element, compiler, **kw):
return 'CREATE {}{}VIEW {}{} AS {}'.format(
'OR REPLACE ' if element.or_replace else '',
'MATERIALIZED ' if element.materialized else '',
'IF NOT EXISTS ' if element.if_not_exists else '',
compiler.dialect.identifier_preparer.quote(element.name),
compiler.sql_compiler.process(element.selectable, literal_binds=True),
)


@compiler.compiles(CreateView, 'postgresql')
def compile_create_materialized_view_postgresql(element, compiler, **kw):
"""
CREATE [ OR REPLACE ] [ TEMP | TEMPORARY ] [ RECURSIVE ] VIEW name [ ( column_name [, ...] ) ]
[ WITH ( view_option_name [= view_option_value] [, ... ] ) ]
AS query
[ WITH [ CASCADED | LOCAL ] CHECK OPTION ]

CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] table_name
[ (column_name [, ...] ) ]
[ USING method ]
[ WITH ( storage_parameter [= value] [, ... ] ) ]
[ TABLESPACE tablespace_name ]
AS query
[ WITH [ NO ] DATA ]

see https://www.postgresql.org/docs/current/sql-createview.html
see https://www.postgresql.org/docs/current/sql-creatematerializedview.html
"""
return 'CREATE {}{}VIEW {}{} AS {}'.format(
'OR REPLACE ' if not element.materialized and element.or_replace else '',
'MATERIALIZED ' if element.materialized else '',
'IF NOT EXISTS ' if element.materialized and element.if_not_exists else '',
compiler.dialect.identifier_preparer.quote(element.name),
compiler.sql_compiler.process(element.selectable, literal_binds=True),
)


@compiler.compiles(CreateView, 'mysql')
def compile_create_materialized_view_mysql(element, compiler, **kw):
"""
CREATE
[OR REPLACE]
[ALGORITHM = {UNDEFINED | MERGE | TEMPTABLE}]
[DEFINER = user]
[SQL SECURITY { DEFINER | INVOKER }]
VIEW view_name [(column_list)]
AS select_statement
[WITH [CASCADED | LOCAL] CHECK OPTION]

See https://dev.mysql.com/doc/refman/8.0/en/create-view.html
NOTE mysql does not support materialized view
"""
if element.materialized:
raise ValueError('mysql does not support materialized view!')
return 'CREATE {}VIEW {} AS {}'.format(
'OR REPLACE ' if element.or_replace else '',
compiler.dialect.identifier_preparer.quote(element.name),
compiler.sql_compiler.process(element.selectable, literal_binds=True),
)


@compiler.compiles(CreateView, 'mssql')
def compile_create_materialized_view_mssql(element, compiler, **kw):
"""
CREATE [ OR ALTER ] VIEW [ schema_name . ] view_name [ (column [ ,...n ] ) ]
[ WITH <view_attribute> [ ,...n ] ]
AS select_statement
[ WITH CHECK OPTION ]
[ ; ]

CREATE MATERIALIZED VIEW [ schema_name. ] materialized_view_name
WITH (
<distribution_option>
)
AS <select_statement>
[;]

see https://docs.microsoft.com/en-us/sql/t-sql/statements/create-view-transact-sql?view=sql-server-ver15
see https://docs.microsoft.com/en-us/sql/t-sql/statements/create-materialized-view-as-select-transact-sql?view=azure-sqldw-latest&viewFallbackFrom=sql-server-ver15
"""
return 'CREATE {}{}VIEW {} AS {}'.format(
'OR ALTER ' if not element.materialized and element.or_replace else '',
'MATERIALIZED ' if element.materialized else '',
compiler.dialect.identifier_preparer.quote(element.name),
compiler.sql_compiler.process(element.selectable, literal_binds=True),
)


@compiler.compiles(CreateView, 'snowflake')
def compile_create_materialized_view_snowflake(element, compiler, **kw):
"""
CREATE [ OR REPLACE ] [ SECURE ] [ RECURSIVE ] VIEW [ IF NOT EXISTS ] <name>
[ ( <column_list> ) ]
[ <col1> [ WITH ] MASKING POLICY <policy_name> [ USING ( <col1> , <cond_col1> , ... ) ]
[ WITH ] TAG ( <tag_name> = '<tag_value>' [ , <tag_name> = '<tag_value>' , ... ] ) ]
[ , <col2> [ ... ] ]
[ [ WITH ] ROW ACCESS POLICY <policy_name> ON ( <col_name> [ , <col_name> ... ] ) ]
[ WITH ] TAG ( <tag_name> = '<tag_value>' [ , <tag_name> = '<tag_value>' , ... ] ) ]
[ COPY GRANTS ]
[ COMMENT = '<string_literal>' ]
AS <select_statement>

CREATE [ OR REPLACE ] [ SECURE ] MATERIALIZED VIEW [ IF NOT EXISTS ] <name>
[ COPY GRANTS ]
( <column_list> )
[ <col1> [ WITH ] MASKING POLICY <policy_name> [ USING ( <col1> , <cond_col1> , ... ) ]
[ WITH ] TAG ( <tag_name> = '<tag_value>' [ , <tag_name> = '<tag_value>' , ... ] ) ]
[ , <col2> [ ... ] ]
[ [ WITH ] ROW ACCESS POLICY <policy_name> ON ( <col_name> [ , <col_name> ... ] ) ]
[ WITH ] TAG ( <tag_name> = '<tag_value>' [ , <tag_name> = '<tag_value>' , ... ] ) ]
[ COMMENT = '<string_literal>' ]
[ CLUSTER BY ( <expr1> [, <expr2> ... ] ) ]
AS <select_statement>

see https://docs.snowflake.com/en/sql-reference/sql/create-view.html
see https://docs.snowflake.com/en/sql-reference/sql/create-materialized-view.html
"""
return 'CREATE {}{}VIEW {}{} AS {}'.format(
'OR REPLACE ' if element.or_replace else '',
'MATERIALIZED ' if element.materialized else '',
'IF NOT EXISTS ' if element.if_not_exists else '',
compiler.dialect.identifier_preparer.quote(element.name),
compiler.sql_compiler.process(element.selectable, literal_binds=True),
)


class DropView(DDLElement):
def __init__(self, name, materialized=False, cascade=True):
self.name = name
Expand All @@ -33,17 +161,12 @@ def compile_drop_materialized_view(element, compiler, **kw):
return 'DROP {}VIEW IF EXISTS {} {}'.format(
'MATERIALIZED ' if element.materialized else '',
compiler.dialect.identifier_preparer.quote(element.name),
'CASCADE' if element.cascade else ''
'CASCADE' if element.cascade else '',
)


def create_table_from_selectable(
name,
selectable,
indexes=None,
metadata=None,
aliases=None,
**kwargs
name, selectable, indexes=None, metadata=None, aliases=None, **kwargs
):
if indexes is None:
indexes = []
Expand All @@ -53,10 +176,7 @@ def create_table_from_selectable(
aliases = {}
args = [
sa.Column(
c.name,
c.type,
key=aliases.get(c.name, c.name),
primary_key=c.primary_key
c.name, c.type, key=aliases.get(c.name, c.name), primary_key=c.primary_key
)
for c in get_columns(selectable)
] + indexes
Expand All @@ -74,9 +194,11 @@ def create_materialized_view(
selectable,
metadata,
indexes=None,
aliases=None
aliases=None,
if_not_exists=False,
or_replace=False,
):
""" Create a view on a given metadata
"""Create a view on a given metadata

:param name: The name of the view to create.
:param selectable: An SQLAlchemy selectable e.g. a select() statement.
Expand All @@ -87,6 +209,12 @@ def create_materialized_view(
:param aliases:
An optional dictionary containing with keys as column names and values
as column aliases.
:param if_not_exists:
An optional flag to indicate whether to use the
``CREATE MATERIALIZED VIEW IF NOT EXISTS`` statement.
:param or_replace:
An optional flag to indicate whether to use the
``CREATE OR REPLACE MATERIALIZED VIEW`` statement.

Same as for ``create_view`` except that a ``CREATE MATERIALIZED VIEW``
statement is emitted instead of a ``CREATE VIEW``.
Expand All @@ -97,41 +225,51 @@ def create_materialized_view(
selectable=selectable,
indexes=indexes,
metadata=None,
aliases=aliases
aliases=aliases,
)

sa.event.listen(
metadata,
'after_create',
CreateView(name, selectable, materialized=True)
CreateView(
name,
selectable,
materialized=True,
if_not_exists=if_not_exists,
or_replace=or_replace,
),
)

@sa.event.listens_for(metadata, 'after_create')
def create_indexes(target, connection, **kw):
for idx in table.indexes:
idx.create(connection)

sa.event.listen(
metadata,
'before_drop',
DropView(name, materialized=True)
)
sa.event.listen(metadata, 'before_drop', DropView(name, materialized=True))
return table


def create_view(
name,
selectable,
metadata,
cascade_on_drop=True
cascade_on_drop=True,
if_not_exists=False,
or_replace=False,
):
""" Create a view on a given metadata
"""Create a view on a given metadata

:param name: The name of the view to create.
:param selectable: An SQLAlchemy selectable e.g. a select() statement.
:param metadata:
An SQLAlchemy Metadata instance that stores the features of the
database being described.
:param if_not_exists:
An optional flag to indicate whether to use the
``CREATE VIEW IF NOT EXISTS`` statement.
:param or_replace:
An optional flag to indicate whether to use the
``CREATE OR REPLACE VIEW`` statement. (``OR ALTER`` will be used for mysql)

The process for creating a view is similar to the standard way that a
table is constructed, except that a selectable is provided instead of
Expand All @@ -156,28 +294,28 @@ def create_view(

"""
table = create_table_from_selectable(
name=name,
selectable=selectable,
metadata=None
name=name, selectable=selectable, metadata=None
)

sa.event.listen(metadata, 'after_create', CreateView(name, selectable))
sa.event.listen(
metadata,
'after_create',
CreateView(
name, selectable, if_not_exists=if_not_exists, or_replace=or_replace
),
)

@sa.event.listens_for(metadata, 'after_create')
def create_indexes(target, connection, **kw):
for idx in table.indexes:
idx.create(connection)

sa.event.listen(
metadata,
'before_drop',
DropView(name, cascade=cascade_on_drop)
)
sa.event.listen(metadata, 'before_drop', DropView(name, cascade=cascade_on_drop))
return table


def refresh_materialized_view(session, name, concurrently=False):
""" Refreshes an already existing materialized view
"""Refreshes an already existing materialized view

:param session: An SQLAlchemy Session instance.
:param name: The name of the materialized view to refresh.
Expand All @@ -191,6 +329,6 @@ def refresh_materialized_view(session, name, concurrently=False):
session.execute(
'REFRESH MATERIALIZED VIEW {}{}'.format(
'CONCURRENTLY ' if concurrently else '',
session.bind.engine.dialect.identifier_preparer.quote(name)
session.bind.engine.dialect.identifier_preparer.quote(name),
)
)
Loading