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

[database] new, API table metadata #9002

Merged
merged 14 commits into from
Jan 29, 2020
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ describe('async actions', () => {
fetchMock.delete(updateTableSchemaEndpoint, {});
fetchMock.post(updateTableSchemaEndpoint, JSON.stringify({ id: 1 }));

const getTableMetadataEndpoint = 'glob:*/superset/table/*';
const getTableMetadataEndpoint = 'glob:*/api/v1/database/*';
fetchMock.get(getTableMetadataEndpoint, {});
const getExtraTableMetadataEndpoint =
'glob:*/superset/extra_table_metadata/*';
Expand Down
2 changes: 1 addition & 1 deletion superset/assets/src/SqlLab/actions/sqlLab.js
Original file line number Diff line number Diff line change
Expand Up @@ -934,7 +934,7 @@ export function mergeTable(table, query) {
function getTableMetadata(table, query, dispatch) {
return SupersetClient.get({
endpoint: encodeURI(
`/superset/table/${query.dbId}/` +
`/api/v1/database/${query.dbId}/table/` +
`${encodeURIComponent(table.name)}/${encodeURIComponent(
table.schema,
)}/`,
Expand Down
64 changes: 0 additions & 64 deletions superset/views/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1957,70 +1957,6 @@ def sqllab_viz(self):
db.session.commit()
return json_success(json.dumps({"table_id": table.id}))

@has_access
@expose("/table/<database_id>/<table_name>/<schema>/")
@event_logger.log_this
def table(self, database_id, table_name, schema):
schema = utils.parse_js_uri_path_item(schema, eval_undefined=True)
table_name = utils.parse_js_uri_path_item(table_name)
mydb = db.session.query(models.Database).filter_by(id=database_id).one()
payload_columns = []
indexes = []
primary_key = []
foreign_keys = []
try:
columns = mydb.get_columns(table_name, schema)
indexes = mydb.get_indexes(table_name, schema)
primary_key = mydb.get_pk_constraint(table_name, schema)
foreign_keys = mydb.get_foreign_keys(table_name, schema)
except Exception as e:
return json_error_response(utils.error_msg_from_exception(e))
keys = []
if primary_key and primary_key.get("constrained_columns"):
primary_key["column_names"] = primary_key.pop("constrained_columns")
primary_key["type"] = "pk"
keys += [primary_key]
for fk in foreign_keys:
fk["column_names"] = fk.pop("constrained_columns")
fk["type"] = "fk"
keys += foreign_keys
for idx in indexes:
idx["type"] = "index"
keys += indexes

for col in columns:
dtype = ""
try:
dtype = "{}".format(col["type"])
except Exception:
# sqla.types.JSON __str__ has a bug, so using __class__.
dtype = col["type"].__class__.__name__
pass
payload_columns.append(
{
"name": col["name"],
"type": dtype.split("(")[0] if "(" in dtype else dtype,
"longType": dtype,
"keys": [k for k in keys if col["name"] in k.get("column_names")],
}
)
tbl = {
"name": table_name,
"columns": payload_columns,
"selectStar": mydb.select_star(
table_name,
schema=schema,
show_cols=True,
indent=True,
cols=columns,
latest_partition=True,
),
"primaryKey": primary_key,
"foreignKeys": foreign_keys,
"indexes": keys,
}
return json_success(json.dumps(tbl))

@has_access
@expose("/extra_table_metadata/<database_id>/<table_name>/<schema>/")
@event_logger.log_this
Expand Down
237 changes: 232 additions & 5 deletions superset/views/database/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,107 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from typing import Any, Dict, List, Optional

from flask_appbuilder.api import expose, protect, safe
from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_babel import lazy_gettext as _
from sqlalchemy.exc import SQLAlchemyError

import superset.models.core as models
from superset import event_logger
from superset.models.core import Database
from superset.utils.core import error_msg_from_exception, parse_js_uri_path_item
from superset.views.base_api import BaseSupersetModelRestApi
from superset.views.database.filters import DatabaseFilter
from superset.views.database.mixins import DatabaseMixin
from superset.views.database.validators import sqlalchemy_uri_validator


def get_foreign_keys_metadata(
database: Database, table_name: str, schema_name: Optional[str]
) -> List[Dict[str, Any]]:
foreign_keys = database.get_foreign_keys(table_name, schema_name)
for fk in foreign_keys:
fk["column_names"] = fk.pop("constrained_columns")
fk["type"] = "fk"
return foreign_keys


def get_indexes_metadata(
database: Database, table_name: str, schema_name: Optional[str]
) -> List[Dict[str, Any]]:
indexes = database.get_indexes(table_name, schema_name)
for idx in indexes:
idx["type"] = "index"
return indexes


from .mixins import DatabaseFilter, DatabaseMixin
from .validators import sqlalchemy_uri_validator
def get_col_type(col: Dict) -> str:
try:
dtype = f"{col['type']}"
except Exception: # pylint: disable=broad-except
# sqla.types.JSON __str__ has a bug, so using __class__.
dtype = col["type"].__class__.__name__
return dtype


def get_table_metadata(
database: Database, table_name: str, schema_name: Optional[str]
) -> Dict:
"""
Get table metadata information, including type, pk, fks.
This function raises SQLAlchemyError when a schema is not found.


:param database: The database model
:param table_name: Table name
:param schema_name: schema name
:return: Dict table metadata ready for API response
"""
keys: List = []
columns = database.get_columns(table_name, schema_name)
primary_key = database.get_pk_constraint(table_name, schema_name)
if primary_key and primary_key.get("constrained_columns"):
primary_key["column_names"] = primary_key.pop("constrained_columns")
primary_key["type"] = "pk"
keys += [primary_key]
foreign_keys = get_foreign_keys_metadata(database, table_name, schema_name)
indexes = get_indexes_metadata(database, table_name, schema_name)
keys += foreign_keys + indexes
payload_columns: List[Dict] = []
for col in columns:
dtype = get_col_type(col)
payload_columns.append(
{
"name": col["name"],
"type": dtype.split("(")[0] if "(" in dtype else dtype,
"longType": dtype,
"keys": [k for k in keys if col["name"] in k.get("column_names")],
}
)
return {
"name": table_name,
"columns": payload_columns,
"selectStar": database.select_star(
table_name,
schema=schema_name,
show_cols=True,
indent=True,
cols=columns,
latest_partition=True,
),
"primaryKey": primary_key,
"foreignKeys": foreign_keys,
"indexes": keys,
}


class DatabaseRestApi(DatabaseMixin, BaseSupersetModelRestApi):
datamodel = SQLAInterface(models.Database)
include_route_methods = {"get_list"}
datamodel = SQLAInterface(Database)

include_route_methods = {"get_list", "table_metadata"}
class_permission_name = "DatabaseView"
method_permission_name = {"get_list": "list", "table_metadata": "list"}
resource_name = "database"
allow_browser_login = True
base_filters = [["id", DatabaseFilter, lambda: []]]
Expand All @@ -51,3 +138,143 @@ class DatabaseRestApi(DatabaseMixin, BaseSupersetModelRestApi):
# Removes the local limit for the page size
max_page_size = -1
validators_columns = {"sqlalchemy_uri": sqlalchemy_uri_validator}

@expose(
"/<int:pk>/table/<string:table_name>/<string:schema_name>/", methods=["GET"]
)
@protect()
@safe
@event_logger.log_this
def table_metadata(
self, pk: int, table_name: str, schema_name: str
): # pylint: disable=invalid-name
""" Table schema info
---
get:
description: Get database table metadata
parameters:
- in: path
schema:
type: integer
name: pk
description: The database id
- in: path
schema:
type: string
name: table_name
description: Table name
- in: path
schema:
type: string
name: schema
description: Table schema
responses:
200:
description: Table schema info
content:
text/plain:
schema:
type: object
properties:
columns:
type: array
description: Table columns info
items:
type: object
properties:
keys:
type: array
items:
type: string
longType:
type: string
name:
type: string
type:
type: string
foreignKeys:
type: array
description: Table list of foreign keys
items:
type: object
properties:
column_names:
type: array
items:
type: string
name:
type: string
options:
type: object
referred_columns:
type: array
items:
type: string
referred_schema:
type: string
referred_table:
type: string
type:
type: string
indexes:
type: array
description: Table list of indexes
items:
type: object
properties:
column_names:
type: array
items:
type: string
name:
type: string
options:
type: object
referred_columns:
type: array
items:
type: string
referred_schema:
type: string
referred_table:
type: string
type:
type: string
primaryKey:
type: object
properties:
column_names:
type: array
items:
type: string
name:
type: string
type:
type: string
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
"""
table_name_parsed = parse_js_uri_path_item(table_name)
schema_parsed = parse_js_uri_path_item(schema_name, eval_undefined=True)
# schemas can be None but not tables
if not table_name_parsed:
return self.response_422(message=_(f"Could not parse table name or schema"))
database: Database = self.datamodel.get(pk, self._base_filters)
if not database:
return self.response_404()

try:
table_info: Dict = get_table_metadata(
database, table_name_parsed, schema_parsed
)
except SQLAlchemyError as e:
return self.response_422(error_msg_from_exception(e))
return self.response(200, **table_info)
45 changes: 45 additions & 0 deletions superset/views/database/filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from sqlalchemy import or_

from superset import security_manager
from superset.views.base import BaseFilter


class DatabaseFilter(BaseFilter):
# TODO(bogdan): consider caching.
def schema_access_databases(self): # noqa pylint: disable=no-self-use
found_databases = set()
for vm in security_manager.user_view_menu_names("schema_access"):
database_name, _ = security_manager.unpack_schema_perm(vm)
found_databases.add(database_name)
return found_databases

def apply(
self, query, func
): # noqa pylint: disable=unused-argument,arguments-differ
if security_manager.all_database_access():
return query
database_perms = security_manager.user_view_menu_names("database_access")
# TODO(bogdan): consider adding datasource access here as well.
schema_access_databases = self.schema_access_databases()
return query.filter(
or_(
self.model.perm.in_(database_perms),
self.model.database_name.in_(schema_access_databases),
)
)