From 231f19b55c6014b93968ccd5d7ab07403249d449 Mon Sep 17 00:00:00 2001 From: Omer Lachish Date: Sun, 12 Apr 2020 17:22:46 +0300 Subject: [PATCH 1/8] run queries through adhoc SSH tunnels --- redash/handlers/data_sources.py | 14 ++++-- redash/models/__init__.py | 12 ++++- redash/query_runner/__init__.py | 74 +++++++++++++++++++++++++++++ redash/query_runner/clickhouse.py | 25 ++++++++++ redash/settings/dynamic_settings.py | 12 +++++ redash/tasks/general.py | 11 +++++ requirements.txt | 1 + 7 files changed, 144 insertions(+), 5 deletions(-) diff --git a/redash/handlers/data_sources.py b/redash/handlers/data_sources.py index 94ad0abb11..94abe27041 100644 --- a/redash/handlers/data_sources.py +++ b/redash/handlers/data_sources.py @@ -1,4 +1,5 @@ import logging +import time from flask import make_response, request from flask_restful import abort @@ -20,6 +21,7 @@ ) from redash.utils import filter_none from redash.utils.configuration import ConfigurationContainer, ValidationError +from redash.tasks.general import test_connection class DataSourceTypeListResource(BaseResource): @@ -245,10 +247,14 @@ def post(self, data_source_id): ) response = {} - try: - data_source.query_runner.test_connection() - except Exception as e: - response = {"message": str(e), "ok": False} + + job = test_connection.delay(data_source.id) + while not (job.is_finished or job.is_failed): + time.sleep(1) + job.refresh() + + if isinstance(job.result, Exception): + response = {"message": str(job.result), "ok": False} else: response = {"message": "success", "ok": True} diff --git a/redash/models/__init__.py b/redash/models/__init__.py index 6304afc44d..1af9661771 100644 --- a/redash/models/__init__.py +++ b/redash/models/__init__.py @@ -24,6 +24,7 @@ ) from redash.metrics import database # noqa: F401 from redash.query_runner import ( + with_ssh_tunnel, get_configuration_schema_for_query_runner_type, get_query_runner, TYPE_BOOLEAN, @@ -251,9 +252,18 @@ def update_group_permission(self, group, view_only): db.session.add(dsg) return dsg + @property + def uses_ssh_tunnel(self): + return "ssh_tunnel" in self.options + @property def query_runner(self): - return get_query_runner(self.type, self.options) + query_runner = get_query_runner(self.type, self.options) + + if self.uses_ssh_tunnel: + query_runner = with_ssh_tunnel(query_runner, self.options.get("ssh_tunnel")) + + return query_runner @classmethod def get_by_name(cls, name): diff --git a/redash/query_runner/__init__.py b/redash/query_runner/__init__.py index 086a05f680..265227b188 100644 --- a/redash/query_runner/__init__.py +++ b/redash/query_runner/__init__.py @@ -1,8 +1,11 @@ import logging +from contextlib import ExitStack from dateutil import parser +from functools import wraps import requests +from sshtunnel import open_tunnel from redash import settings from redash.utils import json_loads from rq.timeouts import JobTimeoutException @@ -70,6 +73,34 @@ def type(cls): def enabled(cls): return True + @property + def host(self): + if "host" in self.configuration: + return self.configuration["host"] + else: + raise NotImplementedError() + + @host.setter + def host(self, host): + if "host" in self.configuration: + self.configuration["host"] = host + else: + raise NotImplementedError() + + @property + def port(self): + if "port" in self.configuration: + return self.configuration["port"] + else: + raise NotImplementedError() + + @port.setter + def port(self, port): + if "port" in self.configuration: + self.configuration["port"] = port + else: + raise NotImplementedError() + @classmethod def configuration_schema(cls): return {} @@ -303,3 +334,46 @@ def guess_type_from_string(string_value): pass return TYPE_STRING + + +def with_ssh_tunnel(query_runner, details): + def tunnel(f): + @wraps(f) + def wrapper(*args, **kwargs): + try: + remote_host, remote_port = query_runner.host, query_runner.port + except NotImplementedError: + raise NotImplementedError( + "SSH tunneling is not implemented for this query runner yet." + ) + + stack = ExitStack() + try: + bastion_address = (details["ssh_host"], details.get("ssh_port", 22)) + remote_address = (remote_host, remote_port) + auth = { + "ssh_username": details["ssh_username"], + **settings.dynamic_settings.ssh_tunnel_auth(), + } + server = stack.enter_context( + open_tunnel( + bastion_address, remote_bind_address=remote_address, **auth + ) + ) + except Exception as error: + raise type(error)("SSH tunnel: {}".format(str(error))) + else: + with stack: + try: + query_runner.host, query_runner.port = server.local_bind_address + result = f(*args, **kwargs) + finally: + query_runner.host, query_runner.port = remote_host, remote_port + + return result + + return wrapper + + query_runner.run_query = tunnel(query_runner.run_query) + + return query_runner \ No newline at end of file diff --git a/redash/query_runner/clickhouse.py b/redash/query_runner/clickhouse.py index b76f812b92..c2a1c6ebb5 100644 --- a/redash/query_runner/clickhouse.py +++ b/redash/query_runner/clickhouse.py @@ -1,5 +1,6 @@ import logging import re +from urllib.parse import urlparse import requests @@ -42,6 +43,30 @@ def configuration_schema(cls): def type(cls): return "clickhouse" + @property + def _url(self): + return urlparse(self.configuration["url"]) + + @_url.setter + def _url(self, url): + self.configuration["url"] = url.geturl() + + @property + def host(self): + return self._url.hostname + + @host.setter + def host(self, host): + self._url = self._url._replace(netloc="{}:{}".format(host, self._url.port)) + + @property + def port(self): + return self._url.port + + @port.setter + def port(self, port): + self._url = self._url._replace(netloc="{}:{}".format(self._url.hostname, port)) + def _get_tables(self, schema): query = "SELECT database, table, name FROM system.columns WHERE database NOT IN ('system')" diff --git a/redash/settings/dynamic_settings.py b/redash/settings/dynamic_settings.py index 33f3f40277..145308d356 100644 --- a/redash/settings/dynamic_settings.py +++ b/redash/settings/dynamic_settings.py @@ -25,3 +25,15 @@ def periodic_jobs(): # This provides the ability to override the way we store QueryResult's data column. # Reference implementation: redash.models.DBPersistence QueryResultPersistence = None + + +def ssh_tunnel_auth(): + """ + To enable data source connections via SSH tunnels, provide your SSH authentication + pkey here. Return a string pointing at your **private** key's path (which will be used + to extract the public key), or a `paramiko.pkey.PKey` instance holding your **public** key. + """ + return { + # 'ssh_pkey': 'path_to_private_key', # or instance of `paramiko.pkey.PKey` + # 'ssh_private_key_password': 'optional_passphrase_of_private_key', + } \ No newline at end of file diff --git a/redash/tasks/general.py b/redash/tasks/general.py index d120576a4f..b2349b311e 100644 --- a/redash/tasks/general.py +++ b/redash/tasks/general.py @@ -63,6 +63,17 @@ def send_mail(to, subject, html, text): logger.exception("Failed sending message: %s", message.subject) +@job("queries", timeout=30, ttl=90) +def test_connection(data_source_id): + try: + data_source = models.DataSource.get_by_id(data_source_id) + data_source.query_runner.test_connection() + except Exception as e: + return e + else: + return True + + def sync_user_details(): users.sync_last_active_at() diff --git a/requirements.txt b/requirements.txt index a5df9e404d..77ffbe2039 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,6 +55,7 @@ maxminddb-geolite2==2018.703 pypd==1.1.0 disposable-email-domains>=0.0.52 gevent==1.4.0 +sshtunnel==0.1.5 supervisor==4.1.0 supervisor_checks==0.8.1 werkzeug==0.16.1 From 93a974867e0edf18287136d9fac4755f18a6da8a Mon Sep 17 00:00:00 2001 From: Omer Lachish Date: Mon, 20 Apr 2020 22:53:05 +0300 Subject: [PATCH 2/8] reduce indent by losing try/else clause --- redash/query_runner/__init__.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/redash/query_runner/__init__.py b/redash/query_runner/__init__.py index 265227b188..1b2f03eef4 100644 --- a/redash/query_runner/__init__.py +++ b/redash/query_runner/__init__.py @@ -362,15 +362,15 @@ def wrapper(*args, **kwargs): ) except Exception as error: raise type(error)("SSH tunnel: {}".format(str(error))) - else: - with stack: - try: - query_runner.host, query_runner.port = server.local_bind_address - result = f(*args, **kwargs) - finally: - query_runner.host, query_runner.port = remote_host, remote_port - - return result + + with stack: + try: + query_runner.host, query_runner.port = server.local_bind_address + result = f(*args, **kwargs) + finally: + query_runner.host, query_runner.port = remote_host, remote_port + + return result return wrapper From 8da215b3190934d6cb587afaff58da05424dbc5a Mon Sep 17 00:00:00 2001 From: Omer Lachish Date: Mon, 20 Apr 2020 23:07:56 +0300 Subject: [PATCH 3/8] document host/port getters and setters --- redash/query_runner/__init__.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/redash/query_runner/__init__.py b/redash/query_runner/__init__.py index 1b2f03eef4..8235be5842 100644 --- a/redash/query_runner/__init__.py +++ b/redash/query_runner/__init__.py @@ -75,6 +75,12 @@ def enabled(cls): @property def host(self): + """Returns this query runner's configured host. + This is used primarily for temporarily swapping endpoints when using SSH tunnels to connect to a data source. + + `BaseQueryRunner`'s naïve implementation supports query runner implementations that store endpoints using `host` and `port` + configuration values. If your query runner uses a different schema (e.g. a web address), you should override this function. + """ if "host" in self.configuration: return self.configuration["host"] else: @@ -82,6 +88,12 @@ def host(self): @host.setter def host(self, host): + """Sets this query runner's configured host. + This is used primarily for temporarily swapping endpoints when using SSH tunnels to connect to a data source. + + `BaseQueryRunner`'s naïve implementation supports query runner implementations that store endpoints using `host` and `port` + configuration values. If your query runner uses a different schema (e.g. a web address), you should override this function. + """ if "host" in self.configuration: self.configuration["host"] = host else: @@ -89,6 +101,12 @@ def host(self, host): @property def port(self): + """Returns this query runner's configured port. + This is used primarily for temporarily swapping endpoints when using SSH tunnels to connect to a data source. + + `BaseQueryRunner`'s naïve implementation supports query runner implementations that store endpoints using `host` and `port` + configuration values. If your query runner uses a different schema (e.g. a web address), you should override this function. + """ if "port" in self.configuration: return self.configuration["port"] else: @@ -96,6 +114,12 @@ def port(self): @port.setter def port(self, port): + """Sets this query runner's configured port. + This is used primarily for temporarily swapping endpoints when using SSH tunnels to connect to a data source. + + `BaseQueryRunner`'s naïve implementation supports query runner implementations that store endpoints using `host` and `port` + configuration values. If your query runner uses a different schema (e.g. a web address), you should override this function. + """ if "port" in self.configuration: self.configuration["port"] = port else: @@ -158,7 +182,7 @@ def to_dict(cls): "name": cls.name(), "type": cls.type(), "configuration_schema": cls.configuration_schema(), - **({ "deprecated": True } if cls.deprecated else {}) + **({"deprecated": True} if cls.deprecated else {}), } @@ -376,4 +400,4 @@ def wrapper(*args, **kwargs): query_runner.run_query = tunnel(query_runner.run_query) - return query_runner \ No newline at end of file + return query_runner From 75a48b098082385ab5241b641239a115e19bed65 Mon Sep 17 00:00:00 2001 From: Omer Lachish Date: Thu, 30 Apr 2020 00:48:36 +0300 Subject: [PATCH 4/8] handle forceful schema refreshes in RQ and poll for their results using the /jobs endpoint --- .../queries/hooks/useDataSourceSchema.js | 31 +++++++++++++------ client/app/services/data-source.js | 2 -- redash/handlers/data_sources.py | 21 +++++-------- redash/serializers/__init__.py | 5 +-- redash/tasks/general.py | 13 ++++++++ 5 files changed, 45 insertions(+), 27 deletions(-) diff --git a/client/app/pages/queries/hooks/useDataSourceSchema.js b/client/app/pages/queries/hooks/useDataSourceSchema.js index 0e7588ab0e..32ff33756b 100644 --- a/client/app/pages/queries/hooks/useDataSourceSchema.js +++ b/client/app/pages/queries/hooks/useDataSourceSchema.js @@ -1,22 +1,35 @@ import { reduce } from "lodash"; import { useCallback, useEffect, useRef, useState, useMemo } from "react"; -import DataSource, { SCHEMA_NOT_SUPPORTED } from "@/services/data-source"; +import { axios } from "@/services/axios"; +import DataSource from "@/services/data-source"; import notification from "@/services/notification"; +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + function getSchema(dataSource, refresh = undefined) { if (!dataSource) { return Promise.resolve([]); } - return DataSource.fetchSchema(dataSource, refresh) - .then(data => { - if (data.schema) { - return data.schema; - } else if (data.error.code === SCHEMA_NOT_SUPPORTED) { - return []; - } - return Promise.reject(new Error("Schema refresh failed.")); + const fetchSchemaFromJob = (data) => { + return sleep(1000).then(() => { + return axios.get(`api/jobs/${data.job.id}`).then(data => { + if (data.job.status < 3) { + return fetchSchemaFromJob(data); + } + else if (data.job.status === 3) { + return data.job.result; + } else { + return Promise.reject(new Error(data.job.error)); + } + }) }) + }; + + return DataSource.fetchSchema(dataSource, refresh) + .then(fetchSchemaFromJob) .catch(() => { notification.error("Schema refresh failed.", "Please try again later."); return Promise.resolve([]); diff --git a/client/app/services/data-source.js b/client/app/services/data-source.js index c7de5d086d..4cebc1c159 100644 --- a/client/app/services/data-source.js +++ b/client/app/services/data-source.js @@ -1,7 +1,5 @@ import { axios } from "@/services/axios"; -export const SCHEMA_NOT_SUPPORTED = 1; -export const SCHEMA_LOAD_ERROR = 2; export const IMG_ROOT = "/static/images/db-logos"; const DataSource = { diff --git a/redash/handlers/data_sources.py b/redash/handlers/data_sources.py index 94abe27041..b60c204b25 100644 --- a/redash/handlers/data_sources.py +++ b/redash/handlers/data_sources.py @@ -21,13 +21,16 @@ ) from redash.utils import filter_none from redash.utils.configuration import ConfigurationContainer, ValidationError -from redash.tasks.general import test_connection +from redash.tasks.general import test_connection, get_schema +from redash.serializers import serialize_job class DataSourceTypeListResource(BaseResource): @require_admin def get(self): - return [q.to_dict() for q in sorted(query_runners.values(), key=lambda q: q.name())] + return [ + q.to_dict() for q in sorted(query_runners.values(), key=lambda q: q.name()) + ] class DataSourceResource(BaseResource): @@ -184,19 +187,9 @@ def get(self, data_source_id): require_access(data_source, self.current_user, view_only) refresh = request.args.get("refresh") is not None - response = {} + job = get_schema.delay(data_source.id, refresh) - try: - response["schema"] = data_source.get_schema(refresh) - except NotSupported: - response["error"] = { - "code": 1, - "message": "Data source type does not support retrieving schema", - } - except Exception: - response["error"] = {"code": 2, "message": "Error retrieving schema."} - - return response + return serialize_job(job) class DataSourcePauseResource(BaseResource): diff --git a/redash/serializers/__init__.py b/redash/serializers/__init__.py index 992d7d6b56..3a6d8b889c 100644 --- a/redash/serializers/__init__.py +++ b/redash/serializers/__init__.py @@ -284,7 +284,7 @@ def serialize_job(job): updated_at = 0 status = STATUSES[job_status] - query_result_id = None + result = query_result_id = None if job.is_cancelled: error = "Query cancelled by user." @@ -294,7 +294,7 @@ def serialize_job(job): status = 4 else: error = "" - query_result_id = job.result + result = query_result_id = job.result return { "job": { @@ -302,6 +302,7 @@ def serialize_job(job): "updated_at": updated_at, "status": status, "error": error, + "result": result, "query_result_id": query_result_id, } } diff --git a/redash/tasks/general.py b/redash/tasks/general.py index b2349b311e..cbaf3aa0ee 100644 --- a/redash/tasks/general.py +++ b/redash/tasks/general.py @@ -9,6 +9,8 @@ from redash.models import users from redash.version_check import run_version_check from redash.worker import job, get_job_logger +from redash.tasks.worker import Queue +from redash.query_runner import NotSupported logger = get_job_logger(__name__) @@ -74,6 +76,17 @@ def test_connection(data_source_id): return True +@job("schemas", queue_class=Queue, at_front=True, timeout=30, ttl=90) +def get_schema(data_source_id, refresh): + try: + data_source = models.DataSource.get_by_id(data_source_id) + return data_source.query_runner.get_schema(refresh) + except NotSupported: + return [] + except Exception as e: + return e + + def sync_user_details(): users.sync_last_active_at() From 7f3b458a61d28079770a97bd2f5749c569efcfb9 Mon Sep 17 00:00:00 2001 From: Omer Lachish Date: Thu, 30 Apr 2020 20:43:33 +0300 Subject: [PATCH 5/8] set schema refresh timeout to 5 minutes --- redash/tasks/general.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redash/tasks/general.py b/redash/tasks/general.py index cbaf3aa0ee..377ad03b1a 100644 --- a/redash/tasks/general.py +++ b/redash/tasks/general.py @@ -76,7 +76,7 @@ def test_connection(data_source_id): return True -@job("schemas", queue_class=Queue, at_front=True, timeout=30, ttl=90) +@job("schemas", queue_class=Queue, at_front=True, timeout=300, ttl=90) def get_schema(data_source_id, refresh): try: data_source = models.DataSource.get_by_id(data_source_id) From 9029e6e332df54c3b0c100eff9a086fe157f7a8c Mon Sep 17 00:00:00 2001 From: "restyled-io[bot]" <32688539+restyled-io[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2020 21:07:32 +0300 Subject: [PATCH 6/8] Restyled by prettier (#4847) Co-authored-by: Restyled.io --- .../pages/queries/hooks/useDataSourceSchema.js | 17 +++++++---------- client/app/services/data-source.js | 6 +++--- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/client/app/pages/queries/hooks/useDataSourceSchema.js b/client/app/pages/queries/hooks/useDataSourceSchema.js index 32ff33756b..ca665a2dd6 100644 --- a/client/app/pages/queries/hooks/useDataSourceSchema.js +++ b/client/app/pages/queries/hooks/useDataSourceSchema.js @@ -5,7 +5,7 @@ import DataSource from "@/services/data-source"; import notification from "@/services/notification"; function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } function getSchema(dataSource, refresh = undefined) { @@ -15,17 +15,16 @@ function getSchema(dataSource, refresh = undefined) { const fetchSchemaFromJob = (data) => { return sleep(1000).then(() => { - return axios.get(`api/jobs/${data.job.id}`).then(data => { + return axios.get(`api/jobs/${data.job.id}`).then((data) => { if (data.job.status < 3) { return fetchSchemaFromJob(data); - } - else if (data.job.status === 3) { + } else if (data.job.status === 3) { return data.job.result; } else { return Promise.reject(new Error(data.job.error)); } - }) - }) + }); + }); }; return DataSource.fetchSchema(dataSource, refresh) @@ -47,11 +46,9 @@ export default function useDataSourceSchema(dataSource) { const reloadSchema = useCallback( (refresh = undefined) => { - const refreshToken = Math.random() - .toString(36) - .substr(2); + const refreshToken = Math.random().toString(36).substr(2); refreshSchemaTokenRef.current = refreshToken; - getSchema(dataSource, refresh).then(data => { + getSchema(dataSource, refresh).then((data) => { if (refreshSchemaTokenRef.current === refreshToken) { setSchema(prepareSchema(data)); } diff --git a/client/app/services/data-source.js b/client/app/services/data-source.js index 4cebc1c159..14e7655a20 100644 --- a/client/app/services/data-source.js +++ b/client/app/services/data-source.js @@ -6,9 +6,9 @@ const DataSource = { query: () => axios.get("api/data_sources"), get: ({ id }) => axios.get(`api/data_sources/${id}`), types: () => axios.get("api/data_sources/types"), - create: data => axios.post(`api/data_sources`, data), - save: data => axios.post(`api/data_sources/${data.id}`, data), - test: data => axios.post(`api/data_sources/${data.id}/test`), + create: (data) => axios.post(`api/data_sources`, data), + save: (data) => axios.post(`api/data_sources/${data.id}`, data), + test: (data) => axios.post(`api/data_sources/${data.id}/test`), delete: ({ id }) => axios.delete(`api/data_sources/${id}`), fetchSchema: (data, refresh = false) => { const params = {}; From bc69c032ab9b8536d8503e29b5b356d6f991c070 Mon Sep 17 00:00:00 2001 From: Omer Lachish Date: Thu, 30 Apr 2020 23:58:26 +0300 Subject: [PATCH 7/8] send schema refresh errors as part of API response --- client/app/pages/queries/hooks/useDataSourceSchema.js | 4 +++- client/app/services/data-source.js | 2 ++ redash/serializers/__init__.py | 3 +++ redash/tasks/general.py | 11 ++++++++--- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/client/app/pages/queries/hooks/useDataSourceSchema.js b/client/app/pages/queries/hooks/useDataSourceSchema.js index ca665a2dd6..2c1b0ea4d4 100644 --- a/client/app/pages/queries/hooks/useDataSourceSchema.js +++ b/client/app/pages/queries/hooks/useDataSourceSchema.js @@ -1,7 +1,7 @@ import { reduce } from "lodash"; import { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { axios } from "@/services/axios"; -import DataSource from "@/services/data-source"; +import DataSource, { SCHEMA_NOT_SUPPORTED } from "@/services/data-source"; import notification from "@/services/notification"; function sleep(ms) { @@ -20,6 +20,8 @@ function getSchema(dataSource, refresh = undefined) { return fetchSchemaFromJob(data); } else if (data.job.status === 3) { return data.job.result; + } else if (data.job.status === 4 && data.job.error.code === SCHEMA_NOT_SUPPORTED) { + return []; } else { return Promise.reject(new Error(data.job.error)); } diff --git a/client/app/services/data-source.js b/client/app/services/data-source.js index 14e7655a20..80497baf13 100644 --- a/client/app/services/data-source.js +++ b/client/app/services/data-source.js @@ -1,5 +1,7 @@ import { axios } from "@/services/axios"; +export const SCHEMA_NOT_SUPPORTED = 1; +export const SCHEMA_LOAD_ERROR = 2; export const IMG_ROOT = "/static/images/db-logos"; const DataSource = { diff --git a/redash/serializers/__init__.py b/redash/serializers/__init__.py index 3a6d8b889c..b0782ac319 100644 --- a/redash/serializers/__init__.py +++ b/redash/serializers/__init__.py @@ -292,6 +292,9 @@ def serialize_job(job): elif isinstance(job.result, Exception): error = str(job.result) status = 4 + elif isinstance(job.result, dict) and "error" in job.result: + error = job.result["error"] + status = 4 else: error = "" result = query_result_id = job.result diff --git a/redash/tasks/general.py b/redash/tasks/general.py index 377ad03b1a..ec91c065a9 100644 --- a/redash/tasks/general.py +++ b/redash/tasks/general.py @@ -82,9 +82,14 @@ def get_schema(data_source_id, refresh): data_source = models.DataSource.get_by_id(data_source_id) return data_source.query_runner.get_schema(refresh) except NotSupported: - return [] - except Exception as e: - return e + return { + "error": { + "code": 1, + "message": "Data source type does not support retrieving schema", + } + } + except Exception: + return {"error": {"code": 2, "message": "Error retrieving schema."}} def sync_user_details(): From 6a26d78b59473ab61ead83a773ec170914e89ce5 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Mon, 11 May 2020 12:12:20 +0300 Subject: [PATCH 8/8] Use correct get_schema call. --- redash/tasks/general.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redash/tasks/general.py b/redash/tasks/general.py index ec91c065a9..7b2c7287dd 100644 --- a/redash/tasks/general.py +++ b/redash/tasks/general.py @@ -80,7 +80,7 @@ def test_connection(data_source_id): def get_schema(data_source_id, refresh): try: data_source = models.DataSource.get_by_id(data_source_id) - return data_source.query_runner.get_schema(refresh) + return data_source.get_schema(refresh) except NotSupported: return { "error": {