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

refactor(sqllab): migrate share queries via kv by permalink #29163

Open
wants to merge 2 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
20 changes: 20 additions & 0 deletions superset-frontend/src/SqlLab/actions/sqlLab.js
Original file line number Diff line number Diff line change
Expand Up @@ -1185,6 +1185,26 @@
};
}

export function popPermalink(key) {
return function (dispatch) {
return SupersetClient.get({ endpoint: `/api/v1/sqllab/permalink/${key}` })
.then(({ json }) =>
dispatch(
addQueryEditor({
name: json.name ? json.name : t('Shared query'),
dbId: json.dbId ? parseInt(json.dbId, 10) : null,
catalog: json.catalog ? json.catalog : null,
schema: json.schema ? json.schema : null,
autorun: json.autorun ? json.autorun : false,
sql: json.sql ? json.sql : 'SELECT ...',
templateParams: json.templateParams,
}),
),
)
.catch(() => dispatch(addDangerToast(ERR_MSG_CANT_LOAD_QUERY)));

Check warning on line 1204 in superset-frontend/src/SqlLab/actions/sqlLab.js

View check run for this annotation

Codecov / codecov/patch

superset-frontend/src/SqlLab/actions/sqlLab.js#L1204

Added line #L1204 was not covered by tests
};
}

