Skip to content

Commit

Permalink
feat: refactor ddl for create_database and add create_schema where re…
Browse files Browse the repository at this point in the history
…levant
  • Loading branch information
cpcloud authored and gforsyth committed Aug 1, 2023
1 parent ed40fdb commit d7a857c
Show file tree
Hide file tree
Showing 19 changed files with 460 additions and 91 deletions.
1 change: 1 addition & 0 deletions docker-compose.yml
Expand Up @@ -119,6 +119,7 @@ services:
- mysql
volumes:
- mysql:/data
- $PWD/docker/mysql:/docker-entrypoint-initdb.d:ro
postgres:
user: postgres
environment:
Expand Down
3 changes: 3 additions & 0 deletions docker/mysql/startup.sql
@@ -0,0 +1,3 @@
CREATE USER 'ibis'@'localhost' IDENTIFIED BY 'ibis';
GRANT CREATE, DROP ON *.* TO 'ibis'@'%';
FLUSH PRIVILEGES;
99 changes: 66 additions & 33 deletions ibis/backends/base/__init__.py
Expand Up @@ -519,6 +519,72 @@ def to_delta(
write_deltalake(path, batch_reader, **kwargs)


class CanCreateDatabase(abc.ABC):
@abc.abstractmethod
def create_database(self, name: str, force: bool = False) -> None:
"""Create a new database.
Parameters
----------
name
Name of the new database.
force
If `False`, an exception is raised if the database already exists.
"""

@abc.abstractmethod
def drop_database(self, name: str, force: bool = False) -> None:
"""Drop a database with name `name`."""

@abc.abstractmethod
def list_databases(self, like: str | None = None) -> list[str]:
"""List existing databases in the current connection.
Parameters
----------
like
A pattern in Python's regex format to filter returned database
names.
Returns
-------
list[str]
The database names that exist in the current connection, that match
the `like` pattern if provided.
"""


class CanCreateSchema(abc.ABC):
@abc.abstractmethod
def create_schema(
self, name: str, database: str | None = None, force: bool = False
) -> None:
"""Create a schema named `name` in `database`."""

@abc.abstractmethod
def drop_schema(
self, name: str, database: str | None = None, force: bool = False
) -> None:
"""Drop the schema with `name` in `database`."""

@abc.abstractmethod
def list_schemas(self, like: str | None = None) -> list[str]:
"""List existing databases in the current connection.
Parameters
----------
like
A pattern in Python's regex format to filter returned schema
names.
Returns
-------
list[str]
The schema names that exist in the current connection, that match
the `like` pattern if provided.
"""


class BaseBackend(abc.ABC, _FileIOHandler):
"""Base backend class.
Expand Down Expand Up @@ -649,23 +715,6 @@ def current_database(self) -> str | None:
Name of the current database.
"""

@abc.abstractmethod
def list_databases(self, like: str | None = None) -> list[str]:
"""List existing databases in the current connection.
Parameters
----------
like
A pattern in Python's regex format to filter returned database
names.
Returns
-------
list[str]
The database names that exist in the current connection, that match
the `like` pattern if provided.
"""

@staticmethod
def _filter_with_like(
values: Iterable[str],
Expand Down Expand Up @@ -837,22 +886,6 @@ def decorator(translation_function: Callable) -> None:

return decorator

def create_database(self, name: str, force: bool = False) -> None:
"""Create a new database.
Not all backends implement this method.
Parameters
----------
name
Name of the new database.
force
If `False`, an exception is raised if the database already exists.
"""
raise NotImplementedError(
f'Backend "{self.name}" does not implement "create_database"'
)

@abc.abstractmethod
def create_table(
self,
Expand Down
7 changes: 7 additions & 0 deletions ibis/backends/base/sql/alchemy/__init__.py
Expand Up @@ -20,6 +20,7 @@
import ibis.expr.schema as sch
import ibis.expr.types as ir
from ibis import util
from ibis.backends.base import CanCreateSchema
from ibis.backends.base.sql import BaseSQLBackend
from ibis.backends.base.sql.alchemy.geospatial import geospatial_supported
from ibis.backends.base.sql.alchemy.query_builder import AlchemyCompiler
Expand Down Expand Up @@ -94,6 +95,12 @@ def _create_table_as(element, compiler, **kw):
return stmt + f"TABLE {name} AS {compiler.process(element.query, **kw)}"


class AlchemyCanCreateSchema(CanCreateSchema):
def list_schemas(self, like: str | None = None) -> list[str]:
"""Return a list of all schemas matching `like`."""
return self._filter_with_like(self.inspector.get_schema_names(), like)


class BaseAlchemyBackend(BaseSQLBackend):
"""Backend class for backends that compile to SQLAlchemy expressions."""

Expand Down
51 changes: 48 additions & 3 deletions ibis/backends/bigquery/__init__.py
Expand Up @@ -17,7 +17,7 @@
import ibis.common.exceptions as com
import ibis.expr.operations as ops
import ibis.expr.types as ir
from ibis.backends.base import Database
from ibis.backends.base import CanCreateSchema, Database
from ibis.backends.base.sql import BaseSQLBackend
from ibis.backends.bigquery.client import (
BigQueryCursor,
Expand Down Expand Up @@ -71,7 +71,7 @@ def _create_client_info_gapic(application_name):
return ClientInfo(user_agent=_create_user_agent(application_name))


class Backend(BaseSQLBackend):
class Backend(BaseSQLBackend, CanCreateSchema):
name = "bigquery"
compiler = BigQueryCompiler
supports_in_memory_tables = False
Expand Down Expand Up @@ -231,6 +231,45 @@ def project_id(self):
def dataset_id(self):
return self.dataset

def create_schema(
self,
name: str,
database: str | None = None,
force: bool = False,
collate: str | None = None,
**options: Any,
) -> None:
create_stmt = "CREATE SCHEMA"
if force:
create_stmt += " IF NOT EXISTS"

create_stmt += " "
create_stmt += ".".join(filter(None, [database, name]))

if collate is not None:
create_stmt += f" DEFAULT COLLATION {collate}"

options_str = ", ".join(f"{name}={value!r}" for name, value in options.items())
if options_str:
create_stmt += f" OPTIONS({options_str})"
self.raw_sql(create_stmt)

def drop_schema(
self,
name: str,
database: str | None = None,
force: bool = False,
cascade: bool = False,
) -> None:
drop_stmt = "DROP SCHEMA"
if force:
drop_stmt += " IF EXISTS"

drop_stmt += " "
drop_stmt += ".".join(filter(None, [database, name]))
drop_stmt += " CASCADE" if cascade else " RESTRICT"
self.raw_sql(drop_stmt)

def table(self, name: str, database: str | None = None) -> ir.TableExpr:
if database is None:
database = f"{self.data_project}.{self.current_database}"
Expand Down Expand Up @@ -409,13 +448,19 @@ def get_schema(self, name, database=None):
table = self.client.get_table(table_ref)
return schema_from_bigquery_table(table)

def list_databases(self, like=None):
def list_schemas(self, like=None):
results = [
dataset.dataset_id
for dataset in self.client.list_datasets(project=self.data_project)
]
return self._filter_with_like(results, like)

@ibis.util.deprecated(
instead="use `list_schemas()`", as_of="6.1.0", removed_in="8.0.0"
)
def list_databases(self, like=None):
return self.list_schemas(like=like)

def list_tables(self, like=None, database=None):
project, dataset = self._parse_project_and_dataset(database)
dataset_ref = bq.DatasetReference(project, dataset)
Expand Down
7 changes: 4 additions & 3 deletions ibis/backends/clickhouse/__init__.py
Expand Up @@ -21,10 +21,9 @@
import ibis.expr.schema as sch
import ibis.expr.types as ir
from ibis import util
from ibis.backends.base import BaseBackend
from ibis.backends.base import BaseBackend, CanCreateDatabase
from ibis.backends.clickhouse.compiler import translate
from ibis.backends.clickhouse.datatypes import parse, serialize
from ibis.formats.pandas import PandasData

if TYPE_CHECKING:
import pandas as pd
Expand Down Expand Up @@ -58,7 +57,7 @@ def insert(self, obj, settings: Mapping[str, Any] | None = None, **kwargs):
return self._client.con.insert_df(self.name, obj, settings=settings, **kwargs)


class Backend(BaseBackend):
class Backend(BaseBackend, CanCreateDatabase):
name = "clickhouse"

# ClickHouse itself does, but the client driver does not
Expand Down Expand Up @@ -466,6 +465,8 @@ def raw_sql(
def fetch_from_cursor(self, cursor, schema):
import pandas as pd

from ibis.formats.pandas import PandasData

df = pd.DataFrame.from_records(iter(cursor), columns=schema.names)
return PandasData.convert_table(df, schema)

Expand Down
46 changes: 33 additions & 13 deletions ibis/backends/conftest.py
Expand Up @@ -18,8 +18,9 @@
from packaging.version import parse as vparse

import ibis
import ibis.common.exceptions as com
from ibis import util
from ibis.backends.base import _get_backend_names
from ibis.backends.base import CanCreateDatabase, CanCreateSchema, _get_backend_names
from ibis.conftest import WINDOWS

TEST_TABLES = {
Expand Down Expand Up @@ -453,13 +454,36 @@ def con(backend):
return backend.connection


@pytest.fixture(scope='session')
def con_create_database(con):
if isinstance(con, CanCreateDatabase):
return con
else:
pytest.skip(f"{con.name} backend cannot create databases")


@pytest.fixture(scope='session')
def con_create_schema(con):
if isinstance(con, CanCreateSchema):
return con
else:
pytest.skip(f"{con.name} backend cannot create schemas")


@pytest.fixture(scope='session')
def con_create_database_schema(con):
if isinstance(con, CanCreateDatabase) and isinstance(con, CanCreateSchema):
return con
else:
pytest.skip(f"{con.name} backend cannot create both database and schemas")


def _setup_backend(request, data_dir, tmp_path_factory, worker_id):
if (backend := request.param) == "duckdb" and WINDOWS:
pytest.xfail(
"windows prevents two connections to the same duckdb file "
"even in the same process"
)
return None
else:
cls = _get_backend_conf(backend)
return cls.load_data(data_dir, tmp_path_factory, worker_id)
Expand Down Expand Up @@ -672,12 +696,6 @@ def temp_view(ddl_con) -> str:
ddl_con.drop_view(name, force=True)


@pytest.fixture(scope='session')
def current_data_db(ddl_con) -> str:
"""Return current database name."""
return ddl_con.current_database


@pytest.fixture
def alternate_current_database(ddl_con, ddl_backend) -> str:
"""Create a temporary database and yield its name. Drops the created
Expand All @@ -686,18 +704,20 @@ def alternate_current_database(ddl_con, ddl_backend) -> str:
Parameters
----------
ddl_con : ibis.backends.base.Client
current_data_db : str
Yields
-------
------
str
"""
name = util.gen_name('database')
try:
ddl_con.create_database(name)
except NotImplementedError:
pytest.skip(f"{ddl_backend.name()} doesn't have create_database method.")
except AttributeError:
pytest.skip(f"{ddl_backend.name()} doesn't have a `create_database` method.")
yield name
ddl_con.drop_database(name, force=True)

with contextlib.suppress(com.UnsupportedOperationError):
ddl_con.drop_database(name, force=True)


@pytest.fixture
Expand Down

0 comments on commit d7a857c

Please sign in to comment.