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

Add feature flags to control query sharing, KV exposure #9120

Merged
merged 11 commits into from
Feb 19, 2020
141 changes: 103 additions & 38 deletions superset-frontend/spec/javascripts/sqllab/ShareSqlLabQuery_spec.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { OverlayTrigger } from 'react-bootstrap';
import fetchMock from 'fetch-mock';
import * as featureFlags from 'src/featureFlags';
import { shallow } from 'enzyme';

import * as utils from '../../../src/utils/common';
Expand All @@ -29,8 +30,9 @@ import ShareSqlLabQuery from '../../../src/SqlLab/components/ShareSqlLabQuery';

const mockStore = configureStore([thunk]);
const store = mockStore();
let isFeatureEnabledMock;

describe('ShareSqlLabQuery', () => {
describe('ShareSqlLabQuery via /kv/store', () => {
const storeQueryUrl = 'glob:*/kv/store/';
const storeQueryMockId = '123';

Expand All @@ -50,9 +52,18 @@ describe('ShareSqlLabQuery', () => {
schema: 'query_schema',
autorun: false,
sql: 'SELECT * FROM ...',
remoteId: 999,
},
};

const storedQueryAttributes = {
dbId: 0,
title: 'query title',
schema: 'query_schema',
autorun: false,
sql: 'SELECT * FROM ...',
};

function setup(overrideProps) {
const wrapper = shallow(
<ShareSqlLabQuery {...defaultProps} {...overrideProps} />,
Expand All @@ -64,51 +75,105 @@ describe('ShareSqlLabQuery', () => {
return wrapper;
}

it('renders an OverlayTrigger with Button', () => {
const wrapper = setup();
const trigger = wrapper.find(OverlayTrigger);
const button = trigger.find(Button);

expect(trigger).toHaveLength(1);
expect(button).toHaveLength(1);
});
describe('via /kv/store', () => {
beforeAll(() => {
isFeatureEnabledMock = jest
.spyOn(featureFlags, 'isFeatureEnabled')
.mockImplementation(feature => true);
});

it('calls storeQuery() with the query when getCopyUrl() is called and saves the url', () => {
expect.assertions(4);
const storeQuerySpy = jest.spyOn(utils, 'storeQuery');
it('renders an OverlayTrigger with Button', () => {
const wrapper = setup();
const trigger = wrapper.find(OverlayTrigger);
const button = trigger.find(Button);

const wrapper = setup();
const instance = wrapper.instance();
expect(trigger).toHaveLength(1);
expect(button).toHaveLength(1);
});

return instance.getCopyUrl().then(() => {
expect(storeQuerySpy.mock.calls).toHaveLength(1);
expect(fetchMock.calls(storeQueryUrl)).toHaveLength(1);
expect(storeQuerySpy.mock.calls[0][0]).toMatchObject(
defaultProps.queryEditor,
);
expect(instance.state.shortUrl).toContain(storeQueryMockId);
it('calls storeQuery() with the query when getCopyUrl() is called and saves the url', () => {
expect.assertions(4);
const storeQuerySpy = jest.spyOn(utils, 'storeQuery');

return Promise.resolve();
});
});
const wrapper = setup();
const instance = wrapper.instance();

it('dispatches an error toast upon fetching failure', () => {
expect.assertions(3);
const error = 'error';
const addDangerToastSpy = jest.fn();
fetchMock.post(storeQueryUrl, { throws: error }, { overwriteRoutes: true });
const wrapper = setup();
wrapper.setProps({ addDangerToast: addDangerToastSpy });

return wrapper
.instance()
.getCopyUrl()
.then(() => {
return instance.getCopyUrl().then(() => {
expect(storeQuerySpy.mock.calls).toHaveLength(1);
expect(fetchMock.calls(storeQueryUrl)).toHaveLength(1);
expect(addDangerToastSpy.mock.calls).toHaveLength(1);
expect(addDangerToastSpy.mock.calls[0][0]).toBe(error);
expect(storeQuerySpy.mock.calls[0][0]).toMatchObject(
storedQueryAttributes,
);
expect(instance.state.shortUrl).toContain(storeQueryMockId);

storeQuerySpy.mockRestore();

return Promise.resolve();
});
});

it('dispatches an error toast upon fetching failure', () => {
expect.assertions(3);
const error = 'error';
const addDangerToastSpy = jest.fn();
fetchMock.post(
storeQueryUrl,
{ throws: error },
{ overwriteRoutes: true },
);
const wrapper = setup();
wrapper.setProps({ addDangerToast: addDangerToastSpy });

return wrapper
.instance()
.getCopyUrl()
.then(() => {
expect(fetchMock.calls(storeQueryUrl)).toHaveLength(1);
expect(addDangerToastSpy.mock.calls).toHaveLength(1);
expect(addDangerToastSpy.mock.calls[0][0]).toBe(error);

return Promise.resolve();
});
});
})
describe('via saved query', () => {
beforeAll(() => {
isFeatureEnabledMock = jest
.spyOn(featureFlags, 'isFeatureEnabled')
.mockImplementation(feature => false);
});

it('renders an OverlayTrigger with Button', () => {
const wrapper = setup();
const trigger = wrapper.find(OverlayTrigger);
const button = trigger.find(Button);

expect(trigger).toHaveLength(1);
expect(button).toHaveLength(1);
});

it('does not call storeQuery() with the query when getCopyUrl() is called', () => {
const storeQuerySpy = jest.spyOn(utils, 'storeQuery');

const wrapper = setup();
const instance = wrapper.instance();

instance.getCopyUrl();

expect(storeQuerySpy.mock.calls).toHaveLength(0);
expect(fetchMock.calls(storeQueryUrl)).toHaveLength(0);
expect(instance.state.shortUrl).toContain(999);

storeQuerySpy.mockRestore();
});

it('shows a request to save the query when the query is not yet saved', () => {
const wrapper = setup({ remoteId: undefined });
const instance = wrapper.instance();

instance.getCopyUrl();

expect(instance.state.shortUrl).toContain('save')
});
});
});
27 changes: 27 additions & 0 deletions superset-frontend/src/SqlLab/components/ShareSqlLabQuery.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import CopyToClipboard from '../../components/CopyToClipboard';
import { storeQuery } from '../../utils/common';
import getClientErrorObject from '../../utils/getClientErrorObject';
import withToasts from '../../messageToasts/enhancers/withToasts';
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';

const propTypes = {
queryEditor: PropTypes.shape({
Expand All @@ -34,6 +35,7 @@ const propTypes = {
schema: PropTypes.string,
autorun: PropTypes.bool,
sql: PropTypes.string,
remoteId: PropTypes.number,
}).isRequired,
addDangerToast: PropTypes.func.isRequired,
};
Expand All @@ -45,9 +47,19 @@ class ShareSqlLabQuery extends React.Component {
shortUrl: t('Loading ...'),
};
this.getCopyUrl = this.getCopyUrl.bind(this);
this.getCopyUrlForSavedQuery = this.getCopyUrlForSavedQuery.bind(this);
this.getCopyUrlForKvStore = this.getCopyUrlForKvStore.bind(this);
Copy link
Member

@suddjian suddjian Feb 11, 2020

Choose a reason for hiding this comment

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

.bind(this) is only necessary when you are going to be passing this.getCopyUrlForKvStore around as a separate variable. If it's only called via this.getCopyUrlForKvStore() then this will be bound correctly and these lines are unnecessary.

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'll try removing them - I had to add them at one point to get things functioning.

}

getCopyUrl() {
if (isFeatureEnabled(FeatureFlag.SHARE_QUERIES_VIA_KV_STORE)) {
return this.getCopyUrlForKvStore();
}

this.getCopyUrlForSavedQuery();
}

getCopyUrlForKvStore() {
const { dbId, title, schema, autorun, sql } = this.props.queryEditor;
const sharedQuery = { dbId, title, schema, autorun, sql };

Expand All @@ -63,6 +75,21 @@ class ShareSqlLabQuery extends React.Component {
});
}

getCopyUrlForSavedQuery() {
let savedQueryToastContent;

if (this.props.queryEditor.remoteId) {
savedQueryToastContent =
window.location.origin +
window.location.pathname +
`?savedQueryId=${this.props.queryEditor.remoteId}`;
this.setState({ shortUrl: savedQueryToastContent });
} else {
savedQueryToastContent = t('Please save the query to enable sharing');
this.setState({ shortUrl: savedQueryToastContent });
}
}

renderPopover() {
return (
<Popover id="sqllab-shareurl-popover">
Expand Down
1 change: 1 addition & 0 deletions superset-frontend/src/featureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export enum FeatureFlag {
SCHEDULED_QUERIES = 'SCHEDULED_QUERIES',
SQL_VALIDATORS_BY_ENGINE = 'SQL_VALIDATORS_BY_ENGINE',
ESTIMATE_QUERY_COST = 'ESTIMATE_QUERY_COST',
SHARE_QUERIES_VIA_KV_STORE = 'SHARE_QUERIES_VIA_KV_STORE',
SQLLAB_BACKEND_PERSISTENCE = 'SQLLAB_BACKEND_PERSISTENCE',
}

Expand Down
5 changes: 4 additions & 1 deletion superset/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,10 @@ def init_views(self) -> None:
appbuilder.add_view_no_menu(Dashboard)
appbuilder.add_view_no_menu(DashboardModelViewAsync)
appbuilder.add_view_no_menu(Datasource)
appbuilder.add_view_no_menu(KV)

if feature_flag_manager.is_feature_enabled("KV_STORE"):
appbuilder.add_view_no_menu(KV)

appbuilder.add_view_no_menu(R)
appbuilder.add_view_no_menu(SavedQueryView)
appbuilder.add_view_no_menu(SavedQueryViewApi)
Expand Down
2 changes: 2 additions & 0 deletions superset/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,9 @@ def _try_json_readsha(filepath, length): # pylint: disable=unused-argument
# Experimental feature introducing a client (browser) cache
"CLIENT_CACHE": False,
"ENABLE_EXPLORE_JSON_CSRF_PROTECTION": False,
"KV_STORE": False,
"PRESTO_EXPAND_DATA": False,
"SHARE_QUERIES_VIA_KV_STORE": False,
"TAGGING_SYSTEM": False,
}

Expand Down
14 changes: 12 additions & 2 deletions tests/core_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,20 @@
import string
from typing import Any, Dict
import unittest
from unittest import mock
from unittest import mock, skipUnless

import pandas as pd
import sqlalchemy as sqla

from tests.test_app import app
from superset import dataframe, db, jinja_context, security_manager, sql_lab
from superset import (
dataframe,
db,
jinja_context,
security_manager,
sql_lab,
is_feature_enabled,
)
from superset.common.query_context import QueryContext
from superset.connectors.connector_registry import ConnectorRegistry
from superset.connectors.sqla.models import SqlaTable
Expand Down Expand Up @@ -497,6 +504,9 @@ def test_shortner(self):
resp = self.client.post("/r/shortner/", data=dict(data=data))
assert re.search(r"\/r\/[0-9]+", resp.data.decode("utf-8"))

@skipUnless(
Copy link
Member

Choose a reason for hiding this comment

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

These should always be run in our test environment whether the flag is enabled or disabled. Can you set the feature flag to on before running them?

Copy link
Member Author

Choose a reason for hiding this comment

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

@etr2460 I spent an hour banging my head against it, and was unable to find an approach to modify feature flags after they were initialized without breaking the whole suite. I'm very open to recommendations.

Copy link
Member

Choose a reason for hiding this comment

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

Could enabling this feature on just for the tests be a viable approach? superset_test_config.py

Copy link
Member

Choose a reason for hiding this comment

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

In this case it would be, since the feature flag only removes functionality and doesn't add anything different. that said, I think we should be able to test both sides of the feature flag here, do you know how to do this @dpgaspar ?

Maybe mocking the feature flag checking function?

Copy link
Member Author

Choose a reason for hiding this comment

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

The issue in this case is that the application is booted once for the test suite and the feature flag changes the nature of the instantiation. This is a boot-time flag, not a run-time check. For this PR I'll go ahead and enable it for tests.

(is_feature_enabled("KV_STORE")), "skipping as /kv/ endpoints are not enabled"
)
def test_kv(self):
self.login(username="admin")

Expand Down