export function popStoredQuery(urlId) {
return function (dispatch) {
return SupersetClient.get({ endpoint: `/kv/${urlId}` })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import fetchMock from 'fetch-mock';
import * as uiCore from '@superset-ui/core';
import { Provider } from 'react-redux';
import { supersetTheme, ThemeProvider } from '@superset-ui/core';
import { render, screen, act } from '@testing-library/react';
import { render, screen, act, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import userEvent from '@testing-library/user-event';
import * as utils from 'src/utils/common';
Expand Down Expand Up @@ -92,20 +92,24 @@ const standardProviderWithUnsaved: FC = ({ children }) => (
);

describe('ShareSqlLabQuery', () => {
const storeQueryUrl = 'glob:*/kv/store/';
const storeQueryMockId = '123';
const storeQueryUrl = 'glob:*/api/v1/sqllab/permalink';
const storeQueryMockId = 'ci39c3';

beforeEach(async () => {
fetchMock.post(storeQueryUrl, () => ({ id: storeQueryMockId }), {
overwriteRoutes: true,
});
fetchMock.post(
storeQueryUrl,
() => ({ key: storeQueryMockId, url: `/p/${storeQueryMockId}` }),
{
overwriteRoutes: true,
},
);
fetchMock.resetHistory();
jest.clearAllMocks();
});

afterAll(fetchMock.reset);

describe('via /kv/store', () => {
describe('via permalink api', () => {
beforeAll(() => {
isFeatureEnabledMock = jest
.spyOn(uiCore, 'isFeatureEnabled')
Expand All @@ -124,11 +128,13 @@ describe('ShareSqlLabQuery', () => {
});
const button = screen.getByRole('button');
const { id, remoteId, ...expected } = mockQueryEditor;
const storeQuerySpy = jest.spyOn(utils, 'storeQuery');
userEvent.click(button);
expect(storeQuerySpy.mock.calls).toHaveLength(1);
expect(storeQuerySpy).toBeCalledWith(expected);
storeQuerySpy.mockRestore();
await waitFor(() =>
expect(fetchMock.calls(storeQueryUrl)).toHaveLength(1),
);
expect(
JSON.parse(fetchMock.calls(storeQueryUrl)[0][1]?.body as string),
).toEqual(expected);
});

it('calls storeQuery() with unsaved changes', async () => {
Expand All @@ -139,11 +145,13 @@ describe('ShareSqlLabQuery', () => {
});
const button = screen.getByRole('button');
const { id, ...expected } = unsavedQueryEditor;
const storeQuerySpy = jest.spyOn(utils, 'storeQuery');
userEvent.click(button);
expect(storeQuerySpy.mock.calls).toHaveLength(1);
expect(storeQuerySpy).toBeCalledWith(expected);
storeQuerySpy.mockRestore();
await waitFor(() =>
expect(fetchMock.calls(storeQueryUrl)).toHaveLength(1),
);
expect(
JSON.parse(fetchMock.calls(storeQueryUrl)[0][1]?.body as string),
).toEqual(expected);
});
});

Expand Down
16 changes: 10 additions & 6 deletions superset-frontend/src/SqlLab/components/ShareSqlLabQuery/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ import {
useTheme,
isFeatureEnabled,
getClientErrorObject,
SupersetClient,
} from '@superset-ui/core';
import Button from 'src/components/Button';
import Icons from 'src/components/Icons';
import withToasts from 'src/components/MessageToasts/withToasts';
import CopyToClipboard from 'src/components/CopyToClipboard';
import { storeQuery } from 'src/utils/common';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';

interface ShareSqlLabQueryProps {
Expand Down Expand Up @@ -63,12 +63,16 @@ const ShareSqlLabQuery = ({
'templateParams',
]);

const getCopyUrlForKvStore = (callback: Function) => {
const getCopyUrlForPermalink = (callback: Function) => {
const sharedQuery = { dbId, name, schema, autorun, sql, templateParams };

return storeQuery(sharedQuery)
.then(shortUrl => {
callback(shortUrl);
return SupersetClient.post({
endpoint: '/api/v1/sqllab/permalink',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sharedQuery),
})
.then(({ json }) => {
callback(json.url);
})
.catch(response => {
getClientErrorObject(response).then(() => {
Expand All @@ -92,7 +96,7 @@ const ShareSqlLabQuery = ({
};
const getCopyUrl = (callback: Function) => {
if (isFeatureEnabled(FeatureFlag.ShareQueriesViaKvStore)) {
return getCopyUrlForKvStore(callback);
return getCopyUrlForPermalink(callback);
Comment on lines 98 to +99
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this feature be enabled by default? Now the permalink feature is only available if the SHARE_QUERIES_VIA_KV_STORE feature flag is enabled:

image

I feel we could now remove the feature flag, and make this the default behavior.

In addition, I think it would be a good idea to create the permalink based on the same query snippet that gets executed when running the query - Currently the entire editor gets persisted into the permalink, even if only a specific portion of the editor is selected.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel we could now remove the feature flag, and make this the default behavior.

@villebro as @michael-s-molina mentioned, we'll remove the feature flag during the 5.0 proposal work.

In addition, I think it would be a good idea to create the permalink based on the same query snippet that gets executed when running the query - Currently the entire editor gets persisted into the permalink, even if only a specific portion of the editor is selected.

Clipping only specific executed sections into a permalink might cause confusion for some users. Instead, I'm thinking about a method where, if an area is selected, it automatically highlights the selection in the editor.

}
return getCopyUrlForSavedQuery(callback);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,27 @@ describe('componentDidMount', () => {
'/sqllab',
);
});
test('should handle permalink', async () => {
const key = '9sadkfl';
fetchMock.get(`glob:*/api/v1/sqllab/permalink/${key}`, {
label: 'test permalink',
sql: 'SELECT * FROM test_table',
dbId: 1,
});
uriStub.mockReturnValue({ p: key });
setup(store);
await waitFor(() =>
expect(
fetchMock.calls(`glob:*/api/v1/sqllab/permalink/${key}`),
).toHaveLength(1),
);
expect(replaceState).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
'/sqllab',
);
fetchMock.reset();
});
test('should handle savedQueryId', () => {
uriStub.mockReturnValue({ savedQueryId: 1 });
setup(store);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class TabbedSqlEditors extends PureComponent<TabbedSqlEditorsProps> {
const {
id,
name,
p,
sql,
savedQueryId,
datasourceKey,
Expand All @@ -97,8 +98,10 @@ class TabbedSqlEditors extends PureComponent<TabbedSqlEditorsProps> {
} as Record<string, string>;

// Popping a new tab based on the querystring
if (id || sql || savedQueryId || datasourceKey || queryId) {
if (id) {
if (p || id || sql || savedQueryId || datasourceKey || queryId) {
if (p) {
this.props.actions.popPermalink(p);
} else if (id) {
this.props.actions.popStoredQuery(id);
} else if (savedQueryId) {
this.props.actions.popSavedQuery(savedQueryId);
Expand Down
16 changes: 16 additions & 0 deletions superset/commands/sql_lab/permalink/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# 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.
35 changes: 35 additions & 0 deletions superset/commands/sql_lab/permalink/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# 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 abc import ABC

from superset.commands.base import BaseCommand
from superset.key_value.shared_entries import get_permalink_salt
from superset.key_value.types import (
KeyValueResource,
MarshmallowKeyValueCodec,
SharedKey,
)
from superset.sqllab.permalink.schemas import SqlLabPermalinkSchema


class BaseSqlLabPermalinkCommand(BaseCommand, ABC):
resource: KeyValueResource = KeyValueResource.SQLLAB_PERMALINK
codec = MarshmallowKeyValueCodec(SqlLabPermalinkSchema())

@property
def salt(self) -> str:
return get_permalink_salt(SharedKey.SQLLAB_PERMALINK_SALT)
49 changes: 49 additions & 0 deletions superset/commands/sql_lab/permalink/create.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# 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.
import logging
from typing import Any

from superset.commands.key_value.create import CreateKeyValueCommand
from superset.commands.sql_lab.permalink.base import BaseSqlLabPermalinkCommand
from superset.key_value.exceptions import KeyValueCodecEncodeException
from superset.key_value.utils import encode_permalink_key
from superset.sqllab.permalink.exceptions import SqlLabPermalinkCreateFailedError

logger = logging.getLogger(__name__)


class CreateSqlLabPermalinkCommand(BaseSqlLabPermalinkCommand):
def __init__(self, state: dict[str, Any]):
self._properties = state.copy()

def run(self) -> str:
self.validate()
try:
command = CreateKeyValueCommand(
resource=self.resource,
value=self._properties,
codec=self.codec,
)
key = command.run()
if key.id is None:
raise SqlLabPermalinkCreateFailedError("Unexpected missing key id")

Check warning on line 43 in superset/commands/sql_lab/permalink/create.py

View check run for this annotation

Codecov / codecov/patch

superset/commands/sql_lab/permalink/create.py#L43

Added line #L43 was not covered by tests
return encode_permalink_key(key=key.id, salt=self.salt)
except KeyValueCodecEncodeException as ex:
raise SqlLabPermalinkCreateFailedError(str(ex)) from ex

Check warning on line 46 in superset/commands/sql_lab/permalink/create.py

View check run for this annotation

Codecov / codecov/patch

superset/commands/sql_lab/permalink/create.py#L45-L46

Added lines #L45 - L46 were not covered by tests

def validate(self) -> None:
pass
58 changes: 58 additions & 0 deletions superset/commands/sql_lab/permalink/get.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# 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.
import logging
from typing import Optional

from superset.commands.key_value.get import GetKeyValueCommand
from superset.commands.sql_lab.permalink.base import BaseSqlLabPermalinkCommand
from superset.key_value.exceptions import (
KeyValueCodecDecodeException,
KeyValueGetFailedError,
KeyValueParseKeyError,
)
from superset.key_value.utils import decode_permalink_id
from superset.sqllab.permalink.exceptions import SqlLabPermalinkGetFailedError
from superset.sqllab.permalink.types import SqlLabPermalinkValue

logger = logging.getLogger(__name__)


class GetSqlLabPermalinkCommand(BaseSqlLabPermalinkCommand):
def __init__(self, key: str):
self.key = key

def run(self) -> Optional[SqlLabPermalinkValue]:
self.validate()
try:
key = decode_permalink_id(self.key, salt=self.salt)
value: Optional[SqlLabPermalinkValue] = GetKeyValueCommand(
resource=self.resource,
key=key,
codec=self.codec,
).run()
if value:
return value
return None
except (

Check warning on line 50 in superset/commands/sql_lab/permalink/get.py

View check run for this annotation

Codecov / codecov/patch

superset/commands/sql_lab/permalink/get.py#L49-L50

Added lines #L49 - L50 were not covered by tests
KeyValueCodecDecodeException,
KeyValueGetFailedError,
KeyValueParseKeyError,
) as ex:
raise SqlLabPermalinkGetFailedError(message=ex.message) from ex

Check warning on line 55 in superset/commands/sql_lab/permalink/get.py

View check run for this annotation

Codecov / codecov/patch

superset/commands/sql_lab/permalink/get.py#L55

Added line #L55 was not covered by tests

def validate(self) -> None:
pass
2 changes: 2 additions & 0 deletions superset/initialization/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ def init_views(self) -> None:
from superset.row_level_security.api import RLSRestApi
from superset.security.api import SecurityRestApi
from superset.sqllab.api import SqlLabRestApi
from superset.sqllab.permalink.api import SqlLabPermalinkRestApi
from superset.tags.api import TagRestApi
from superset.views.alerts import AlertView, ReportView
from superset.views.all_entities import TaggedObjectsModelView
Expand Down Expand Up @@ -221,6 +222,7 @@ def init_views(self) -> None:
appbuilder.add_api(SavedQueryRestApi)
appbuilder.add_api(TagRestApi)
appbuilder.add_api(SqlLabRestApi)
appbuilder.add_api(SqlLabPermalinkRestApi)
#
# Setup regular views
#
Expand Down
2 changes: 2 additions & 0 deletions superset/key_value/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,13 @@ class KeyValueResource(StrEnum):
EXPLORE_PERMALINK = "explore_permalink"
METASTORE_CACHE = "superset_metastore_cache"
LOCK = "lock"
SQLLAB_PERMALINK = "sqllab_permalink"


class SharedKey(StrEnum):
DASHBOARD_PERMALINK_SALT = "dashboard_permalink_salt"
EXPLORE_PERMALINK_SALT = "explore_permalink_salt"
SQLLAB_PERMALINK_SALT = "sqllab_permalink_salt"


class KeyValueCodec(ABC):
Expand Down
Loading
Loading