diff --git a/README.md b/README.md index 6c3dff4579d816e6ca3c12071988c2bd5f01c904..98437a7abbf4fa5609eaa0552d874e3acbd85d7e 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ For more information check out [metabase.com](http://www.metabase.com) - CrateDB - Oracle - Vertica +- Presto Don't see your favorite database? File an issue to let us know. diff --git a/bin/ci b/bin/ci index 461fdffa284967ba745f08edadf10ba88f525c38..4869c1cd4ae31e520fe065a3278870a386de2183 100755 --- a/bin/ci +++ b/bin/ci @@ -19,11 +19,15 @@ node-1() { run_step lein-test } node-2() { - is_enabled "drivers" && export ENGINES="h2,postgres,sqlite" || export ENGINES="h2" + is_enabled "drivers" && export ENGINES="h2,postgres,sqlite,presto" || export ENGINES="h2" if is_engine_enabled "crate"; then run_step install-crate fi + if is_engine_enabled "presto"; then + run_step install-presto + fi MB_ENCRYPTION_SECRET_KEY='Orw0AAyzkO/kPTLJRxiyKoBHXa/d6ZcO+p+gpZO/wSQ=' MB_DB_TYPE=mysql MB_DB_DBNAME=circle_test MB_DB_PORT=3306 MB_DB_USER=ubuntu MB_DB_HOST=localhost \ + MB_PRESTO_HOST=localhost MB_PRESTO_PORT=8080 \ run_step lein-test } node-3() { @@ -91,6 +95,11 @@ install-vertica() { sleep 60 } +install-presto() { + docker run --detach --publish 8080:8080 wiill/presto-mb-ci + sleep 10 +} + lein-test() { lein test } diff --git a/frontend/src/metabase/admin/databases/components/CreatedDatabaseModal.jsx b/frontend/src/metabase/admin/databases/components/CreatedDatabaseModal.jsx index 29c31dbd99275f2afc8c53df334af36ec1f78098..eb6788801c35d82b27302f4e64664f894a7ce076 100644 --- a/frontend/src/metabase/admin/databases/components/CreatedDatabaseModal.jsx +++ b/frontend/src/metabase/admin/databases/components/CreatedDatabaseModal.jsx @@ -3,6 +3,8 @@ import { Link } from "react-router"; import ModalContent from "metabase/components/ModalContent.jsx"; +import * as Urls from "metabase/lib/urls"; + export default class CreatedDatabaseModal extends Component { static propTypes = { databaseId: PropTypes.number.isRequired, @@ -22,7 +24,7 @@ export default class CreatedDatabaseModal extends Component { We're analyzing its schema now to make some educated guesses about its metadata. <Link to={"/admin/datamodel/database/"+databaseId}>View this database</Link> in the Data Model section to see what we've found and to - make edits, or <Link to={"/q#?db="+databaseId}>ask a question</Link> about + make edits, or <Link to={Urls.question(null, `?db=${databaseId}`)}>ask a question</Link> about this database. </p> </div> diff --git a/frontend/src/metabase/admin/datamodel/components/PartialQueryBuilder.jsx b/frontend/src/metabase/admin/datamodel/components/PartialQueryBuilder.jsx index 133fc5c27f05bcdf1b76aa478e0e8056e9cb8067..ea5abefc8c5f745bece440f429f9836f0d7dfd12 100644 --- a/frontend/src/metabase/admin/datamodel/components/PartialQueryBuilder.jsx +++ b/frontend/src/metabase/admin/datamodel/components/PartialQueryBuilder.jsx @@ -2,7 +2,7 @@ import React, { Component, PropTypes } from "react"; import GuiQueryEditor from "metabase/query_builder/components/GuiQueryEditor.jsx"; -import { serializeCardForUrl } from "metabase/lib/card"; +import * as Urls from "metabase/lib/urls"; import cx from "classnames"; @@ -56,7 +56,7 @@ export default class PartialQueryBuilder extends Component { } } }; - let previewUrl = "/q#" + serializeCardForUrl(previewCard); + let previewUrl = Urls.question(null, previewCard); const onChange = (query) => { this.props.onChange(query); diff --git a/frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx b/frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx index 109b043fa9f1bfe3e9a8295e6c5472340a6a0366..c2719ee5c9d4c6434c437efc1c6ff541036bc904 100644 --- a/frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx +++ b/frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx @@ -9,7 +9,7 @@ import Confirm from "metabase/components/Confirm"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; import { CardApi, DashboardApi } from "metabase/services"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import MetabaseAnalytics from "metabase/lib/analytics"; @@ -161,7 +161,7 @@ export const PublicLinksQuestionListing = () => load={CardApi.listPublic} revoke={CardApi.deletePublicLink} type='Public Card Listing' - getUrl={({ id }) => Urls.card(id)} + getUrl={({ id }) => Urls.question(id)} getPublicUrl={({ public_uuid }) => window.location.origin + Urls.publicCard(public_uuid)} noLinksMessage="No questions have been publicly shared yet." />; @@ -177,7 +177,7 @@ export const EmbeddedDashboardListing = () => export const EmbeddedQuestionListing = () => <PublicLinksListing load={CardApi.listEmbeddable} - getUrl={({ id }) => Urls.card(id)} + getUrl={({ id }) => Urls.question(id)} type='Embedded Card Listing' noLinksMessage="No questions have been embedded yet." />; diff --git a/frontend/src/metabase/components/AddToDashSelectDashModal.jsx b/frontend/src/metabase/components/AddToDashSelectDashModal.jsx index 408cb5777523ee39bdb95e3a7bce27f553055a8e..0492804ed33e38179f64bd9d66b10f5a31fceb19 100644 --- a/frontend/src/metabase/components/AddToDashSelectDashModal.jsx +++ b/frontend/src/metabase/components/AddToDashSelectDashModal.jsx @@ -5,7 +5,7 @@ import Icon from 'metabase/components/Icon.jsx'; import ModalContent from "metabase/components/ModalContent.jsx"; import SortableItemList from 'metabase/components/SortableItemList.jsx'; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import { DashboardApi } from "metabase/services"; import moment from 'moment'; diff --git a/frontend/src/metabase/components/NotFound.jsx b/frontend/src/metabase/components/NotFound.jsx index 0eb16a19945a2dbe89ac36d3a778b913b0820d96..cdf98c4afcac3c1fccdfc438d34ca0e947f6344b 100644 --- a/frontend/src/metabase/components/NotFound.jsx +++ b/frontend/src/metabase/components/NotFound.jsx @@ -1,6 +1,8 @@ import React, { Component, PropTypes } from "react"; import { Link } from "react-router"; +import * as Urls from "metabase/lib/urls"; + export default class NotFound extends Component { render() { return ( @@ -11,7 +13,7 @@ export default class NotFound extends Component { <p className="h4">You might've been tricked by a ninja, but in all likelihood, you were just given a bad link.</p> <p className="h4 my4">You can always:</p> <div className="flex align-center"> - <Link to="/q" className="Button Button--primary"> + <Link to={Urls.question()} className="Button Button--primary"> <div className="p1">Ask a new question.</div> </Link> <span className="mx2">or</span> diff --git a/frontend/src/metabase/dashboard/containers/DashboardEmbedWidget.jsx b/frontend/src/metabase/dashboard/containers/DashboardEmbedWidget.jsx index 6ff0deeb50520db60ecf107be57925c318123142..50ca40dee81a890a314713e9f0adac57686d7c9a 100644 --- a/frontend/src/metabase/dashboard/containers/DashboardEmbedWidget.jsx +++ b/frontend/src/metabase/dashboard/containers/DashboardEmbedWidget.jsx @@ -5,7 +5,7 @@ import { connect } from "react-redux"; import EmbedWidget from "metabase/public/components/widgets/EmbedWidget"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import { createPublicLink, deletePublicLink, updateEnableEmbedding, updateEmbeddingParams } from "../dashboard"; diff --git a/frontend/src/metabase/home/components/Activity.jsx b/frontend/src/metabase/home/components/Activity.jsx index 69dcce269099d519495e83ac6a2ff91787aca92f..bc8d65ca2c60aab05fb1102ef878b4661f4c9731 100644 --- a/frontend/src/metabase/home/components/Activity.jsx +++ b/frontend/src/metabase/home/components/Activity.jsx @@ -6,7 +6,7 @@ import LoadingAndErrorWrapper from 'metabase/components/LoadingAndErrorWrapper.j import ActivityItem from './ActivityItem.jsx'; import ActivityStory from './ActivityStory.jsx'; -import Urls from 'metabase/lib/urls'; +import * as Urls from "metabase/lib/urls"; export default class Activity extends Component { @@ -213,7 +213,7 @@ export default class Activity extends Component { case "dashboard-remove-cards": description.body = item.details.dashcards[0].name; if (item.details.dashcards[0].exists) { - description.bodyLink = Urls.card(item.details.dashcards[0].card_id); + description.bodyLink = Urls.question(item.details.dashcards[0].card_id); } break; case "metric-create": diff --git a/frontend/src/metabase/home/components/NewUserOnboardingModal.jsx b/frontend/src/metabase/home/components/NewUserOnboardingModal.jsx index 1de3e68b33880d05c0de16500768e7c36579329b..d58b1ff6912110f687a4df04804bae7e4fc7f8a5 100644 --- a/frontend/src/metabase/home/components/NewUserOnboardingModal.jsx +++ b/frontend/src/metabase/home/components/NewUserOnboardingModal.jsx @@ -2,6 +2,7 @@ import React, { Component, PropTypes } from "react"; import { Link } from "react-router"; import MetabaseSettings from "metabase/lib/settings"; +import * as Urls from "metabase/lib/urls"; export default class NewUserOnboardingModal extends Component { constructor(props, context) { @@ -84,7 +85,7 @@ export default class NewUserOnboardingModal extends Component { {this.renderStep()} <span className="flex-align-right"> <a className="text-underline-hover cursor-pointer mr3" onClick={() => (this.closeModal())}>skip for now</a> - <Link to="/q#?tutorial" className="Button Button--primary">Let's do it!</Link> + <Link to={Urls.question(null, "?tutorial")} className="Button Button--primary">Let's do it!</Link> </span> </div> </div> diff --git a/frontend/src/metabase/home/components/RecentViews.jsx b/frontend/src/metabase/home/components/RecentViews.jsx index f1146966a807b999db69613662d91df7f7d40d4f..9a967584956eed30780e4128ede71e705e7f7b1e 100644 --- a/frontend/src/metabase/home/components/RecentViews.jsx +++ b/frontend/src/metabase/home/components/RecentViews.jsx @@ -3,7 +3,7 @@ import { Link } from "react-router"; import Icon from "metabase/components/Icon.jsx"; import SidebarSection from "./SidebarSection.jsx"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import { normal } from "metabase/lib/colors"; diff --git a/frontend/src/metabase/lib/card.js b/frontend/src/metabase/lib/card.js index b53d09d1732ae751d2dcddc8f34f73d8986ac573..2b0ac9090b755dc86b2a981f6fdd1cb00de4faa3 100644 --- a/frontend/src/metabase/lib/card.js +++ b/frontend/src/metabase/lib/card.js @@ -1,6 +1,7 @@ import _ from "underscore"; import Query, { createQuery } from "metabase/lib/query"; import Utils from "metabase/lib/utils"; +import * as Urls from "metabase/lib/urls"; import { CardApi } from "metabase/services"; @@ -24,10 +25,10 @@ export function startNewCard(type, databaseId, tableId) { } // load a card either by ID or from a base64 serialization. if both are present then they are merged, which the serialized version taking precedence +// TODO: move to redux export async function loadCard(cardId) { try { - let card = await CardApi.get({ "cardId": cardId }); - return card && cleanCopyCard(card); + return await CardApi.get({ "cardId": cardId }); } catch (error) { console.log("error loading card", error); throw error; @@ -111,16 +112,10 @@ export function b64url_to_utf8(b64url) { } export function urlForCardState(state, dirty) { - var url; - if (state.cardId) { - url = "/card/" + state.cardId; - } else { - url = "/q"; - } - if (state.serializedCard && dirty) { - url += "#" + state.serializedCard; - } - return url; + return Urls.question( + state.cardId, + (state.serializedCard && dirty) ? state.serializedCard : "" + ); } export function cleanCopyCard(card) { diff --git a/frontend/src/metabase/lib/dataset.js b/frontend/src/metabase/lib/dataset.js new file mode 100644 index 0000000000000000000000000000000000000000..c5e56bf62088ae03f3cf17a4f883669ad49a9fef --- /dev/null +++ b/frontend/src/metabase/lib/dataset.js @@ -0,0 +1,4 @@ +import _ from "underscore"; + +// Many aggregations result in [[null]] if there are no rows to aggregate after filters +export const datasetContainsNoResults = (data) => data.rows.length === 0 || _.isEqual(data.rows, [[null]]) diff --git a/frontend/src/metabase/lib/urls.js b/frontend/src/metabase/lib/urls.js index ed11450f3b0fd4c68e1de077a1e29dc25ee491ea..7c2eba73166aeef3535ac9f4ee9e9f3b995fba8d 100644 --- a/frontend/src/metabase/lib/urls.js +++ b/frontend/src/metabase/lib/urls.js @@ -1,66 +1,71 @@ import { serializeCardForUrl } from "metabase/lib/card"; // provides functions for building urls to things we care about -var Urls = { - q: function(card) { - return "/q#" + serializeCardForUrl(card); - }, - card: function(card_id) { - // NOTE that this is for an ephemeral card link, not an editable card - return "/card/"+card_id; - }, - - dashboard: function(dashboard_id) { - return "/dash/"+dashboard_id; - }, +export function question(cardId, cardOrHash = "") { + if (cardOrHash && typeof cardOrHash === "object") { + cardOrHash = serializeCardForUrl(cardOrHash); + } + if (cardOrHash && cardOrHash.charAt(0) !== "#") { + cardOrHash = "#" + cardOrHash; + } + // NOTE that this is for an ephemeral card link, not an editable card + return cardId != null + ? `/question/${cardId}${cardOrHash}` + : `/question${cardOrHash}`; +} - modelToUrl: function(model, model_id) { - switch (model) { - case "card": return Urls.card(model_id); - case "dashboard": return Urls.dashboard(model_id); - case "pulse": return Urls.pulse(model_id); - default: return null; - } - }, +export function dashboard(dashboardId) { + return `/dashboard/${dashboardId}`; +} - pulse: function(pulse_id) { - return "/pulse/#"+pulse_id; - }, +export function modelToUrl(model, modelId) { + switch (model) { + case "card": + return question(modelId); + case "dashboard": + return dashboard(modelId); + case "pulse": + return pulse(modelId); + default: + return null; + } +} - tableRowsQuery: function(database_id, table_id, metric_id, segment_id) { - let url = "/q#?db="+database_id+"&table="+table_id; +export function pulse(pulseId) { + return `/pulse/#${pulseId}`; +} - if (metric_id) { - url = url + "&metric="+metric_id; - } +export function tableRowsQuery(databaseId, tableId, metricId, segmentId) { + let query = `?db=${databaseId}&table=${tableId}`; - if (segment_id) { - url = url + "&segment="+segment_id; - } + if (metricId) { + query += `&metric=${metricId}`; + } - return url; - }, + if (segmentId) { + query += `&segment=${segmentId}`; + } - collection(collection) { - return `/questions/collections/${encodeURIComponent(collection.slug)}`; - }, + return question(null, query); +} - label(label) { - return `/questions/search?label=${encodeURIComponent(label.slug)}`; - }, +export function collection(collection) { + return `/questions/collections/${encodeURIComponent(collection.slug)}`; +} - publicCard(uuid, type = null) { - return `/public/question/${uuid}` + (type ? `.${type}` : ``); - }, +export function label(label) { + return `/questions/search?label=${encodeURIComponent(label.slug)}`; +} - publicDashboard(uuid) { - return `/public/dashboard/${uuid}`; - }, +export function publicCard(uuid, type = null) { + return `/public/question/${uuid}` + (type ? `.${type}` : ``); +} - embedCard(token, type = null) { - return `/embed/question/${token}` + (type ? `.${type}` : ``); - }, +export function publicDashboard(uuid) { + return `/public/dashboard/${uuid}`; } -export default Urls; +export function embedCard(token, type = null) { + return `/embed/question/${token}` + (type ? `.${type}` : ``); +} diff --git a/frontend/src/metabase/nav/containers/DashboardsDropdown.jsx b/frontend/src/metabase/nav/containers/DashboardsDropdown.jsx index b902a25096e2c25ff4cdac9d6854c6b69643a88c..425c4d220862d0d318126d3c532ed7560f8ca26f 100644 --- a/frontend/src/metabase/nav/containers/DashboardsDropdown.jsx +++ b/frontend/src/metabase/nav/containers/DashboardsDropdown.jsx @@ -3,12 +3,13 @@ import { connect } from "react-redux"; import { Link } from "react-router"; import OnClickOutsideWrapper from 'metabase/components/OnClickOutsideWrapper.jsx'; - -import MetabaseAnalytics from "metabase/lib/analytics"; import CreateDashboardModal from "metabase/components/CreateDashboardModal.jsx"; import Modal from "metabase/components/Modal.jsx"; import ConstrainToScreen from "metabase/components/ConstrainToScreen"; +import MetabaseAnalytics from "metabase/lib/analytics"; +import * as Urls from "metabase/lib/urls"; + import _ from "underscore"; import cx from "classnames"; @@ -62,7 +63,7 @@ export default class DashboardsDropdown extends Component { try { let action = await createDashboard(newDashboard, true); // FIXME: this doesn't feel right... - this.props.onChangeLocation(`/dash/${action.payload.id}`); + this.props.onChangeLocation(Urls.dashboard(action.payload.id)); } catch (e) { console.log("createDashboard failed", e); } @@ -137,7 +138,7 @@ export default class DashboardsDropdown extends Component { <ul className="NavDropdown-content-layer"> { dashboards.map(dash => <li key={dash.id} className="block"> - <Link to={"/dash/"+dash.id} data-metabase-event={"Navbar;Dashboard Dropdown;Open Dashboard;"+dash.id} className="Dropdown-item block text-white no-decoration" onClick={this.closeDropdown}> + <Link to={Urls.dashboard(dash.id)} data-metabase-event={"Navbar;Dashboard Dropdown;Open Dashboard;"+dash.id} className="Dropdown-item block text-white no-decoration" onClick={this.closeDropdown}> <div className="flex text-bold"> {dash.name} </div> diff --git a/frontend/src/metabase/nav/containers/Navbar.jsx b/frontend/src/metabase/nav/containers/Navbar.jsx index 74520d03ff4d49a44a9bc1a91e2df4f3bf0ee24c..3dc5c7f4cef6f4ebf7bba46752d1e5fdf9baa5bb 100644 --- a/frontend/src/metabase/nav/containers/Navbar.jsx +++ b/frontend/src/metabase/nav/containers/Navbar.jsx @@ -11,6 +11,8 @@ import LogoIcon from "metabase/components/LogoIcon.jsx"; import DashboardsDropdown from "metabase/nav/containers/DashboardsDropdown.jsx"; import ProfileLink from "metabase/nav/components/ProfileLink.jsx"; +import * as Urls from "metabase/lib/urls"; + import { getPath, getContext, getUser } from "../selectors"; const mapStateToProps = (state, props) => ({ @@ -115,7 +117,13 @@ export default class Navbar extends Component { </li> <li className="pl3"> <DashboardsDropdown {...this.props}> - <a data-metabase-event={"Navbar;Dashboard Dropdown;Toggle"} style={this.styles.navButton} className={cx("NavDropdown-button NavItem text-white text-bold cursor-pointer px2 flex align-center transition-background", {"NavItem--selected": this.isActive("/dash/")})}> + <a + data-metabase-event={"Navbar;Dashboard Dropdown;Toggle"} + style={this.styles.navButton} + className={cx("NavDropdown-button NavItem text-white text-bold cursor-pointer px2 flex align-center transition-background", { + "NavItem--selected": this.isActive("/dashboard/") + })} + > <span className="NavDropdown-button-layer"> Dashboards <Icon className="ml1" name={'chevrondown'} size={8}></Icon> @@ -133,7 +141,7 @@ export default class Navbar extends Component { <Link to="/reference/guide" data-metabase-event={"Navbar;DataReference"} style={this.styles.navButton} className={cx("NavItem cursor-pointer text-white text-bold no-decoration flex align-center px2 transition-background")} activeClassName="NavItem--selected">Data Reference</Link> </li> <li className="pl3"> - <Link to="/q" data-metabase-event={"Navbar;New Question"} style={this.styles.newQuestion} className="NavNewQuestion rounded inline-block bg-white text-brand text-bold cursor-pointer px2 no-decoration transition-all">New <span className="hide sm-show">Question</span></Link> + <Link to={Urls.question()} data-metabase-event={"Navbar;New Question"} style={this.styles.newQuestion} className="NavNewQuestion rounded inline-block bg-white text-brand text-bold cursor-pointer px2 no-decoration transition-all">New <span className="hide sm-show">Question</span></Link> </li> <li className="flex-align-right transition-background"> <div className="inline-block text-white"><ProfileLink {...this.props}></ProfileLink></div> diff --git a/frontend/src/metabase/pulse/components/PulseEditChannels.jsx b/frontend/src/metabase/pulse/components/PulseEditChannels.jsx index 5029bf3945bc720c1d0b881a2f4918386d0ffc76..c92a3215312688b996aea6152064d99f87ea7cc5 100644 --- a/frontend/src/metabase/pulse/components/PulseEditChannels.jsx +++ b/frontend/src/metabase/pulse/components/PulseEditChannels.jsx @@ -152,7 +152,7 @@ export default class PulseEditChannels extends Component { <span className="h4 text-bold mr1">{field.displayName}</span> { field.type === "select" ? <Select - className="h4 text-bold" + className="h4 text-bold bg-white" value={channel.details[field.name]} options={field.options} optionNameFn={o => o} diff --git a/frontend/src/metabase/pulse/components/PulseListItem.jsx b/frontend/src/metabase/pulse/components/PulseListItem.jsx index 7429928e757872a2c685fb3d3549fe073191833a..47c0e3b7e2c6468f17cdcef9b4fda291c0572723 100644 --- a/frontend/src/metabase/pulse/components/PulseListItem.jsx +++ b/frontend/src/metabase/pulse/components/PulseListItem.jsx @@ -5,7 +5,7 @@ import { Link } from "react-router"; import cx from "classnames"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import PulseListChannel from "./PulseListChannel.jsx"; export default class PulseListItem extends Component { @@ -43,7 +43,7 @@ export default class PulseListItem extends Component { <ol className="mb2 px4 flex flex-wrap"> { pulse.cards.map((card, index) => <li key={index} className="mr1 mb1"> - <Link to={Urls.card(card.id)} className="Button"> + <Link to={Urls.question(card.id)} className="Button"> {card.name} </Link> </li> diff --git a/frontend/src/metabase/pulse/components/RecipientPicker.jsx b/frontend/src/metabase/pulse/components/RecipientPicker.jsx index 3b69caf3a5aea5dfd6b7f6fe977278bbb5bec09e..67db8b2cd3cca1a4e5706a7d378f8ce417933624 100644 --- a/frontend/src/metabase/pulse/components/RecipientPicker.jsx +++ b/frontend/src/metabase/pulse/components/RecipientPicker.jsx @@ -154,7 +154,7 @@ export default class RecipientPicker extends Component { let { recipients } = this.props; return ( - <ul className={cx("px1 pb1 bordered rounded flex flex-wrap", { "input--focus": this.state.focused })} onMouseDownCapture={this.onMouseDownCapture}> + <ul className={cx("px1 pb1 bordered rounded flex flex-wrap bg-white", { "input--focus": this.state.focused })} onMouseDownCapture={this.onMouseDownCapture}> {recipients.map((recipient, index) => <li key={index} className="mr1 py1 pl1 mt1 rounded bg-grey-1"> <span className="h4 text-bold">{recipient.common_name || recipient.email}</span> @@ -163,12 +163,11 @@ export default class RecipientPicker extends Component { </a> </li> )} - <li className="flex-full mr1 py1 pl1 mt1" style={{ "minWidth": " 100px" }}> + <li className="flex-full mr1 py1 pl1 mt1 bg-white" style={{ "minWidth": " 100px" }}> <input ref="input" type="text" className="full h4 text-bold text-default no-focus borderless" - style={{"backgroundColor": "transparent"}} placeholder={recipients.length === 0 ? "Enter email addresses you'd like this data to go to" : null} value={this.state.inputValue} autoFocus={this.state.focused} diff --git a/frontend/src/metabase/pulse/components/SchedulePicker.jsx b/frontend/src/metabase/pulse/components/SchedulePicker.jsx index 7e1df516236f86ad69dcff9f451edb25a50d75c0..99e70181c37960d4ed5a2a5839b88653f4bcd024 100644 --- a/frontend/src/metabase/pulse/components/SchedulePicker.jsx +++ b/frontend/src/metabase/pulse/components/SchedulePicker.jsx @@ -56,6 +56,7 @@ export default class SchedulePicker extends Component { value={_.find(MONTH_DAY_OPTIONS, (o) => o.value === c.schedule_frame)} options={MONTH_DAY_OPTIONS} optionNameFn={o => o.name} + className="bg-white" optionValueFn={o => o.value} onChange={(o) => this.props.onPropertyChange("schedule_frame", o) } /> @@ -66,6 +67,7 @@ export default class SchedulePicker extends Component { options={DAY_OPTIONS} optionNameFn={o => o.name} optionValueFn={o => o.value} + className="bg-white" onChange={(o) => this.props.onPropertyChange("schedule_day", o) } /> </span> @@ -83,6 +85,7 @@ export default class SchedulePicker extends Component { options={DAY_OF_WEEK_OPTIONS} optionNameFn={o => o.name} optionValueFn={o => o.value} + className="bg-white" onChange={(o) => this.props.onPropertyChange("schedule_day", o) } /> </span> diff --git a/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx b/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx index 58ead4423d3d5bfc1e7931c0bb4b6f11bb1d2664..337b5ac6b1f0c91c5523244ba746833e0b63483e 100644 --- a/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx +++ b/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx @@ -7,7 +7,7 @@ import Tooltip from "metabase/components/Tooltip.jsx"; import FieldSet from "metabase/components/FieldSet.jsx"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import _ from "underscore"; import cx from "classnames"; diff --git a/frontend/src/metabase/query_builder/components/QueryHeader.jsx b/frontend/src/metabase/query_builder/components/QueryHeader.jsx index 2e61da66556b74a6600bced942a4b3457a78ee0d..ff451023ecaa2485dfb2044d9a0cb93533fdc446 100644 --- a/frontend/src/metabase/query_builder/components/QueryHeader.jsx +++ b/frontend/src/metabase/query_builder/components/QueryHeader.jsx @@ -23,7 +23,7 @@ import { CardApi, RevisionApi } from "metabase/services"; import MetabaseAnalytics from "metabase/lib/analytics"; import Query from "metabase/lib/query"; import { cancelable } from "metabase/lib/promise"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import cx from "classnames"; import _ from "underscore"; diff --git a/frontend/src/metabase/query_builder/components/QueryVisualization.jsx b/frontend/src/metabase/query_builder/components/QueryVisualization.jsx index 0ed51d41d15292d96d9c9e0413c6ba4192782011..a32d4ccc783c6592a8a8e3fddc7259554436f3e9 100644 --- a/frontend/src/metabase/query_builder/components/QueryVisualization.jsx +++ b/frontend/src/metabase/query_builder/components/QueryVisualization.jsx @@ -19,6 +19,7 @@ import QuestionEmbedWidget from "../containers/QuestionEmbedWidget"; import { formatNumber, inflect, duration } from "metabase/lib/formatting"; import Utils from "metabase/lib/utils"; import MetabaseSettings from "metabase/lib/settings"; +import * as Urls from "metabase/lib/urls"; import cx from "classnames"; import _ from "underscore"; @@ -223,5 +224,5 @@ export default class QueryVisualization extends Component { const VisualizationEmptyState = ({showTutorialLink}) => <div className="flex full layout-centered text-grey-1 flex-column"> <h1>If you give me some data I can show you something cool. Run a Query!</h1> - { showTutorialLink && <Link to="/q#?tutorial" className="link cursor-pointer my2">How do I use this thing?</Link> } + { showTutorialLink && <Link to={Urls.question(null, "?tutorial")} className="link cursor-pointer my2">How do I use this thing?</Link> } </div> diff --git a/frontend/src/metabase/query_builder/components/VisualizationResult.jsx b/frontend/src/metabase/query_builder/components/VisualizationResult.jsx index 3a6c8a94d96f2c99336e86039b8ba1d8bab8e29a..1637825a4044fe8e1739dfe5856f6b1d6f0c711d 100644 --- a/frontend/src/metabase/query_builder/components/VisualizationResult.jsx +++ b/frontend/src/metabase/query_builder/components/VisualizationResult.jsx @@ -4,11 +4,14 @@ import React, { PropTypes } from "react"; import QueryVisualizationObjectDetailTable from './QueryVisualizationObjectDetailTable.jsx'; import VisualizationErrorMessage from './VisualizationErrorMessage'; import Visualization from "metabase/visualizations/components/Visualization.jsx"; +import { datasetContainsNoResults } from "metabase/lib/dataset"; const VisualizationResult = ({card, isObjectDetail, lastRunDatasetQuery, result, ...props}) => { + const noResults = datasetContainsNoResults(result.data); + if (isObjectDetail) { return <QueryVisualizationObjectDetailTable data={result.data} {...props} /> - } else if (result.data.rows.length === 0) { + } else if (noResults) { // successful query but there were 0 rows returned with the result return <VisualizationErrorMessage type='noRows' diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx index 95f367df9fa450f25cb94ddc88ed670c35a0138b..e0fddf9f11224e94198af92083e27dd7dae97aee 100644 --- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx +++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx @@ -154,9 +154,9 @@ export default class QueryBuilder extends Component { if (nextProps.location.action === "POP" && getURL(nextProps.location) !== getURL(this.props.location)) { this.props.popState(nextProps.location); - } else if (this.props.location.query.tutorial === undefined && nextProps.location.query.tutorial !== undefined) { + } else if (this.props.location.hash !== "#?tutorial" && nextProps.location.hash === "#?tutorial") { this.props.initializeQB(nextProps.location, nextProps.params); - } else if (getURL(nextProps.location) === "/q" && getURL(this.props.location) !== "/q") { + } else if (getURL(nextProps.location) === "/question" && getURL(this.props.location) !== "/question") { this.props.initializeQB(nextProps.location, nextProps.params); } } diff --git a/frontend/src/metabase/query_builder/containers/QuestionEmbedWidget.jsx b/frontend/src/metabase/query_builder/containers/QuestionEmbedWidget.jsx index d285d4c94d8dc3c1d6b1402e76bbc91ca5a3f36c..faf973c0dcde01a03a5344df4eacb9b05aad9e71 100644 --- a/frontend/src/metabase/query_builder/containers/QuestionEmbedWidget.jsx +++ b/frontend/src/metabase/query_builder/containers/QuestionEmbedWidget.jsx @@ -5,7 +5,7 @@ import { connect } from "react-redux"; import EmbedWidget from "metabase/public/components/widgets/EmbedWidget"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import { getParameters } from "metabase/meta/Card"; import { createPublicLink, deletePublicLink, updateEnableEmbedding, updateEmbeddingParams, } from "../actions"; diff --git a/frontend/src/metabase/questions/collections.js b/frontend/src/metabase/questions/collections.js index 98ffd30aa0967674657997ae18af6a66f19d9069..592417105a325b7ba5ad1890a68ff19bc568216d 100644 --- a/frontend/src/metabase/questions/collections.js +++ b/frontend/src/metabase/questions/collections.js @@ -2,7 +2,7 @@ import { createAction, createThunkAction, handleActions, combineReducers } from "metabase/lib/redux"; import { reset } from 'redux-form'; import { replace } from "react-router-redux"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import _ from "underscore"; diff --git a/frontend/src/metabase/questions/components/CollectionBadge.jsx b/frontend/src/metabase/questions/components/CollectionBadge.jsx index 143520b64333f5aa63cb28db9bc50eef5d7dfa91..17cb724c593c6a9675ae80f29e048a7d60b49483 100644 --- a/frontend/src/metabase/questions/components/CollectionBadge.jsx +++ b/frontend/src/metabase/questions/components/CollectionBadge.jsx @@ -1,7 +1,7 @@ import React, { Component, PropTypes } from "react"; import { Link } from "react-router"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import Color from "color"; import cx from "classnames"; diff --git a/frontend/src/metabase/questions/components/Item.jsx b/frontend/src/metabase/questions/components/Item.jsx index b8f5003d4fd7c20809de6f5504c96cc032442fd3..0f24593d33436ab5a8f50609763634927756e878 100644 --- a/frontend/src/metabase/questions/components/Item.jsx +++ b/frontend/src/metabase/questions/components/Item.jsx @@ -14,7 +14,7 @@ import MoveToCollection from "../containers/MoveToCollection.jsx"; import Labels from "./Labels.jsx"; import CollectionBadge from "./CollectionBadge.jsx"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; const ITEM_ICON_SIZE = 20; @@ -122,7 +122,7 @@ Item.propTypes = { const ItemBody = pure(({ entity, id, name, description, labels, favorite, collection, setFavorited, onEntityClick }) => <div className={S.itemBody}> <div className={cx('flex', S.itemTitle)}> - <Link to={Urls.card(id)} className={cx(S.itemName)} onClick={onEntityClick && ((e) => { e.preventDefault(); onEntityClick(entity); })}> + <Link to={Urls.question(id)} className={cx(S.itemName)} onClick={onEntityClick && ((e) => { e.preventDefault(); onEntityClick(entity); })}> {name} </Link> { collection && diff --git a/frontend/src/metabase/questions/components/Labels.jsx b/frontend/src/metabase/questions/components/Labels.jsx index cadf7680243fc2acd3dc436d3c45eac04b524f1f..dabc0137ce8a198158efdd891d5a8ebef9123e32 100644 --- a/frontend/src/metabase/questions/components/Labels.jsx +++ b/frontend/src/metabase/questions/components/Labels.jsx @@ -3,7 +3,7 @@ import React, { Component, PropTypes } from "react"; import { Link } from "react-router"; import S from "./Labels.css"; import color from 'color' -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import EmojiIcon from "metabase/components/EmojiIcon.jsx" diff --git a/frontend/src/metabase/questions/questions.js b/frontend/src/metabase/questions/questions.js index 9d1970c246abe09a32a35cda55ae91100af7ecfe..056815d94ee2c1828401aafc862f112d7af16502 100644 --- a/frontend/src/metabase/questions/questions.js +++ b/frontend/src/metabase/questions/questions.js @@ -7,7 +7,7 @@ import _ from "underscore"; import { inflect } from "metabase/lib/formatting"; import MetabaseAnalytics from "metabase/lib/analytics"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import { push, replace } from "react-router-redux"; import { setRequestState } from "metabase/redux/requests"; diff --git a/frontend/src/metabase/reference/components/GuideDetail.jsx b/frontend/src/metabase/reference/components/GuideDetail.jsx index bbb27cc09b74ceff2c85ff22c45cd4925aa6add1..faf42ad83f95d13464616e6e2b550c21fc823ad3 100644 --- a/frontend/src/metabase/reference/components/GuideDetail.jsx +++ b/frontend/src/metabase/reference/components/GuideDetail.jsx @@ -2,7 +2,9 @@ import React, { Component, PropTypes } from "react"; import { Link } from "react-router"; import pure from "recompose/pure"; import cx from "classnames"; + import Icon from "metabase/components/Icon" +import * as Urls from "metabase/lib/urls"; import { getQuestionUrl, @@ -21,7 +23,7 @@ const GuideDetail = ({ const title = entity.display_name || entity.name; const { caveats, points_of_interest } = entity; const typeToLink = { - dashboard: `/dash/${entity.id}`, + dashboard: Urls.dashboard(entity.id), metric: getQuestionUrl({ dbId: tables[entity.table_id] && tables[entity.table_id].db_id, tableId: entity.table_id, diff --git a/frontend/src/metabase/reference/containers/ReferenceEntityList.jsx b/frontend/src/metabase/reference/containers/ReferenceEntityList.jsx index daf3818bd173b97955d390ef587e863b4299d9a7..9f821341533866af791d903eec787c720cec6106 100644 --- a/frontend/src/metabase/reference/containers/ReferenceEntityList.jsx +++ b/frontend/src/metabase/reference/containers/ReferenceEntityList.jsx @@ -5,6 +5,7 @@ import moment from "moment"; import visualizations from "metabase/visualizations"; import { isQueryable } from "metabase/lib/table"; +import * as Urls from "metabase/lib/urls"; import S from "metabase/components/List.css"; import R from "metabase/reference/Reference.css"; @@ -57,7 +58,7 @@ const createListItem = (entity, index, section) => } url={section.type !== 'questions' ? `${section.id}/${entity.id}` : - `/card/${entity.id}` + Urls.question(entity.id) } icon={section.type === 'questions' ? visualizations.get(entity.display).iconName : diff --git a/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx b/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx index a5640fb8d7295fb1b3d616207ce54a3a38edbcef..b34c861b019d881c0797b701c203b252bc49b08f 100644 --- a/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx +++ b/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx @@ -4,10 +4,12 @@ import { Link } from "react-router"; import { connect } from 'react-redux'; import { push } from 'react-router-redux'; import { reduxForm } from "redux-form"; + import { assoc } from "icepick"; import cx from "classnames"; import MetabaseAnalytics from "metabase/lib/analytics"; +import * as Urls from "metabase/lib/urls"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx"; import CreateDashboardModal from 'metabase/components/CreateDashboardModal.jsx'; @@ -225,7 +227,7 @@ export default class ReferenceGettingStartedGuide extends Component { createDashboardFn={async (newDashboard) => { try { const action = await createDashboard(newDashboard, true); - push(`/dash/${action.payload.id}`); + push(Urls.dashboard(action.payload.id)); } catch(error) { console.error(error); @@ -632,4 +634,4 @@ const AdminInstructions = ({ children }) => // eslint-disable-line react/prop-ty </div> const SectionHeader = ({ trim, children }) => // eslint-disable-line react/prop-types - <h2 className={cx('text-dark text-measure', { "mb0" : trim }, { "mb4" : !trim })}>{children}</h2> + <h2 className={cx('text-dark text-measure', { "mb0" : trim }, { "mb4" : !trim })}>{children}</h2> diff --git a/frontend/src/metabase/reference/utils.js b/frontend/src/metabase/reference/utils.js index e22840da4f48bf99e05eb563c75c6951927bee43..0a85e12cc89dcc5ec8ef40b417179cc031cae011 100644 --- a/frontend/src/metabase/reference/utils.js +++ b/frontend/src/metabase/reference/utils.js @@ -2,8 +2,9 @@ import { assoc, assocIn, chain } from "icepick"; import _ from "underscore"; import { titleize, humanize } from "metabase/lib/formatting"; -import { startNewCard, serializeCardForUrl } from "metabase/lib/card"; +import { startNewCard } from "metabase/lib/card"; import { isPK } from "metabase/lib/types"; +import * as Urls from "metabase/lib/urls"; export const idsToObjectMap = (ids, objects) => ids .map(id => objects[id]) @@ -76,11 +77,10 @@ export const tryUpdateData = async (fields, props) => { const importantFieldIds = fields.important_fields.map(field => field.id); const existingImportantFieldIds = guide.metric_important_fields && guide.metric_important_fields[entity.id]; - const areFieldIdsIdentitical = existingImportantFieldIds && + const areFieldIdsIdentitical = existingImportantFieldIds && existingImportantFieldIds.length === importantFieldIds.length && existingImportantFieldIds.every(id => importantFieldIds.includes(id)); - - console.log(areFieldIdsIdentitical); + if (!areFieldIdsIdentitical) { await updateMetricImportantFields(entity.id, importantFieldIds); tryFetchData(props); @@ -156,8 +156,8 @@ export const tryUpdateGuide = async (formFields, props) => { startLoading(); try { const updateNewEntities = ({ - entities, - formFields, + entities, + formFields, updateEntity }) => formFields.map(formField => { if (!formField.id) { @@ -175,7 +175,7 @@ export const tryUpdateGuide = async (formFields, props) => { const newEntity = entities[formField.id]; const updatedNewEntity = { - ...newEntity, + ...newEntity, ...editedEntity }; @@ -185,9 +185,9 @@ export const tryUpdateGuide = async (formFields, props) => { }); const updateOldEntities = ({ - newEntityIds, - oldEntityIds, - entities, + newEntityIds, + oldEntityIds, + entities, updateEntity }) => oldEntityIds .filter(oldEntityId => !newEntityIds.includes(oldEntityId)) @@ -201,14 +201,14 @@ export const tryUpdateGuide = async (formFields, props) => { ); const updatingOldEntity = updateEntity(updatedOldEntity); - + return [updatingOldEntity]; }); //FIXME: necessary because revision_message is a mandatory field // even though we don't actually keep track of changes to caveats/points_of_interest yet const updateWithRevisionMessage = updateEntity => entity => updateEntity(assoc( entity, - 'revision_message', + 'revision_message', 'Updated in Getting Started guide.' )); @@ -218,9 +218,9 @@ export const tryUpdateGuide = async (formFields, props) => { updateEntity: updateDashboard }) .concat(updateOldEntities({ - newEntityIds: formFields.most_important_dashboard ? + newEntityIds: formFields.most_important_dashboard ? [formFields.most_important_dashboard.id] : [], - oldEntityIds: guide.most_important_dashboard ? + oldEntityIds: guide.most_important_dashboard ? [guide.most_important_dashboard] : [], entities: dashboards, @@ -239,7 +239,7 @@ export const tryUpdateGuide = async (formFields, props) => { entities: metrics, updateEntity: updateWithRevisionMessage(updateMetric) })); - + const updatingMetricImportantFields = formFields.important_metrics .map(metricFormField => { if (!metricFormField.id || !metricFormField.important_fields) { @@ -248,17 +248,17 @@ export const tryUpdateGuide = async (formFields, props) => { const importantFieldIds = metricFormField.important_fields .map(field => field.id); const existingImportantFieldIds = guide.metric_important_fields[metricFormField.id]; - - const areFieldIdsIdentitical = existingImportantFieldIds && + + const areFieldIdsIdentitical = existingImportantFieldIds && existingImportantFieldIds.length === importantFieldIds.length && existingImportantFieldIds.every(id => importantFieldIds.includes(id)); if (areFieldIdsIdentitical) { return []; } - + return [updateMetricImportantFields(metricFormField.id, importantFieldIds)]; }); - + const segmentFields = formFields.important_segments_and_tables .filter(field => field.type === 'segment'); @@ -299,7 +299,7 @@ export const tryUpdateGuide = async (formFields, props) => { guide.contact.name !== formFields.contact.name ? [updateSetting({key: 'getting-started-contact-name', value: formFields.contact.name })] : []; - + const updatingContactEmail = guide.contact && formFields.contact && guide.contact.email !== formFields.contact.email ? [updateSetting({key: 'getting-started-contact-email', value: formFields.contact.email })] : @@ -318,7 +318,7 @@ export const tryUpdateGuide = async (formFields, props) => { if (updatingData.length > 0) { await Promise.all(updatingData); - + clearRequestState({statePath: ['reference', 'guide']}); await fetchGuide(); @@ -431,7 +431,7 @@ export const getQuestion = ({dbId, tableId, fieldId, metricId, segmentId, getCou return question; }; -export const getQuestionUrl = getQuestionArgs => `/q#${serializeCardForUrl(getQuestion(getQuestionArgs))}`; +export const getQuestionUrl = getQuestionArgs => Urls.question(null, getQuestion(getQuestionArgs)); export const isGuideEmpty = ({ things_to_know, @@ -446,7 +446,7 @@ export const isGuideEmpty = ({ most_important_dashboard ? false : important_metrics && important_metrics.length !== 0 ? false : important_segments && important_segments.length !== 0 ? false : - important_tables && important_tables.length !== 0 ? false : + important_tables && important_tables.length !== 0 ? false : true; export const typeToLinkClass = { diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx index 26285e188981bda6c8cadddadaa8053905382c08..1be0d20d54d4ef1eafad19b5edd4864de11cb470 100644 --- a/frontend/src/metabase/routes.jsx +++ b/frontend/src/metabase/routes.jsx @@ -151,11 +151,11 @@ export const getRoutes = (store) => <Route path="/" component={HomepageApp} /> {/* DASHBOARD */} - <Route path="/dash/:dashboardId" component={DashboardApp} /> + <Route path="/dashboard/:dashboardId" component={DashboardApp} /> {/* QUERY BUILDER */} - <Route path="/card/:cardId" component={QueryBuilder} /> - <Route path="/q" component={QueryBuilder} /> + <Route path="/question" component={QueryBuilder} /> + <Route path="/question/:cardId" component={QueryBuilder} /> {/* QUESTIONS */} <Route path="/questions"> @@ -262,13 +262,13 @@ export const getRoutes = (store) => <IndexRedirect to="/_internal/list" /> </Route> + {/* DEPRECATED */} + <Redirect from="/q" to="/question" /> + <Redirect from="/card/:cardId" to="/question/:cardId" /> + <Redirect from="/dash/:dashboardId" to="/dashboard/:dashboardId" /> + {/* MISC */} <Route path="/unauthorized" component={Unauthorized} /> <Route path="/*" component={NotFound} /> - - {/* LEGACY */} - <Redirect from="/card" to="/questions" /> - <Redirect from="/card/:cardId/:serializedCard" to="/questions/:cardId#:serializedCard" /> - <Redirect from="/q/:serializedCard" to="/q#:serializedCard" /> </Route> </Route> diff --git a/frontend/src/metabase/visualizations/components/LegendHeader.jsx b/frontend/src/metabase/visualizations/components/LegendHeader.jsx index 0d29843b595d4362aef8c5523adc295b86978fbe..3574ac8732835fdb54eeb4788714f09b12e5fef5 100644 --- a/frontend/src/metabase/visualizations/components/LegendHeader.jsx +++ b/frontend/src/metabase/visualizations/components/LegendHeader.jsx @@ -5,7 +5,7 @@ import styles from "./Legend.css"; import Icon from "metabase/components/Icon.jsx"; import LegendItem from "./LegendItem.jsx"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import cx from "classnames"; @@ -65,7 +65,7 @@ export default class LegendHeader extends Component { key={index} title={s.card.name} description={description} - href={linkToCard && s.card.id && Urls.card(s.card.id)} + href={linkToCard && s.card.id && Urls.question(s.card.id)} color={colors[index % colors.length]} showDot={showDots} showTitle={showTitles} diff --git a/frontend/src/metabase/visualizations/components/Visualization.jsx b/frontend/src/metabase/visualizations/components/Visualization.jsx index 404daf44cd669f772dc44af176485e21fee053fe..29d0a5eb9aa79a891983f7b39cec3288c2768541 100644 --- a/frontend/src/metabase/visualizations/components/Visualization.jsx +++ b/frontend/src/metabase/visualizations/components/Visualization.jsx @@ -15,13 +15,14 @@ import { duration, formatNumber } from "metabase/lib/formatting"; import { getVisualizationTransformed } from "metabase/visualizations"; import { getSettings } from "metabase/visualizations/lib/settings"; import { isSameSeries } from "metabase/visualizations/lib/utils"; -import Utils from "metabase/lib/utils"; +import Utils from "metabase/lib/utils"; +import { datasetContainsNoResults } from "metabase/lib/dataset"; import { getModeDrills } from "metabase/qb/lib/modes" import { MinRowsError, ChartSettingsError } from "metabase/visualizations/lib/errors"; -import { assoc, getIn, setIn } from "icepick"; +import { assoc, setIn } from "icepick"; import _ from "underscore"; import cx from "classnames"; @@ -273,7 +274,8 @@ export default class Visualization extends Component<*, Props, State> { } if (!error) { - noResults = getIn(series, [0, "data", "rows", "length"]) === 0; + // $FlowFixMe + noResults = series[0] && series[0].data && datasetContainsNoResults(series[0].data); } let extra = ( diff --git a/frontend/src/metabase/visualizations/visualizations/PieChart.jsx b/frontend/src/metabase/visualizations/visualizations/PieChart.jsx index b922e95cb76714c4bd966056a2e65ff4b5922392..1e4420e735f70313604df9b09944eda2e0680887 100644 --- a/frontend/src/metabase/visualizations/visualizations/PieChart.jsx +++ b/frontend/src/metabase/visualizations/visualizations/PieChart.jsx @@ -184,8 +184,8 @@ export default class PieChart extends Component<*, Props, *> { let value, title; if (hovered && hovered.index != null && slices[hovered.index] !== otherSlice) { - title = slices[hovered.index].key; - value = slices[hovered.index].value; + title = formatDimension(slices[hovered.index].key); + value = formatMetric(slices[hovered.index].value); } else { title = "Total"; value = formatMetric(total); diff --git a/frontend/src/metabase/visualizations/visualizations/Scalar.jsx b/frontend/src/metabase/visualizations/visualizations/Scalar.jsx index 33dda37c911ae1e6c651788909cefd9e5ad26151..0109394d22e216b61bba2ae5395b0ce4892f1645 100644 --- a/frontend/src/metabase/visualizations/visualizations/Scalar.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Scalar.jsx @@ -8,7 +8,7 @@ import Icon from "metabase/components/Icon.jsx"; import Tooltip from "metabase/components/Tooltip.jsx"; import Ellipsified from "metabase/components/Ellipsified.jsx"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import { formatValue } from "metabase/lib/formatting"; import { TYPE } from "metabase/lib/types"; import { isNumber } from "metabase/lib/schema_metadata"; @@ -195,7 +195,7 @@ export default class Scalar extends Component<*, VisualizationProps, *> { <div className={styles.Title + " flex align-center"}> <Ellipsified tooltip={card.name}> { linkToCard ? - <Link to={Urls.card(card.id)} className="no-decoration fullscreen-normal-text fullscreen-night-text">{settings["card.title"]}</Link> + <Link to={Urls.question(card.id)} className="no-decoration fullscreen-normal-text fullscreen-night-text">{settings["card.title"]}</Link> : <span className="fullscreen-normal-text fullscreen-night-text">{settings["card.title"]}</span> } diff --git a/frontend/test/e2e/auth/login.spec.js b/frontend/test/e2e/auth/login.spec.js index d67f0476d4aff0f55317f1b3a5c71743fda8e77e..fe191cebf9c5107762286b3346a5890b20aed86a 100644 --- a/frontend/test/e2e/auth/login.spec.js +++ b/frontend/test/e2e/auth/login.spec.js @@ -55,8 +55,8 @@ describeE2E("auth/login", () => { }); it("loads the qb", async () => { - await driver.get(`${server.host}/q#eyJuYW1lIjpudWxsLCJkYXRhc2V0X3F1ZXJ5Ijp7ImRhdGFiYXNlIjoxLCJ0eXBlIjoibmF0aXZlIiwibmF0aXZlIjp7InF1ZXJ5Ijoic2VsZWN0ICdvaCBoYWkgZ3Vpc2Ug8J-QsScifSwicGFyYW1ldGVycyI6W119LCJkaXNwbGF5Ijoic2NhbGFyIiwidmlzdWFsaXphdGlvbl9zZXR0aW5ncyI6e319`); - await waitForUrl(driver, `${server.host}/q#eyJuYW1lIjpudWxsLCJkYXRhc2V0X3F1ZXJ5Ijp7ImRhdGFiYXNlIjoxLCJ0eXBlIjoibmF0aXZlIiwibmF0aXZlIjp7InF1ZXJ5Ijoic2VsZWN0ICdvaCBoYWkgZ3Vpc2Ug8J-QsScifSwicGFyYW1ldGVycyI6W119LCJkaXNwbGF5Ijoic2NhbGFyIiwidmlzdWFsaXphdGlvbl9zZXR0aW5ncyI6e319`); + await driver.get(`${server.host}/question#eyJuYW1lIjpudWxsLCJkYXRhc2V0X3F1ZXJ5Ijp7ImRhdGFiYXNlIjoxLCJ0eXBlIjoibmF0aXZlIiwibmF0aXZlIjp7InF1ZXJ5Ijoic2VsZWN0ICdvaCBoYWkgZ3Vpc2Ug8J-QsScifSwicGFyYW1ldGVycyI6W119LCJkaXNwbGF5Ijoic2NhbGFyIiwidmlzdWFsaXphdGlvbl9zZXR0aW5ncyI6e319`); + await waitForUrl(driver, `${server.host}/question#eyJuYW1lIjpudWxsLCJkYXRhc2V0X3F1ZXJ5Ijp7ImRhdGFiYXNlIjoxLCJ0eXBlIjoibmF0aXZlIiwibmF0aXZlIjp7InF1ZXJ5Ijoic2VsZWN0ICdvaCBoYWkgZ3Vpc2Ug8J-QsScifSwicGFyYW1ldGVycyI6W119LCJkaXNwbGF5Ijoic2NhbGFyIiwidmlzdWFsaXphdGlvbl9zZXR0aW5ncyI6e319`); await screenshot(driver, "screenshots/qb.png"); }); }); diff --git a/frontend/test/e2e/query_builder/query_builder.spec.js b/frontend/test/e2e/query_builder/query_builder.spec.js index 6e4106bd8722b4ed842020f59468a80d715e3e2a..9eb6aa8d69d39a6dc0d9d572ca0f2113db7db8ee 100644 --- a/frontend/test/e2e/query_builder/query_builder.spec.js +++ b/frontend/test/e2e/query_builder/query_builder.spec.js @@ -15,7 +15,7 @@ describeE2E("query_builder", () => { describe("tables", () => { it("should allow users to create pivot tables", async () => { // load the query builder and screenshot blank - await d.get("/q"); + await d.get("/question"); await d.screenshot("screenshots/qb-initial.png"); // pick the orders table (assumes database is already selected, i.e. there's only 1 database) @@ -56,7 +56,7 @@ describeE2E("query_builder", () => { describe("charts", () => { xit("should allow users to create line charts", async () => { - await d.get("/q"); + await d.get("/question"); // select orders table await d.select("#TablePicker .List-item:first-child>a").wait().click(); @@ -106,7 +106,7 @@ describeE2E("query_builder", () => { xit("should allow users to create bar charts", async () => { // load line chart - await d.get("/card/2"); + await d.get("/question/2"); // dismiss saved questions modal await d.select(".Modal .Button.Button--primary").wait().click(); diff --git a/frontend/test/e2e/query_builder/tutorial.spec.js b/frontend/test/e2e/query_builder/tutorial.spec.js index 8b6b31f589a2becb8170a0bac563025c3ad08501..9298adaf0a18211a64e7647e82286bf14ddf5504 100644 --- a/frontend/test/e2e/query_builder/tutorial.spec.js +++ b/frontend/test/e2e/query_builder/tutorial.spec.js @@ -24,7 +24,7 @@ describeE2E("tutorial", () => { await waitForElementAndClick(driver, ".Modal .Button.Button--primary"); await waitForElementAndClick(driver, ".Modal .Button.Button--primary"); - await waitForUrl(driver, `${server.host}/q`); + await waitForUrl(driver, `${server.host}/question`); await waitForElement(driver, ".Modal .Button.Button--primary"); await screenshot(driver, "screenshots/setup-tutorial-qb.png"); await waitForElementAndClick(driver, ".Modal .Button.Button--primary"); diff --git a/package.json b/package.json index 8cebcc3d1696c7a4c1b6cf3a107c98c47033f1d4..77edb5a62183b55dc27d90deac3fb94b2cfdd11f 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "flow-bin": "^0.37.4", "fs-promise": "^1.0.0", "glob": "^7.1.1", + "html-webpack-harddisk-plugin": "^0.1.0", "html-webpack-plugin": "^2.14.0", "husky": "^0.13.2", "image-diff": "^1.6.3", @@ -153,7 +154,7 @@ "test-e2e-sauce": "USE_SAUCE=true yarn run test-e2e", "build": "webpack --bail", "build-watch": "webpack --watch", - "build-hot": "NODE_ENV=hot webpack --bail && NODE_ENV=hot webpack-dev-server --progress", + "build-hot": "NODE_ENV=hot webpack-dev-server --progress", "start": "yarn run build && lein ring server", "storybook": "start-storybook -p 9001", "precommit": "lint-staged", diff --git a/project.clj b/project.clj index 2dd29bb69d5e83eaf740572b4433f1cc6093e73b..f972daf60dc0175ebe9ae6ca40f91f24c996eab1 100644 --- a/project.clj +++ b/project.clj @@ -12,7 +12,7 @@ "profile" ["with-profile" "+profile" "run" "profile"] "h2" ["with-profile" "+h2-shell" "run" "-url" "jdbc:h2:./metabase.db" "-user" "" "-password" "" "-driver" "org.h2.Driver"]} :dependencies [[org.clojure/clojure "1.8.0"] - [org.clojure/core.async "0.2.395"] + [org.clojure/core.async "0.3.442"] [org.clojure/core.match "0.3.0-alpha4"] ; optimized pattern matching library for Clojure [org.clojure/core.memoize "0.5.9"] ; needed by core.match; has useful FIFO, LRU, etc. caching mechanisms [org.clojure/data.csv "0.1.3"] ; CSV parsing / generation @@ -25,7 +25,7 @@ :exclusions [org.clojure/clojure org.clojure/clojurescript]] ; fixed length queue implementation, used in log buffering [amalloy/ring-gzip-middleware "0.1.3"] ; Ring middleware to GZIP responses if client can handle it - [aleph "0.4.1"] ; Async HTTP library; WebSockets + [aleph "0.4.3"] ; Async HTTP library; WebSockets [buddy/buddy-core "1.2.0"] ; various cryptograhpic functions [buddy/buddy-sign "1.5.0"] ; JSON Web Tokens; High-Level message signing library [cheshire "5.7.0"] ; fast JSON encoding (used by Ring JSON middleware) @@ -43,10 +43,10 @@ ring/ring-core]] [com.draines/postal "2.0.2"] ; SMTP library [com.google.apis/google-api-services-analytics ; Google Analytics Java Client Library - "v3-rev136-1.22.0"] + "v3-rev139-1.22.0"] [com.google.apis/google-api-services-bigquery ; Google BigQuery Java Client Library - "v2-rev334-1.22.0"] - [com.h2database/h2 "1.4.193"] ; embedded SQL database + "v2-rev342-1.22.0"] + [com.h2database/h2 "1.4.194"] ; embedded SQL database [com.mattbertolini/liquibase-slf4j "2.0.0"] ; Java Migrations lib [com.mchange/c3p0 "0.9.5.2"] ; connection pooling library [com.novemberain/monger "3.1.0"] ; MongoDB Driver @@ -68,20 +68,20 @@ :exclusions [org.slf4j/slf4j-api]] [net.sourceforge.jtds/jtds "1.3.1"] ; Open Source SQL Server driver [org.liquibase/liquibase-core "3.5.3"] ; migration management (Java lib) - [org.slf4j/slf4j-log4j12 "1.7.22"] ; abstraction for logging frameworks -- allows end user to plug in desired logging framework at deployment time - [org.yaml/snakeyaml "1.17"] ; YAML parser (required by liquibase) - [org.xerial/sqlite-jdbc "3.8.11.2"] ; SQLite driver !!! DO NOT UPGRADE THIS UNTIL UPSTREAM BUG IS FIXED -- SEE https://github.com/metabase/metabase/issues/3753 !!! + [org.slf4j/slf4j-log4j12 "1.7.25"] ; abstraction for logging frameworks -- allows end user to plug in desired logging framework at deployment time + [org.yaml/snakeyaml "1.18"] ; YAML parser (required by liquibase) + [org.xerial/sqlite-jdbc "3.16.1"] ; SQLite driver [postgresql "9.3-1102.jdbc41"] ; Postgres driver [io.crate/crate-jdbc "2.1.6"] ; Crate JDBC driver - [prismatic/schema "1.1.3"] ; Data schema declaration and validation library + [prismatic/schema "1.1.5"] ; Data schema declaration and validation library [ring/ring-jetty-adapter "1.5.1"] ; Ring adapter using Jetty webserver (used to run a Ring server for unit tests) [ring/ring-json "0.4.0"] ; Ring middleware for reading/writing JSON automatically [stencil "0.5.0"] ; Mustache templates for Clojure [toucan "1.0.2" ; Model layer, hydration, and DB utilities :exclusions [honeysql]]] :repositories [["bintray" "https://dl.bintray.com/crate/crate"]] ; Repo for Crate JDBC driver - :plugins [[lein-environ "1.0.3"] ; easy access to environment variables - [lein-ring "0.9.7" ; start the HTTP server with 'lein ring server' + :plugins [[lein-environ "1.1.0"] ; easy access to environment variables + [lein-ring "0.11.0" ; start the HTTP server with 'lein ring server' :exclusions [org.clojure/clojure]]] ; TODO - should this be a dev dependency ? :main ^:skip-aot metabase.core :manifest {"Liquibase-Package" "liquibase.change,liquibase.changelog,liquibase.database,liquibase.parser,liquibase.precondition,liquibase.datatype,liquibase.serializer,liquibase.sqlgenerator,liquibase.executor,liquibase.snapshot,liquibase.logging,liquibase.diff,liquibase.structure,liquibase.structurecompare,liquibase.lockservice,liquibase.sdk,liquibase.ext"} @@ -107,13 +107,12 @@ :docstring-checker {:include [#"^metabase"] :exclude [#"test" #"^metabase\.http-client$"]} - :profiles {:dev {:dependencies [[org.clojure/tools.nrepl "0.2.12"] ; REPL <3 - [expectations "2.1.9"] ; unit tests + :profiles {:dev {:dependencies [[expectations "2.1.9"] ; unit tests [ring/ring-mock "0.3.0"]] ; Library to create mock Ring requests for unit tests :plugins [[docstring-checker "1.0.0"] ; Check that all public vars have docstrings. Run with 'lein docstring-checker' [jonase/eastwood "0.2.3" :exclusions [org.clojure/clojure]] ; Linting - [lein-bikeshed "0.3.0"] ; Linting + [lein-bikeshed "0.4.1"] ; Linting [lein-expectations "0.0.8"] ; run unit tests with 'lein expectations' [lein-instant-cheatsheet "2.2.1" ; use awesome instant cheatsheet created by yours truly w/ 'lein instant-cheatsheet' :exclusions [org.clojure/clojure diff --git a/src/metabase/driver/bigquery.clj b/src/metabase/driver/bigquery.clj index 8e4c41e194baa3c014da31a0f84141ef7332734b..e979b235e799f2c45ea0ab408e1f65d60c472353 100644 --- a/src/metabase/driver/bigquery.clj +++ b/src/metabase/driver/bigquery.clj @@ -382,6 +382,12 @@ (defn- string-length-fn [field-key] (hsql/call :length field-key)) +;; From the dox: Fields must contain only letters, numbers, and underscores, start with a letter or underscore, and be at most 128 characters long. +(defn- format-custom-field-name ^String [^String custom-field-name] + (s/join (take 128 (-> (s/trim custom-field-name) + (s/replace #"[^\w\d_]" "_") + (s/replace #"(^\d)" "_$1"))))) + (defrecord BigQueryDriver [] clojure.lang.Named @@ -407,46 +413,45 @@ driver/IDriver (merge driver/IDriverDefaultsMixin - {:analyze-table analyze/generic-analyze-table - :can-connect? (u/drop-first-arg can-connect?) - :date-interval (u/drop-first-arg (comp prepare-value u/relative-date)) - :describe-database (u/drop-first-arg describe-database) - :describe-table (u/drop-first-arg describe-table) - :details-fields (constantly [{:name "project-id" - :display-name "Project ID" - :placeholder "praxis-beacon-120871" - :required true} - {:name "dataset-id" - :display-name "Dataset ID" - :placeholder "toucanSightings" - :required true} - {:name "client-id" - :display-name "Client ID" - :placeholder "1201327674725-y6ferb0feo1hfssr7t40o4aikqll46d4.apps.googleusercontent.com" - :required true} - {:name "client-secret" - :display-name "Client Secret" - :placeholder "dJNi4utWgMzyIFo2JbnsK6Np" - :required true} - {:name "auth-code" - :display-name "Auth Code" - :placeholder "4/HSk-KtxkSzTt61j5zcbee2Rmm5JHkRFbL5gD5lgkXek" - :required true}]) - :execute-query (u/drop-first-arg execute-query) + {:analyze-table analyze/generic-analyze-table + :can-connect? (u/drop-first-arg can-connect?) + :date-interval (u/drop-first-arg (comp prepare-value u/relative-date)) + :describe-database (u/drop-first-arg describe-database) + :describe-table (u/drop-first-arg describe-table) + :details-fields (constantly [{:name "project-id" + :display-name "Project ID" + :placeholder "praxis-beacon-120871" + :required true} + {:name "dataset-id" + :display-name "Dataset ID" + :placeholder "toucanSightings" + :required true} + {:name "client-id" + :display-name "Client ID" + :placeholder "1201327674725-y6ferb0feo1hfssr7t40o4aikqll46d4.apps.googleusercontent.com" + :required true} + {:name "client-secret" + :display-name "Client Secret" + :placeholder "dJNi4utWgMzyIFo2JbnsK6Np" + :required true} + {:name "auth-code" + :display-name "Auth Code" + :placeholder "4/HSk-KtxkSzTt61j5zcbee2Rmm5JHkRFbL5gD5lgkXek" + :required true}]) + :execute-query (u/drop-first-arg execute-query) ;; Don't enable foreign keys when testing because BigQuery *doesn't* have a notion of foreign keys. Joins are still allowed, which puts us in a weird position, however; ;; people can manually specifiy "foreign key" relationships in admin and everything should work correctly. ;; Since we can't infer any "FK" relationships during sync our normal FK tests are not appropriate for BigQuery, so they're disabled for the time being. ;; TODO - either write BigQuery-speciifc tests for FK functionality or add additional code to manually set up these FK relationships for FK tables - :features (constantly (set/union #{:basic-aggregations - :standard-deviation-aggregations - :native-parameters - ;; Expression aggregations *would* work, but BigQuery doesn't support the auto-generated column names. BQ column names - ;; can only be alphanumeric or underscores. If we slugified the auto-generated column names, we could enable this feature. - #_:expression-aggregations} - (when-not config/is-test? - ;; during unit tests don't treat bigquery as having FK support - #{:foreign-keys}))) - :field-values-lazy-seq (u/drop-first-arg field-values-lazy-seq) - :mbql->native (u/drop-first-arg mbql->native)})) + :features (constantly (set/union #{:basic-aggregations + :standard-deviation-aggregations + :native-parameters + :expression-aggregations} + (when-not config/is-test? + ;; during unit tests don't treat bigquery as having FK support + #{:foreign-keys}))) + :field-values-lazy-seq (u/drop-first-arg field-values-lazy-seq) + :format-custom-field-name (u/drop-first-arg format-custom-field-name) + :mbql->native (u/drop-first-arg mbql->native)})) (driver/register-driver! :bigquery driver) diff --git a/src/metabase/driver/druid.clj b/src/metabase/driver/druid.clj index 6f652e66cf5b1a5e6bcb642b4b3c152275923c38..1cfe762537c4f60b095724370b24c44a2de2a3ef 100644 --- a/src/metabase/driver/druid.clj +++ b/src/metabase/driver/druid.clj @@ -7,6 +7,7 @@ [metabase.driver.druid.query-processor :as qp] (metabase.models [field :as field] [table :as table]) + [metabase.sync-database.analyze :as analyze] [metabase.util :as u])) ;;; ### Request helper fns @@ -138,6 +139,15 @@ (field-values-lazy-seq details table-name field-name total-items-fetched paging-identifiers))))))) +(defn- analyze-table + "Implementation of `analyze-table` for Druid driver." + [driver table new-table-ids] + ((analyze/make-analyze-table driver + :field-avg-length-fn (constantly 0) ; TODO implement this? + :field-percent-urls-fn (constantly 0) + :calculate-row-count? false) driver table new-table-ids)) + + ;;; ### DruidrDriver Class Definition (defrecord DruidDriver [] @@ -148,6 +158,7 @@ driver/IDriver (merge driver/IDriverDefaultsMixin {:can-connect? (u/drop-first-arg can-connect?) + :analyze-table analyze-table :describe-database (u/drop-first-arg describe-database) :describe-table (u/drop-first-arg describe-table) :details-fields (constantly [{:name "host" diff --git a/src/metabase/driver/generic_sql.clj b/src/metabase/driver/generic_sql.clj index 4c1364d7ba01a6182f3faa329a72342280d94834..dbec2699e8e5f9c135fab69c7843b3bfcb5edf3b 100644 --- a/src/metabase/driver/generic_sql.clj +++ b/src/metabase/driver/generic_sql.clj @@ -27,9 +27,12 @@ Methods marked *OPTIONAL* have default implementations in `ISQLDriverDefaultsMixin`." (active-tables ^java.util.Set [this, ^DatabaseMetaData metadata] - "Return a set of maps containing information about the active tables/views, collections, or equivalent that currently exist in DATABASE. + "*OPTIONAL* Return a set of maps containing information about the active tables/views, collections, or equivalent that currently exist in DATABASE. Each map should contain the key `:name`, which is the string name of the table. For databases that have a concept of schemas, - this map should also include the string name of the table's `:schema`.") + this map should also include the string name of the table's `:schema`. + + Two different implementations are provided in this namespace: `fast-active-tables` (the default), and `post-filtered-active-tables`. You should be fine using + the default, but refer to the documentation for those functions for more details on the differences.") ;; The following apply-* methods define how the SQL Query Processor handles given query clauses. Each method is called when a matching clause is present ;; in QUERY, and should return an appropriately modified version of KORMA-QUERY. Most drivers can use the default implementations for all of these methods, @@ -71,7 +74,7 @@ (field-percent-urls [this field] "*OPTIONAL*. Implementation of the `:field-percent-urls-fn` to be passed to `make-analyze-table`. The default implementation is `fast-field-percent-urls`, which avoids a full table scan. Substitue this with `slow-field-percent-urls` for databases - where this doesn't work, such as SQL Server") + where this doesn't work, such as SQL Server.") (field->alias ^String [this, ^Field field] "*OPTIONAL*. Return the alias that should be used to for FIELD, i.e. in an `AS` clause. The default implementation calls `name`, which diff --git a/src/metabase/driver/generic_sql/query_processor.clj b/src/metabase/driver/generic_sql/query_processor.clj index 3a19281b242c7d0705e087f545cd94d2a132088c..741a791a1a35a4ea6836755639fe631c7b6bd9da 100644 --- a/src/metabase/driver/generic_sql/query_processor.clj +++ b/src/metabase/driver/generic_sql/query_processor.clj @@ -151,7 +151,7 @@ (defn- apply-expression-aggregation [driver honeysql-form expression] (h/merge-select honeysql-form [(expression-aggregation->honeysql driver expression) - (hx/escape-dots (annotate/aggregation-name expression))])) + (hx/escape-dots (driver/format-custom-field-name driver (annotate/aggregation-name expression)))])) (defn- apply-single-aggregation [driver honeysql-form {:keys [aggregation-type field], :as aggregation}] (h/merge-select honeysql-form [(aggregation->honeysql driver aggregation-type field) diff --git a/src/metabase/driver/generic_sql/util/unprepare.clj b/src/metabase/driver/generic_sql/util/unprepare.clj index d65502ac180912785228c1ac8058e590ed548f71..56845e692a68a5e0a3500406c0c5609ebed4ffcd 100644 --- a/src/metabase/driver/generic_sql/util/unprepare.clj +++ b/src/metabase/driver/generic_sql/util/unprepare.clj @@ -7,20 +7,20 @@ (:import java.util.Date)) (defprotocol ^:private IUnprepare - (^:private unprepare-arg ^String [this])) + (^:private unprepare-arg ^String [this settings])) (extend-protocol IUnprepare - nil (unprepare-arg [this] "NULL") - String (unprepare-arg [this] (str \' (str/replace this "'" "\\\\'") \')) ; escape single-quotes - Boolean (unprepare-arg [this] (if this "TRUE" "FALSE")) - Number (unprepare-arg [this] (str this)) - Date (unprepare-arg [this] (first (hsql/format (hsql/call :timestamp (hx/literal (u/date->iso-8601 this))))))) ; TODO - this probably doesn't work for every DB! + nil (unprepare-arg [this _] "NULL") + String (unprepare-arg [this {:keys [quote-escape]}] (str \' (str/replace this "'" (str quote-escape "'")) \')) ; escape single-quotes + Boolean (unprepare-arg [this _] (if this "TRUE" "FALSE")) + Number (unprepare-arg [this _] (str this)) + Date (unprepare-arg [this {:keys [iso-8601-fn]}] (first (hsql/format (hsql/call iso-8601-fn (hx/literal (u/date->iso-8601 this))))))) (defn unprepare "Convert a normal SQL `[statement & prepared-statement-args]` vector into a flat, non-prepared statement." - ^String [[sql & args]] + ^String [[sql & args] & {:keys [quote-escape iso-8601-fn], :or {quote-escape "\\\\", iso-8601-fn :timestamp}}] (loop [sql sql, [arg & more-args, :as args] args] (if-not (seq args) sql - (recur (str/replace-first sql #"(?<!\?)\?(?!\?)" (unprepare-arg arg)) + (recur (str/replace-first sql #"(?<!\?)\?(?!\?)" (unprepare-arg arg {:quote-escape quote-escape, :iso-8601-fn iso-8601-fn})) more-args)))) diff --git a/src/metabase/driver/mongo/query_processor.clj b/src/metabase/driver/mongo/query_processor.clj index 4e27d66d57140600f9380fc92e11de81b4643a67..4dac3f89814c8677bfd63ebbcaad925229b68545 100644 --- a/src/metabase/driver/mongo/query_processor.clj +++ b/src/metabase/driver/mongo/query_processor.clj @@ -379,31 +379,63 @@ v)})))) -;;; ------------------------------------------------------------ Handling ISODate(...) forms ------------------------------------------------------------ -;; In Mongo it's fairly common use ISODate(...) forms in queries, which unfortunately are not valid JSON, +;;; ------------------------------------------------------------ Handling ISODate(...) and ObjectId(...) forms ------------------------------------------------------------ +;; In Mongo it's fairly common use ISODate(...) or ObjectId(...) forms in queries, which unfortunately are not valid JSON, ;; and thus cannot be parsed by Cheshire. But we are clever so we will: ;; ;; 1) Convert forms like ISODate(...) to valid JSON forms like ["___ISODate", ...] ;; 2) Parse Normally -;; 3) Walk the parsed JSON and convert forms like [:___ISODate ...] to JodaTime dates +;; 3) Walk the parsed JSON and convert forms like [:___ISODate ...] to JodaTime dates, and [:___ObjectId ...] to BSON IDs + +;; add more fn handlers here as needed +(def ^:private fn-name->decoder + {:ISODate (fn [arg] + (DateTime. arg)) + :ObjectId (fn [^String arg] + (ObjectId. arg))}) + +(defn- form->encoded-fn-name + "If FORM is an encoded fn call form return the key representing the fn call that was encoded. + If it doesn't represent an encoded fn, return `nil`. + + (form->encoded-fn-name [:___ObjectId \"583327789137b2700a1621fb\"]) -> :ObjectId" + [form] + (when (vector? form) + (when (u/string-or-keyword? (first form)) + (when-let [[_ k] (re-matches #"^___(\w+$)" (name (first form)))] + (let [k (keyword k)] + (when (contains? fn-name->decoder k) + k)))))) + +(defn- maybe-decode-fncall [form] + (if-let [fn-name (form->encoded-fn-name form)] + ((fn-name->decoder fn-name) (second form)) + form)) -(defn- encoded-iso-date? [form] - (and (vector? form) - (= (first form) "___ISODate"))) +(defn- decode-fncalls [query] + (walk/postwalk maybe-decode-fncall query)) -(defn- maybe-decode-iso-date-fncall [form] - (if (encoded-iso-date? form) - (DateTime. (second form)) - form)) +(defn- encode-fncalls-for-fn + "Walk QUERY-STRING and replace fncalls to fn with FN-NAME with encoded forms that can be parsed as valid JSON. + + (encode-fncalls-for-fn \"ObjectId\" \"{\\\"$match\\\":ObjectId(\\\"583327789137b2700a1621fb\\\")}\") + ;; -> \"{\\\"$match\\\":[\\\"___ObjectId\\\", \\\"583327789137b2700a1621fb\\\"]}\"" + [fn-name query-string] + (s/replace query-string + (re-pattern (format "%s\\(([^)]+)\\)" (name fn-name))) + (format "[\"___%s\", $1]" (name fn-name)))) -(defn- decode-iso-date-fncalls [query] - (walk/postwalk maybe-decode-iso-date-fncall query)) +(defn- encode-fncalls + "Replace occurances of `ISODate(...)` and similary function calls (invalid JSON, but legal in Mongo) + with legal JSON forms like `[:___ISODate ...]` that we can decode later. -(defn- encode-iso-date-fncalls - "Replace occurances of `ISODate(...)` function calls (invalid JSON, but legal in Mongo) - with legal JSON forms like `[:___ISODate ...]` that we can decode later." + Walks QUERY-STRING and encodes all the various fncalls we support." [query-string] - (s/replace query-string #"ISODate\(([^)]+)\)" "[\"___ISODate\", $1]")) + (loop [query-string query-string, [fn-name & more] (keys fn-name->decoder)] + (if-not fn-name + query-string + (recur (encode-fncalls-for-fn fn-name query-string) + more)))) ;;; ------------------------------------------------------------ Query Execution ------------------------------------------------------------ @@ -427,7 +459,7 @@ (string? collection) (map? database)]} (let [query (if (string? query) - (decode-iso-date-fncalls (json/parse-string (encode-iso-date-fncalls query) keyword)) + (decode-fncalls (json/parse-string (encode-fncalls query) keyword)) query) results (mc/aggregate *mongo-connection* collection query :allow-disk-use true) diff --git a/src/metabase/driver/presto.clj b/src/metabase/driver/presto.clj new file mode 100644 index 0000000000000000000000000000000000000000..97630c44ff7b08284a7b8d5a11700d335207def3 --- /dev/null +++ b/src/metabase/driver/presto.clj @@ -0,0 +1,344 @@ +(ns metabase.driver.presto + (:require [clojure.set :as set] + [clojure.string :as str] + [clj-http.client :as http] + (honeysql [core :as hsql] + [helpers :as h]) + [metabase.config :as config] + [metabase.driver :as driver] + [metabase.driver.generic-sql :as sql] + [metabase.driver.generic-sql.util.unprepare :as unprepare] + (metabase.models [field :as field] + [table :as table]) + [metabase.sync-database.analyze :as analyze] + [metabase.query-processor.util :as qputil] + [metabase.util :as u] + [metabase.util.honeysql-extensions :as hx]) + (:import java.util.Date + (metabase.query_processor.interface DateTimeValue Value))) + + +;;; Presto API helpers + +(defn- details->uri + [{:keys [ssl host port]} path] + (str (if ssl "https" "http") "://" host ":" port path)) + +(defn- details->request [{:keys [user password catalog report-timezone]}] + (merge {:headers (merge {"X-Presto-Source" "metabase" + "X-Presto-User" user} + (when catalog + {"X-Presto-Catalog" catalog}) + (when report-timezone + {"X-Presto-Time-Zone" report-timezone}))} + (when password + {:basic-auth [user password]}))) + +(defn- parse-time-with-tz [s] + ;; Try parsing with offset first then with full ZoneId + (or (u/ignore-exceptions (u/parse-date "HH:mm:ss.SSS ZZ" s)) + (u/parse-date "HH:mm:ss.SSS ZZZ" s))) + +(defn- parse-timestamp-with-tz [s] + ;; Try parsing with offset first then with full ZoneId + (or (u/ignore-exceptions (u/parse-date "yyyy-MM-dd HH:mm:ss.SSS ZZ" s)) + (u/parse-date "yyyy-MM-dd HH:mm:ss.SSS ZZZ" s))) + +(defn- field-type->parser [field-type] + (condp re-matches field-type + #"decimal.*" bigdec + #"time" (partial u/parse-date :hour-minute-second-ms) + #"time with time zone" parse-time-with-tz + #"timestamp" (partial u/parse-date "yyyy-MM-dd HH:mm:ss.SSS") + #"timestamp with time zone" parse-timestamp-with-tz + #".*" identity)) + +(defn- parse-presto-results [columns data] + (let [parsers (map (comp field-type->parser :type) columns)] + (for [row data] + (for [[value parser] (partition 2 (interleave row parsers))] + (when value + (parser value)))))) + +(defn- fetch-presto-results! [details {prev-columns :columns, prev-rows :rows} uri] + (let [{{:keys [columns data nextUri error]} :body} (http/get uri (assoc (details->request details) :as :json))] + (when error + (throw (ex-info (or (:message error) "Error running query.") error))) + (let [rows (parse-presto-results columns data) + results {:columns (or columns prev-columns) + :rows (vec (concat prev-rows rows))}] + (if (nil? nextUri) + results + (do (Thread/sleep 100) ; Might not be the best way, but the pattern is that we poll Presto at intervals + (fetch-presto-results! details results nextUri)))))) + +(defn- execute-presto-query! [details query] + (let [{{:keys [columns data nextUri error]} :body} (http/post (details->uri details "/v1/statement") + (assoc (details->request details) :body query, :as :json))] + (when error + (throw (ex-info (or (:message error) "Error preparing query.") error))) + (let [rows (parse-presto-results (or columns []) (or data [])) + results {:columns (or columns []) + :rows rows}] + (if (nil? nextUri) + results + (fetch-presto-results! details results nextUri))))) + + +;;; Generic helpers + +(defn- quote-name [nm] + (str \" (str/replace nm "\"" "\"\"") \")) + +(defn- quote+combine-names [& names] + (str/join \. (map quote-name names))) + + +;;; IDriver implementation + +(defn- field-avg-length [{field-name :name, :as field}] + (let [table (field/table field) + {:keys [details]} (table/database table) + sql (format "SELECT cast(round(avg(length(%s))) AS integer) FROM %s WHERE %s IS NOT NULL" + (quote-name field-name) + (quote+combine-names (:schema table) (:name table)) + (quote-name field-name)) + {[[v]] :rows} (execute-presto-query! details sql)] + (or v 0))) + +(defn- field-percent-urls [{field-name :name, :as field}] + (let [table (field/table field) + {:keys [details]} (table/database table) + sql (format "SELECT cast(count_if(url_extract_host(%s) <> '') AS double) / cast(count(*) AS double) FROM %s WHERE %s IS NOT NULL" + (quote-name field-name) + (quote+combine-names (:schema table) (:name table)) + (quote-name field-name)) + {[[v]] :rows} (execute-presto-query! details sql)] + (if (= v "NaN") 0.0 v))) + +(defn- analyze-table [driver table new-table-ids] + ((analyze/make-analyze-table driver + :field-avg-length-fn field-avg-length + :field-percent-urls-fn field-percent-urls) driver table new-table-ids)) + +(defn- can-connect? [{:keys [catalog] :as details}] + (let [{[[v]] :rows} (execute-presto-query! details (str "SHOW SCHEMAS FROM " (quote-name catalog) " LIKE 'information_schema'"))] + (= v "information_schema"))) + +(defn- date-interval [unit amount] + (hsql/call :date_add (hx/literal unit) amount :%now)) + +(defn- describe-schema [{{:keys [catalog] :as details} :details} {:keys [schema]}] + (let [sql (str "SHOW TABLES FROM " (quote+combine-names catalog schema)) + {:keys [rows]} (execute-presto-query! details sql) + tables (map first rows)] + (set (for [name tables] + {:name name, :schema schema})))) + +(defn- describe-database [{{:keys [catalog] :as details} :details :as database}] + (let [sql (str "SHOW SCHEMAS FROM " (quote-name catalog)) + {:keys [rows]} (execute-presto-query! details sql) + schemas (remove #{"information_schema"} (map first rows))] ; inspecting "information_schema" breaks weirdly + {:tables (apply set/union (for [name schemas] + (describe-schema database {:schema name})))})) + +(defn- presto-type->base-type [field-type] + (condp re-matches field-type + #"boolean" :type/Boolean + #"tinyint" :type/Integer + #"smallint" :type/Integer + #"integer" :type/Integer + #"bigint" :type/BigInteger + #"real" :type/Float + #"double" :type/Float + #"decimal.*" :type/Decimal + #"varchar.*" :type/Text + #"char.*" :type/Text + #"varbinary.*" :type/* + #"json" :type/Text ; TODO - this should probably be Dictionary or something + #"date" :type/Date + #"time.*" :type/DateTime + #"array" :type/Array + #"map" :type/Dictionary + #"row.*" :type/* ; TODO - again, but this time we supposedly have a schema + #".*" :type/*)) + +(defn- describe-table [{{:keys [catalog] :as details} :details} {schema :schema, table-name :name}] + (let [sql (str "DESCRIBE " (quote+combine-names catalog schema table-name)) + {:keys [rows]} (execute-presto-query! details sql)] + {:schema schema + :name table-name + :fields (set (for [[name type] rows] + {:name name, :base-type (presto-type->base-type type)}))})) + +(defprotocol ^:private IPrepareValue + (^:private prepare-value [this])) +(extend-protocol IPrepareValue + nil (prepare-value [_] nil) + DateTimeValue (prepare-value [{:keys [value]}] (prepare-value value)) + Value (prepare-value [{:keys [value]}] (prepare-value value)) + String (prepare-value [this] (hx/literal (str/replace this "'" "''"))) + Boolean (prepare-value [this] (hsql/raw (if this "TRUE" "FALSE"))) + Date (prepare-value [this] (hsql/call :from_iso8601_timestamp (hx/literal (u/date->iso-8601 this)))) + Number (prepare-value [this] this) + Object (prepare-value [this] (throw (Exception. (format "Don't know how to prepare value %s %s" (class this) this))))) + +(defn- execute-query [{:keys [database settings], {sql :query, params :params} :native, :as outer-query}] + (let [sql (str "-- " (qputil/query->remark outer-query) "\n" + (unprepare/unprepare (cons sql params) :quote-escape "'", :iso-8601-fn :from_iso8601_timestamp)) + details (merge (:details database) settings) + {:keys [columns rows]} (execute-presto-query! details sql)] + {:columns (map (comp keyword :name) columns) + :rows rows})) + +(defn- field-values-lazy-seq [{field-name :name, :as field}] + ;; TODO - look into making this actually lazy + (let [table (field/table field) + {:keys [details]} (table/database table) + sql (format "SELECT %s FROM %s LIMIT %d" + (quote-name field-name) + (quote+combine-names (:schema table) (:name table)) + driver/max-sync-lazy-seq-results) + {:keys [rows]} (execute-presto-query! details sql)] + (for [row rows] + (first row)))) + +(defn- humanize-connection-error-message [message] + (condp re-matches message + #"^java.net.ConnectException: Connection refused.*$" + (driver/connection-error-messages :cannot-connect-check-host-and-port) + + #"^clojure.lang.ExceptionInfo: Catalog .* does not exist.*$" + (driver/connection-error-messages :database-name-incorrect) + + #"^java.net.UnknownHostException.*$" + (driver/connection-error-messages :invalid-hostname) + + #".*" ; default + message)) + +(defn- table-rows-seq [{:keys [details]} {:keys [schema name]}] + (let [sql (format "SELECT * FROM %s" (quote+combine-names schema name)) + {:keys [rows], :as result} (execute-presto-query! details sql) + columns (map (comp keyword :name) (:columns result))] + (for [row rows] + (zipmap columns row)))) + + +;;; ISQLDriver implementation + +(defn- apply-page [honeysql-query {{:keys [items page]} :page}] + (let [offset (* (dec page) items)] + (if (zero? offset) + ;; if there's no offset we can simply use limit + (h/limit honeysql-query items) + ;; if we need to do an offset we have to do nesting to generate a row number and where on that + (let [over-clause (format "row_number() OVER (%s)" + (first (hsql/format (select-keys honeysql-query [:order-by]) + :allow-dashed-names? true + :quoting :ansi)))] + (-> (apply h/select (map last (:select honeysql-query))) + (h/from (h/merge-select honeysql-query [(hsql/raw over-clause) :__rownum__])) + (h/where [:> :__rownum__ offset]) + (h/limit items)))))) + +(defn- date [unit expr] + (case unit + :default expr + :minute (hsql/call :date_trunc (hx/literal :minute) expr) + :minute-of-hour (hsql/call :minute expr) + :hour (hsql/call :date_trunc (hx/literal :hour) expr) + :hour-of-day (hsql/call :hour expr) + :day (hsql/call :date_trunc (hx/literal :day) expr) + ;; Presto is ISO compliant, so we need to offset Monday = 1 to Sunday = 1 + :day-of-week (hx/+ (hx/mod (hsql/call :day_of_week expr) 7) 1) + :day-of-month (hsql/call :day expr) + :day-of-year (hsql/call :day_of_year expr) + ;; Similar to DoW, sicne Presto is ISO compliant the week starts on Monday, we need to shift that to Sunday + :week (hsql/call :date_add (hx/literal :day) -1 (hsql/call :date_trunc (hx/literal :week) (hsql/call :date_add (hx/literal :day) 1 expr))) + ;; Offset by one day forward to "fake" a Sunday starting week + :week-of-year (hsql/call :week (hsql/call :date_add (hx/literal :day) 1 expr)) + :month (hsql/call :date_trunc (hx/literal :month) expr) + :month-of-year (hsql/call :month expr) + :quarter (hsql/call :date_trunc (hx/literal :quarter) expr) + :quarter-of-year (hsql/call :quarter expr) + :year (hsql/call :year expr))) + +(defn- string-length-fn [field-key] + (hsql/call :length field-key)) + +(defn- unix-timestamp->timestamp [expr seconds-or-milliseconds] + (case seconds-or-milliseconds + :seconds (hsql/call :from_unixtime expr) + :milliseconds (recur (hx// expr 1000.0) :seconds))) + + +;;; Driver implementation + +(defrecord PrestoDriver [] + clojure.lang.Named + (getName [_] "Presto")) + +(u/strict-extend PrestoDriver + driver/IDriver + (merge (sql/IDriverSQLDefaultsMixin) + {:analyze-table analyze-table + :can-connect? (u/drop-first-arg can-connect?) + :date-interval (u/drop-first-arg date-interval) + :describe-database (u/drop-first-arg describe-database) + :describe-table (u/drop-first-arg describe-table) + :describe-table-fks (constantly nil) ; no FKs in Presto + :details-fields (constantly [{:name "host" + :display-name "Host" + :default "localhost"} + {:name "port" + :display-name "Port" + :type :integer + :default 8080} + {:name "catalog" + :display-name "Database name" + :placeholder "hive" + :required true} + {:name "user" + :display-name "Database username" + :placeholder "What username do you use to login to the database" + :default "metabase"} + {:name "password" + :display-name "Database password" + :type :password + :placeholder "*******"} + {:name "ssl" + :display-name "Use a secure connection (SSL)?" + :type :boolean + :default false}]) + :execute-query (u/drop-first-arg execute-query) + :features (constantly (set/union #{:set-timezone + :basic-aggregations + :standard-deviation-aggregations + :expressions + :native-parameters + :expression-aggregations} + (when-not config/is-test? + ;; during unit tests don't treat presto as having FK support + #{:foreign-keys}))) + :field-values-lazy-seq (u/drop-first-arg field-values-lazy-seq) + :humanize-connection-error-message (u/drop-first-arg humanize-connection-error-message) + :table-rows-seq (u/drop-first-arg table-rows-seq)}) + + sql/ISQLDriver + (merge (sql/ISQLDriverDefaultsMixin) + {:apply-page (u/drop-first-arg apply-page) + :column->base-type (constantly nil) + :connection-details->spec (constantly nil) + :current-datetime-fn (constantly :%now) + :date (u/drop-first-arg date) + :excluded-schemas (constantly #{"information_schema"}) + :field-percent-urls (u/drop-first-arg field-percent-urls) + :prepare-value (u/drop-first-arg prepare-value) + :quote-style (constantly :ansi) + :stddev-fn (constantly :stddev_samp) + :string-length-fn (u/drop-first-arg string-length-fn) + :unix-timestamp->timestamp (u/drop-first-arg unix-timestamp->timestamp)})) + + +(driver/register-driver! :presto (PrestoDriver.)) diff --git a/src/metabase/query_processor/sql_parameters.clj b/src/metabase/query_processor/sql_parameters.clj index 42e2b6d698a8942b6273bc5187d5eed586cee3fc..a48cb7b71d810e10af732b03f382f4a5c1462b52 100644 --- a/src/metabase/query_processor/sql_parameters.clj +++ b/src/metabase/query_processor/sql_parameters.clj @@ -243,13 +243,13 @@ :prepared-statement-args (reduce concat (map :prepared-statement-args replacement-snippet-maps))}) (extend-protocol ISQLParamSubstituion - nil (->replacement-snippet-info [this] (honeysql->replacement-snippet-info this)) - Object (->replacement-snippet-info [this] (honeysql->replacement-snippet-info (str this))) - Number (->replacement-snippet-info [this] (honeysql->replacement-snippet-info this)) - Boolean (->replacement-snippet-info [this] (honeysql->replacement-snippet-info this)) - Keyword (->replacement-snippet-info [this] (honeysql->replacement-snippet-info this)) - SqlCall (->replacement-snippet-info [this] (honeysql->replacement-snippet-info this)) - NoValue (->replacement-snippet-info [_] {:replacement-snippet ""}) + nil (->replacement-snippet-info [this] (honeysql->replacement-snippet-info this)) + Object (->replacement-snippet-info [this] (honeysql->replacement-snippet-info (str this))) + Number (->replacement-snippet-info [this] (honeysql->replacement-snippet-info this)) + Boolean (->replacement-snippet-info [this] (honeysql->replacement-snippet-info this)) + Keyword (->replacement-snippet-info [this] (honeysql->replacement-snippet-info this)) + SqlCall (->replacement-snippet-info [this] (honeysql->replacement-snippet-info this)) + NoValue (->replacement-snippet-info [_] {:replacement-snippet ""}) Date (->replacement-snippet-info [{:keys [s]}] @@ -345,19 +345,25 @@ (s/defn ^:private ^:always-validate handle-optional-snippet :- ParamSnippetInfo "Create the approprate `:replacement-snippet` for PARAM, combining the value of REPLACEMENT-SNIPPET from the Param->SQL Substitution phase with the OPTIONAL-SNIPPET, if any." - [{:keys [variable-snippet optional-snippet replacement-snippet], :as snippet-info} :- ParamSnippetInfo] + [{:keys [variable-snippet optional-snippet replacement-snippet prepared-statement-args], :as snippet-info} :- ParamSnippetInfo] (assoc snippet-info - :replacement-snippet (cond - (not optional-snippet) replacement-snippet ; if there is no optional-snippet return replacement as-is - (seq replacement-snippet) (str/replace optional-snippet variable-snippet replacement-snippet) ; if replacement-snippet is non blank splice into optional-snippet - :else ""))) ; otherwise return blank replacement (i.e. for NoValue) + :replacement-snippet (cond + (not optional-snippet) replacement-snippet ; if there is no optional-snippet return replacement as-is + (seq replacement-snippet) (str/replace optional-snippet variable-snippet replacement-snippet) ; if replacement-snippet is non blank splice into optional-snippet + :else "") ; otherwise return blank replacement (i.e. for NoValue) + ;; for every thime the `variable-snippet` occurs in the `optional-snippet` we need to supply an additional set of `prepared-statment-args` + ;; e.g. [[ AND ID = {{id}} OR USER_ID = {{id}} ]] should have *2* sets of the prepared statement args for {{id}} since it occurs twice + :prepared-statement-args (if-let [occurances (u/occurances-of-substring optional-snippet variable-snippet)] + (apply concat (repeat occurances prepared-statement-args)) + prepared-statement-args))) (s/defn ^:private ^:always-validate add-replacement-snippet-info :- [ParamSnippetInfo] "Add `:replacement-snippet` and `:prepared-statement-args` info to the maps in PARAMS-SNIPPETS-INFO by looking at PARAM-KEY->VALUE and using the Param->SQL substituion functions." [params-snippets-info :- [ParamSnippetInfo], param-key->value :- ParamValues] (for [snippet-info params-snippets-info] - (handle-optional-snippet (merge snippet-info (s/validate ParamSnippetInfo (->replacement-snippet-info (snippet-value snippet-info param-key->value))))))) + (handle-optional-snippet (merge snippet-info + (s/validate ParamSnippetInfo (->replacement-snippet-info (snippet-value snippet-info param-key->value))))))) diff --git a/src/metabase/sync_database/analyze.clj b/src/metabase/sync_database/analyze.clj index c21efd882d27f4931166737435daaf35cc22655a..bad37a0c7a53b7e267668e923e7fc08daa921f1b 100644 --- a/src/metabase/sync_database/analyze.clj +++ b/src/metabase/sync_database/analyze.clj @@ -186,12 +186,13 @@ (defn make-analyze-table "Make a generic implementation of `analyze-table`." {:style/indent 1} - [driver & {:keys [field-avg-length-fn field-percent-urls-fn] + [driver & {:keys [field-avg-length-fn field-percent-urls-fn calculate-row-count?] :or {field-avg-length-fn (partial driver/default-field-avg-length driver) - field-percent-urls-fn (partial driver/default-field-percent-urls driver)}}] + field-percent-urls-fn (partial driver/default-field-percent-urls driver) + calculate-row-count? true}}] (fn [driver table new-field-ids] (let [driver (assoc driver :field-avg-length field-avg-length-fn, :field-percent-urls field-percent-urls-fn)] - {:row_count (u/try-apply table-row-count table) + {:row_count (when calculate-row-count? (u/try-apply table-row-count table)) :fields (for [{:keys [id] :as field} (table/fields table)] (let [new-field? (contains? new-field-ids id)] (cond->> {:id id} diff --git a/src/metabase/util.clj b/src/metabase/util.clj index 993e311ee7345c395d357e0497ef820db8318ca2..d73dc84cf240d6b47312984f5ef8b545f9d7baaf 100644 --- a/src/metabase/util.clj +++ b/src/metabase/util.clj @@ -822,3 +822,12 @@ "Increment N if it is non-`nil`, otherwise return `1` (e.g. as if incrementing `0`)." [n] (if n (inc n) 1)) + +(defn occurances-of-substring + "Return the number of times SUBSTR occurs in string S." + ^Integer [^String s, ^String substr] + (when (and (seq s) (seq substr)) + (loop [index 0, cnt 0] + (if-let [new-index (s/index-of s substr index)] + (recur (inc new-index) (inc cnt)) + cnt)))) diff --git a/src/metabase/util/urls.clj b/src/metabase/util/urls.clj index db1c2948f50289fd71a4222729b61d59259f920c..3c27b5ee2033afecfc12bad23d45101565f0e1e0 100644 --- a/src/metabase/util/urls.clj +++ b/src/metabase/util/urls.clj @@ -18,16 +18,16 @@ (defn dashboard-url "Return an appropriate URL for a `Dashboard` with ID. - (dashboard-url 10) -> \"http://localhost:3000/dash/10\"" + (dashboard-url 10) -> \"http://localhost:3000/dashboard/10\"" [^Integer id] - (format "%s/dash/%d" (public-settings/site-url) id)) + (format "%s/dashboard/%d" (public-settings/site-url) id)) (defn card-url "Return an appropriate URL for a `Card` with ID. - (card-url 10) -> \"http://localhost:3000/card/10\"" + (card-url 10) -> \"http://localhost:3000/question/10\"" [^Integer id] - (format "%s/card/%d" (public-settings/site-url) id)) + (format "%s/question/%d" (public-settings/site-url) id)) (defn segment-url "Return an appropriate URL for a `Segment` with ID. diff --git a/test/metabase/driver/bigquery_test.clj b/test/metabase/driver/bigquery_test.clj index d6767253f02cc9514439c1ae831eef331aeb3370..97fd5c353f76e42e5f0d4f75ae0ece8080f29f40 100644 --- a/test/metabase/driver/bigquery_test.clj +++ b/test/metabase/driver/bigquery_test.clj @@ -2,6 +2,7 @@ (:require metabase.driver.bigquery [metabase.models.database :as database] [metabase.query-processor :as qp] + [metabase.query-processor-test :as qptest] [metabase.test.data :as data] (metabase.test.data [datasets :refer [expect-with-engine]] [interface :refer [def-database-definition]]))) @@ -29,3 +30,14 @@ :type :native :database (data/id)})) [:cols :columns])) + +;; make sure that the bigquery driver can handle named columns with characters that aren't allowed in BQ itself +(expect-with-engine :bigquery + {:rows [[113]] + :columns ["User_ID_Plus_Venue_ID"]} + (qptest/rows+column-names (qp/process-query {:database (data/id) + :type "query" + :query {:source_table (data/id :checkins) + :aggregation [["named" ["max" ["+" ["field-id" (data/id :checkins :user_id)] + ["field-id" (data/id :checkins :venue_id)]]] + "User ID Plus Venue ID"]]}}))) diff --git a/test/metabase/driver/generic_sql/util/unprepare_test.clj b/test/metabase/driver/generic_sql/util/unprepare_test.clj index b20a4d91e9bbf2cad244e8258bd19c5de1c573de..ce98bb08df85eeff569c6943d0b1a9155d57d096 100644 --- a/test/metabase/driver/generic_sql/util/unprepare_test.clj +++ b/test/metabase/driver/generic_sql/util/unprepare_test.clj @@ -8,3 +8,12 @@ "Cam's Cool Toucan" true #inst "2017-01-01T00:00:00.000Z"])) + +(expect + "SELECT 'Cam''s Cool Toucan' FROM TRUE WHERE x ?? y AND z = from_iso8601_timestamp('2017-01-01T00:00:00.000Z')" + (unprepare/unprepare ["SELECT ? FROM ? WHERE x ?? y AND z = ?" + "Cam's Cool Toucan" + true + #inst "2017-01-01T00:00:00.000Z"] + :quote-escape "'" + :iso-8601-fn :from_iso8601_timestamp)) diff --git a/test/metabase/driver/generic_sql_test.clj b/test/metabase/driver/generic_sql_test.clj index bbb68a74743a7d5775c342a05c8827bc168405f3..4c51952cf9e49066ce8c3f5276433e1511db5218 100644 --- a/test/metabase/driver/generic_sql_test.clj +++ b/test/metabase/driver/generic_sql_test.clj @@ -19,7 +19,7 @@ (def ^:private generic-sql-engines (delay (set (for [engine datasets/all-valid-engines :let [driver (driver/engine->driver engine)] - :when (not= engine :bigquery) ; bigquery doesn't use the generic sql implementations of things like `field-avg-length` + :when (not (contains? #{:bigquery :presto} engine)) ; bigquery and presto don't use the generic sql implementations of things like `field-avg-length` :when (extends? ISQLDriver (class driver))] (do (require (symbol (str "metabase.test.data." (name engine))) :reload) ; otherwise it gets all snippy if you try to do `lein test metabase.driver.generic-sql-test` engine))))) diff --git a/test/metabase/driver/mongo_test.clj b/test/metabase/driver/mongo_test.clj index 3332a298277eff2370b30b79f5f3f47781186cdc..1387e674bc6939b26d293749344eb763b1c821f4 100644 --- a/test/metabase/driver/mongo_test.clj +++ b/test/metabase/driver/mongo_test.clj @@ -175,21 +175,33 @@ (ql/filter (ql/= $bird_id "abcdefabcdefabcdefabcdef")))))) -;;; ------------------------------------------------------------ Test that we can handle native queries with "ISODate(...)" forms (#3741) ------------------------------------------------------------ +;;; ------------------------------------------------------------ Test that we can handle native queries with "ISODate(...)" and "ObjectId(...) forms (#3741, #4448) ------------------------------------------------------------ (tu/resolve-private-vars metabase.driver.mongo.query-processor - maybe-decode-iso-date-fncall decode-iso-date-fncalls encode-iso-date-fncalls) + maybe-decode-fncall decode-fncalls encode-fncalls) (expect "[{\"$match\":{\"date\":{\"$gte\":[\"___ISODate\", \"2012-01-01\"]}}}]" - (encode-iso-date-fncalls "[{\"$match\":{\"date\":{\"$gte\":ISODate(\"2012-01-01\")}}}]")) + (encode-fncalls "[{\"$match\":{\"date\":{\"$gte\":ISODate(\"2012-01-01\")}}}]")) + +(expect + "[{\"$match\":{\"entityId\":{\"$eq\":[\"___ObjectId\", \"583327789137b2700a1621fb\"]}}}]" + (encode-fncalls "[{\"$match\":{\"entityId\":{\"$eq\":ObjectId(\"583327789137b2700a1621fb\")}}}]")) (expect (DateTime. "2012-01-01") - (maybe-decode-iso-date-fncall ["___ISODate" "2012-01-01"])) + (maybe-decode-fncall ["___ISODate" "2012-01-01"])) + +(expect + (ObjectId. "583327789137b2700a1621fb") + (maybe-decode-fncall ["___ObjectId" "583327789137b2700a1621fb"])) (expect [{:$match {:date {:$gte (DateTime. "2012-01-01")}}}] - (decode-iso-date-fncalls [{:$match {:date {:$gte ["___ISODate" "2012-01-01"]}}}])) + (decode-fncalls [{:$match {:date {:$gte ["___ISODate" "2012-01-01"]}}}])) + +(expect + [{:$match {:entityId {:$eq (ObjectId. "583327789137b2700a1621fb")}}}] + (decode-fncalls [{:$match {:entityId {:$eq ["___ObjectId" "583327789137b2700a1621fb"]}}}])) (datasets/expect-with-engine :mongo 5 @@ -197,3 +209,11 @@ :collection "checkins"} :type :native :database (data/id)})))) + +(datasets/expect-with-engine :mongo + 0 + ;; this query shouldn't match anything, so we're just checking that it completes successfully + (count (rows (qp/process-query {:native {:query "[{\"$match\": {\"_id\": {\"$eq\": ObjectId(\"583327789137b2700a1621fb\")}}}]" + :collection "venues"} + :type :native + :database (data/id)})))) diff --git a/test/metabase/driver/presto_test.clj b/test/metabase/driver/presto_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..86502b962ef26390c1e6f3ae9c777b69409dc94d --- /dev/null +++ b/test/metabase/driver/presto_test.clj @@ -0,0 +1,143 @@ +(ns metabase.driver.presto-test + (:require [expectations :refer :all] + [toucan.db :as db] + [metabase.driver :as driver] + [metabase.driver.generic-sql :as sql] + [metabase.models.table :as table] + [metabase.test.data :as data] + [metabase.test.data.datasets :as datasets] + [metabase.test.util :refer [resolve-private-vars]]) + (:import (metabase.driver.presto PrestoDriver))) + +(resolve-private-vars metabase.driver.presto details->uri details->request parse-presto-results quote-name quote+combine-names apply-page) + +;;; HELPERS + +(expect + "http://localhost:8080/" + (details->uri {:host "localhost", :port 8080, :ssl false} "/")) + +(expect + "https://localhost:8443/" + (details->uri {:host "localhost", :port 8443, :ssl true} "/")) + +(expect + "http://localhost:8080/v1/statement" + (details->uri {:host "localhost", :port 8080, :ssl false} "/v1/statement")) + +(expect + {:headers {"X-Presto-Source" "metabase" + "X-Presto-User" "user"}} + (details->request {:user "user"})) + +(expect + {:headers {"X-Presto-Source" "metabase" + "X-Presto-User" "user"} + :basic-auth ["user" "test"]} + (details->request {:user "user", :password "test"})) + +(expect + {:headers {"X-Presto-Source" "metabase" + "X-Presto-User" "user" + "X-Presto-Catalog" "test_data" + "X-Presto-Time-Zone" "America/Toronto"}} + (details->request {:user "user", :catalog "test_data", :report-timezone "America/Toronto"})) + +(expect + [["2017-04-03" + #inst "2017-04-03T14:19:17.417000000-00:00" + #inst "2017-04-03T10:19:17.417000000-00:00" + 3.1416M + "test"]] + (parse-presto-results [{:type "date"} {:type "timestamp with time zone"} {:type "timestamp"} {:type "decimal(10,4)"} {:type "varchar(255)"}] + [["2017-04-03", "2017-04-03 10:19:17.417 America/Toronto", "2017-04-03 10:19:17.417", "3.1416", "test"]])) + +(expect + "\"weird.table\"\" name\"" + (quote-name "weird.table\" name")) + +(expect + "\"weird . \"\"schema\".\"weird.table\"\" name\"" + (quote+combine-names "weird . \"schema" "weird.table\" name")) + +;; DESCRIBE-DATABASE +(datasets/expect-with-engine :presto + {:tables #{{:name "categories" :schema "default"} + {:name "venues" :schema "default"} + {:name "checkins" :schema "default"} + {:name "users" :schema "default"}}} + (driver/describe-database (PrestoDriver.) (data/db))) + +;; DESCRIBE-TABLE +(datasets/expect-with-engine :presto + {:name "venues" + :schema "default" + :fields #{{:name "name", + :base-type :type/Text} + {:name "latitude" + :base-type :type/Float} + {:name "longitude" + :base-type :type/Float} + {:name "price" + :base-type :type/Integer} + {:name "category_id" + :base-type :type/Integer} + {:name "id" + :base-type :type/Integer}}} + (driver/describe-table (PrestoDriver.) (data/db) (db/select-one 'Table :id (data/id :venues)))) + +;;; ANALYZE-TABLE +(datasets/expect-with-engine :presto + {:row_count 100 + :fields [{:id (data/id :venues :category_id), :values [2 3 4 5 6 7 10 11 12 13 14 15 18 19 20 29 40 43 44 46 48 49 50 58 64 67 71 74]} + {:id (data/id :venues :id)} + {:id (data/id :venues :latitude)} + {:id (data/id :venues :longitude)} + {:id (data/id :venues :name), :values (db/select-one-field :values 'FieldValues, :field_id (data/id :venues :name))} + {:id (data/id :venues :price), :values [1 2 3 4]}]} + (let [venues-table (db/select-one 'Table :id (data/id :venues))] + (driver/analyze-table (PrestoDriver.) venues-table (set (mapv :id (table/fields venues-table)))))) + +;;; FIELD-VALUES-LAZY-SEQ +(datasets/expect-with-engine :presto + ["Red Medicine" + "Stout Burgers & Beers" + "The Apple Pan" + "Wurstküche" + "Brite Spot Family Restaurant"] + (take 5 (driver/field-values-lazy-seq (PrestoDriver.) (db/select-one 'Field :id (data/id :venues :name))))) + +;;; TABLE-ROWS-SEQ +(datasets/expect-with-engine :presto + [{:name "Red Medicine", :price 3, :category_id 4, :id 1} + {:name "Stout Burgers & Beers", :price 2, :category_id 11, :id 2} + {:name "The Apple Pan", :price 2, :category_id 11, :id 3} + {:name "Wurstküche", :price 2, :category_id 29, :id 4} + {:name "Brite Spot Family Restaurant", :price 2, :category_id 20, :id 5}] + (for [row (take 5 (sort-by :id (driver/table-rows-seq (PrestoDriver.) + (db/select-one 'Database :id (data/id)) + (db/select-one 'RawTable :id (db/select-one-field :raw_table_id 'Table, :id (data/id :venues))))))] + (-> (dissoc row :latitude :longitude) + (update :price int) + (update :category_id int) + (update :id int)))) + +;;; FIELD-PERCENT-URLS +(datasets/expect-with-engine :presto + 0.5 + (data/dataset half-valid-urls + (sql/field-percent-urls (PrestoDriver.) (db/select-one 'Field :id (data/id :urls :url))))) + +;;; APPLY-PAGE +(expect + {:select ["name" "id"] + :from [{:select [[:default.categories.name "name"] [:default.categories.id "id"] [{:s "row_number() OVER (ORDER BY \"default\".\"categories\".\"id\" ASC)"} :__rownum__]] + :from [:default.categories] + :order-by [[:default.categories.id :asc]]}] + :where [:> :__rownum__ 5] + :limit 5} + (apply-page {:select [[:default.categories.name "name"] [:default.categories.id "id"]] + :from [:default.categories] + :order-by [[:default.categories.id :asc]]} + {:page {:page 2 + :items 5}})) diff --git a/test/metabase/query_processor/sql_parameters_test.clj b/test/metabase/query_processor/sql_parameters_test.clj index 44585c279c3a8bc65e8cdd29c943f5bf1c0a2c46..dda33ecf8b2442c1e0c354b4914fd7f1595774aa 100644 --- a/test/metabase/query_processor/sql_parameters_test.clj +++ b/test/metabase/query_processor/sql_parameters_test.clj @@ -428,9 +428,10 @@ (generic-sql/quote-name datasets/*driver* identifier)) (defn- checkins-identifier [] - ;; HACK ! I don't have all day to write protocol methods to make this work the "right" way so for BigQuery we will just hackily return the correct identifier here - (if (= datasets/*engine* :bigquery) - "[test_data.checkins]" + ;; HACK ! I don't have all day to write protocol methods to make this work the "right" way so for BigQuery and Presto we will just hackily return the correct identifier here + (case datasets/*engine* + :bigquery "[test_data.checkins]" + :presto "\"default\".\"checkins\"" (let [{table-name :name, schema :schema} (db/select-one ['Table :name :schema], :id (data/id :checkins))] (str (when (seq schema) (str (quote-name schema) \.)) @@ -527,3 +528,22 @@ :native {:query "SELECT count(*) FROM PRODUCTS WHERE TITLE LIKE {{x}}", :template_tags {:x {:name "x", :display_name "X", :type "text", :required true, :default "%Toucan%"}}}, :parameters [{:type "category", :target ["variable" ["template-tag" "x"]]}]}))) + +;; make sure that you can use the same parameter multiple times (#4659) +(expect + {:query "SELECT count(*) FROM products WHERE title LIKE ? AND subtitle LIKE ?" + :template_tags {:x {:name "x", :display_name "X", :type "text", :required true, :default "%Toucan%"}} + :params ["%Toucan%" "%Toucan%"]} + (:native (expand-params {:driver (driver/engine->driver :h2) + :native {:query "SELECT count(*) FROM products WHERE title LIKE {{x}} AND subtitle LIKE {{x}}", + :template_tags {:x {:name "x", :display_name "X", :type "text", :required true, :default "%Toucan%"}}}, + :parameters [{:type "category", :target ["variable" ["template-tag" "x"]]}]}))) + +(expect + {:query "SELECT * FROM ORDERS WHERE true AND ID = ? OR USER_ID = ?" + :template_tags {:id {:name "id", :display_name "ID", :type "text"}} + :params ["2" "2"]} + (:native (expand-params {:driver (driver/engine->driver :h2) + :native {:query "SELECT * FROM ORDERS WHERE true [[ AND ID = {{id}} OR USER_ID = {{id}} ]]" + :template_tags {:id {:name "id", :display_name "ID", :type "text"}}} + :parameters [{:type "category", :target ["variable" ["template-tag" "id"]], :value "2"}]}))) diff --git a/test/metabase/query_processor_test/aggregation_test.clj b/test/metabase/query_processor_test/aggregation_test.clj index 759d5e26e57a62c485d473a0a5534ccaf22ad3fd..d6b8267866176ce7e2bc01ae7810ebcb0366b60f 100644 --- a/test/metabase/query_processor_test/aggregation_test.clj +++ b/test/metabase/query_processor_test/aggregation_test.clj @@ -149,8 +149,8 @@ (ql/aggregation (ql/avg $price) (ql/count) (ql/sum $price)))))) ;; make sure that multiple aggregations of the same type have the correct metadata (#4003) -;; (TODO - this isn't tested against Mongo or BigQuery because those drivers don't currently work correctly with multiple columns with the same name) -(datasets/expect-with-engines (disj non-timeseries-engines :mongo :bigquery) +;; (TODO - this isn't tested against Mongo, BigQuery or Presto because those drivers don't currently work correctly with multiple columns with the same name) +(datasets/expect-with-engines (disj non-timeseries-engines :mongo :bigquery :presto) [(aggregate-col :count) (assoc (aggregate-col :count) :display_name "count_2" diff --git a/test/metabase/query_processor_test/date_bucketing_test.clj b/test/metabase/query_processor_test/date_bucketing_test.clj index eeee25db76c9fc3660e461a2f403d29bcd9cb6a5..603e8d93057fb3fdafc4cd9473181f0a7b6fbef5 100644 --- a/test/metabase/query_processor_test/date_bucketing_test.clj +++ b/test/metabase/query_processor_test/date_bucketing_test.clj @@ -37,7 +37,7 @@ ["2015-06-02 08:20:00" 1] ["2015-06-02 11:11:00" 1]] - (contains? #{:redshift :sqlserver :bigquery :mongo :postgres :vertica :h2 :oracle} *engine*) + (contains? #{:redshift :sqlserver :bigquery :mongo :postgres :vertica :h2 :oracle :presto} *engine*) [["2015-06-01T10:31:00.000Z" 1] ["2015-06-01T16:06:00.000Z" 1] ["2015-06-01T17:23:00.000Z" 1] @@ -246,7 +246,7 @@ (contains? #{:sqlserver :sqlite :crate :oracle} *engine*) [[23 54] [24 46] [25 39] [26 61]] - (contains? #{:mongo :redshift :bigquery :postgres :vertica :h2} *engine*) + (contains? #{:mongo :redshift :bigquery :postgres :vertica :h2 :presto} *engine*) [[23 46] [24 47] [25 40] [26 60] [27 7]] :else diff --git a/test/metabase/test/data/presto.clj b/test/metabase/test/data/presto.clj new file mode 100644 index 0000000000000000000000000000000000000000..790907b1abf8aef6f032d5bdc710f1c8ee235c7c --- /dev/null +++ b/test/metabase/test/data/presto.clj @@ -0,0 +1,107 @@ +(ns metabase.test.data.presto + (:require [clojure.string :as s] + [environ.core :refer [env]] + (honeysql [core :as hsql] + [helpers :as h]) + [metabase.driver.generic-sql.util.unprepare :as unprepare] + [metabase.test.data.interface :as i] + [metabase.test.util :refer [resolve-private-vars]] + [metabase.util :as u] + [metabase.util.honeysql-extensions :as hx]) + (:import java.util.Date + metabase.driver.presto.PrestoDriver + (metabase.query_processor.interface DateTimeValue Value))) + +(resolve-private-vars metabase.driver.presto execute-presto-query! presto-type->base-type quote-name quote+combine-names) + +;;; Helpers + +(defn- get-env-var [env-var] + (or (env (keyword (format "mb-presto-%s" (name env-var)))) + (throw (Exception. (format "In order to test Presto, you must specify the env var MB_PRESTO_%s." + (s/upper-case (s/replace (name env-var) #"-" "_"))))))) + + +;;; IDatasetLoader implementation + +(defn- database->connection-details [context {:keys [database-name]}] + (merge {:host (get-env-var :host) + :port (get-env-var :port) + :user "metabase" + :ssl false} + (when (= context :db) + {:catalog database-name}))) + +(defn- qualify-name + ;; we have to use the default schema from the in-memory connectory + ([db-name] [db-name]) + ([db-name table-name] [db-name "default" table-name]) + ([db-name table-name field-name] [db-name "default" table-name field-name])) + +(defn- qualify+quote-name [& names] + (apply quote+combine-names (apply qualify-name names))) + +(defn- field-base-type->dummy-value [field-type] + ;; we need a dummy value for every base-type to make a properly typed SELECT statement + (if (keyword? field-type) + (case field-type + :type/Boolean "TRUE" + :type/Integer "1" + :type/BigInteger "cast(1 AS bigint)" + :type/Float "1.0" + :type/Decimal "DECIMAL '1.0'" + :type/Text "cast('' AS varchar(255))" + :type/Date "current_timestamp" ; this should probably be a date type, but the test data begs to differ + :type/DateTime "current_timestamp" + "from_hex('00')") ; this might not be the best default ever + ;; we were given a native type, map it back to a base-type and try again + (field-base-type->dummy-value (presto-type->base-type field-type)))) + +(defn- create-table-sql [{:keys [database-name]} {:keys [table-name], :as tabledef}] + (let [field-definitions (conj (:field-definitions tabledef) {:field-name "id", :base-type :type/Integer}) + dummy-values (map (comp field-base-type->dummy-value :base-type) field-definitions) + columns (map :field-name field-definitions)] + ;; Presto won't let us use the `CREATE TABLE (...)` form, but we can still do it creatively if we select the right types out of thin air + (format "CREATE TABLE %s AS SELECT * FROM (VALUES (%s)) AS t (%s) WHERE 1 = 0" + (qualify+quote-name database-name table-name) + (s/join \, dummy-values) + (s/join \, (map quote-name columns))))) + +(defn- drop-table-if-exists-sql [{:keys [database-name]} {:keys [table-name]}] + (str "DROP TABLE IF EXISTS " (qualify+quote-name database-name table-name))) + +(defn- insert-sql [{:keys [database-name]} {:keys [table-name], :as tabledef} rows] + (let [field-definitions (conj (:field-definitions tabledef) {:field-name "id"}) + columns (map (comp keyword :field-name) field-definitions) + [query & params] (-> (apply h/columns columns) + (h/insert-into (apply hsql/qualify (qualify-name database-name table-name))) + (h/values rows) + (hsql/format :allow-dashed-names? true, :quoting :ansi))] + (if (nil? params) + query + (unprepare/unprepare (cons query params) :quote-escape "'", :iso-8601-fn :from_iso8601_timestamp)))) + +(defn- create-db! [{:keys [table-definitions] :as dbdef}] + (let [details (database->connection-details :db dbdef)] + (doseq [tabledef table-definitions + :let [rows (:rows tabledef) + keyed-rows (map-indexed (fn [i row] (conj row (inc i))) rows) ; generate an ID for each row because we don't have auto increments + batches (partition 100 100 nil keyed-rows)]] ; make 100 rows batches since we have to inline everything + (execute-presto-query! details (drop-table-if-exists-sql dbdef tabledef)) + (execute-presto-query! details (create-table-sql dbdef tabledef)) + (doseq [batch batches] + (execute-presto-query! details (insert-sql dbdef tabledef batch)))))) + + +;;; IDatasetLoader implementation + +(u/strict-extend PrestoDriver + i/IDatasetLoader + (merge i/IDatasetLoaderDefaultsMixin + {:engine (constantly :presto) + :database->connection-details (u/drop-first-arg database->connection-details) + :create-db! (u/drop-first-arg create-db!) + :default-schema (constantly "default") + :format-name (u/drop-first-arg s/lower-case) + ;; FIXME Presto actually has very good timezone support + :has-questionable-timezone-support? (constantly true)})) diff --git a/test/metabase/util_test.clj b/test/metabase/util_test.clj index a17d043795dad96442589aaabc2451c7c9f6fe03..8520c9866e9978d35e3ba369980f980ee988d8ef 100644 --- a/test/metabase/util_test.clj +++ b/test/metabase/util_test.clj @@ -204,9 +204,29 @@ (select-nested-keys {} [:c])) -;; tests for base-64-string? +;;; tests for base-64-string? (expect (base-64-string? "ABc")) (expect (base-64-string? "ABc/+asdasd==")) (expect false (base-64-string? 100)) (expect false (base-64-string? "<<>>")) (expect false (base-64-string? "{\"a\": 10}")) + + +;;; tests for `occurances-of-substring` + +;; return nil if one or both strings are nil or empty +(expect nil (occurances-of-substring nil nil)) +(expect nil (occurances-of-substring nil "")) +(expect nil (occurances-of-substring "" nil)) +(expect nil (occurances-of-substring "" "")) +(expect nil (occurances-of-substring "ABC" "")) +(expect nil (occurances-of-substring "" " ABC")) + +(expect 1 (occurances-of-substring "ABC" "A")) +(expect 2 (occurances-of-substring "ABA" "A")) +(expect 3 (occurances-of-substring "AAA" "A")) + +(expect 0 (occurances-of-substring "ABC" "{{id}}")) +(expect 1 (occurances-of-substring "WHERE ID = {{id}}" "{{id}}")) +(expect 2 (occurances-of-substring "WHERE ID = {{id}} OR USER_ID = {{id}}" "{{id}}")) +(expect 3 (occurances-of-substring "WHERE ID = {{id}} OR USER_ID = {{id}} OR TOUCAN_ID = {{id}} OR BIRD_ID = {{bird}}" "{{id}}")) diff --git a/webpack.config.js b/webpack.config.js index 6183bf73437d7e3d01f62dc603001ac35541fb55..ee560eb282106139e925d4155bb37dc81732a63a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -9,6 +9,7 @@ var webpackPostcssTools = require('webpack-postcss-tools'); var ExtractTextPlugin = require('extract-text-webpack-plugin'); var HtmlWebpackPlugin = require('html-webpack-plugin'); +var HtmlWebpackHarddiskPlugin = require('html-webpack-harddisk-plugin'); var UnusedFilesWebpackPlugin = require("unused-files-webpack-plugin").default; var BannerWebpackPlugin = require('banner-webpack-plugin'); @@ -27,18 +28,16 @@ function hasArg(arg) { var SRC_PATH = __dirname + '/frontend/src/metabase'; var BUILD_PATH = __dirname + '/resources/frontend_client'; +// default NODE_ENV to development +var NODE_ENV = process.env["NODE_ENV"] || "development"; // Need to scan the CSS files for variable and custom media used across files // NOTE: this requires "webpack -w" (watch mode) to be restarted when variables change :( -var isWatching = hasArg("-w") || hasArg("--watch"); -if (isWatching) { - console.warn("Warning: in webpack watch mode you must restart webpack if you change any CSS variables or custom media queries"); +var IS_WATCHING = hasArg("-w") || hasArg("--watch"); +if (IS_WATCHING) { + process.stderr.write("Warning: in webpack watch mode you must restart webpack if you change any CSS variables or custom media queries\n"); } -// default NODE_ENV to development -var NODE_ENV = process.env["NODE_ENV"] || "development"; -process.stderr.write("webpack env: " + NODE_ENV + "\n"); - // Babel: var BABEL_CONFIG = { cacheDirectory: ".babel_cache" @@ -159,19 +158,25 @@ var config = module.exports = { filename: '../../index.html', chunks: ["app-main", "styles"], template: __dirname + '/resources/frontend_client/index_template.html', - inject: 'head' + inject: 'head', + alwaysWriteToDisk: true, }), new HtmlWebpackPlugin({ filename: '../../public.html', chunks: ["app-public", "styles"], template: __dirname + '/resources/frontend_client/index_template.html', - inject: 'head' + inject: 'head', + alwaysWriteToDisk: true, }), new HtmlWebpackPlugin({ filename: '../../embed.html', chunks: ["app-embed", "styles"], template: __dirname + '/resources/frontend_client/index_template.html', - inject: 'head' + inject: 'head', + alwaysWriteToDisk: true, + }), + new HtmlWebpackHarddiskPlugin({ + outputPath: __dirname + '/resources/frontend_client/app/dist' }), new webpack.DefinePlugin({ 'process.env': { diff --git a/yarn.lock b/yarn.lock index 65023c03997031bf46ff207d48f499d916c07e1d..a343edb47001c17cfd759be5ee61eccd6f0f3c6e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3723,6 +3723,12 @@ html-minifier@^3.2.3: relateurl "0.2.x" uglify-js "2.7.x" +html-webpack-harddisk-plugin@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/html-webpack-harddisk-plugin/-/html-webpack-harddisk-plugin-0.1.0.tgz#432024961a21ac668fa2b5dfe24629c60b9c58d7" + dependencies: + mkdirp "^0.5.1" + html-webpack-plugin@^2.14.0: version "2.28.0" resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-2.28.0.tgz#2e7863b57e5fd48fe263303e2ffc934c3064d009"