diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index a8f484af147aca756f0f64c38b4d3ec0c00dda0e..614c0ce6f34c577fbfb4c3ae2722290e36309362 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -3,7 +3,7 @@ Before filing an issue we'd appreciate it if you could take a moment to ensure there isn't already an open issue or pull-request. ----- -If there's an exisiting issue, please add a :+1: reaction to the description of +If there's an existing issue, please add a :+1: reaction to the description of the issue. One way we prioritize issues is by the number of :+1: reactions on their descriptions. Please DO NOT add `+1` or :+1: comments. diff --git a/bin/ci b/bin/ci index 71f0748400d49527e2c1ddae35702b49c6666f01..1fa09e48d704843a77ae31db3e264dcff9d0ab7a 100755 --- a/bin/ci +++ b/bin/ci @@ -55,13 +55,15 @@ node-6() { if is_enabled "jar" || is_enabled "e2e" || is_enabled "screenshots"; then run_step ./bin/build version frontend-fast sample-dataset uberjar fi - if is_enabled "e2e" || is_enabled "compare_screenshots"; then - USE_SAUCE=true \ - run_step yarn run test-e2e - fi - if is_enabled "screenshots"; then - run_step node_modules/.bin/babel-node ./bin/compare-screenshots - fi + + # TODO Atte Keinänen 6/22/17: Disabled due to Sauce problems, all tests will be converted to use Jest and Enzyme + # if is_enabled "e2e" || is_enabled "compare_screenshots"; then + # USE_SAUCE=true \ + # run_step yarn run test-e2e + # fi + # if is_enabled "screenshots"; then + # run_step node_modules/.bin/babel-node ./bin/compare-screenshots + # fi } diff --git a/docs/api-documentation.md b/docs/api-documentation.md index 65de1da123cf064922ec7ea94982e8304d44186f..cc23fffd5a56d5be31d344cfaf6074e891da5654 100644 --- a/docs/api-documentation.md +++ b/docs/api-documentation.md @@ -1193,7 +1193,7 @@ Fetch the results for a Card in a publically-accessible Dashboard. Does not requ ## `GET /api/public/oembed` -oEmbed endpoint used to retreive embed code and metadata for a (public) Metabase URL. +oEmbed endpoint used to retrieve embed code and metadata for a (public) Metabase URL. ##### PARAMS: @@ -1709,7 +1709,7 @@ Fetch the current `User`. ## `POST /api/user/` -Create a new `User`, or or reäctivate an existing one. +Create a new `User`, or or reactivate an existing one. You must be a superuser to do this. @@ -1783,7 +1783,7 @@ You must be a superuser to do this. ## `GET /api/util/random_token` -Return a cryptographically secure random 32-byte token, encoded as a hexidecimal string. +Return a cryptographically secure random 32-byte token, encoded as a hexadecimal string. Intended for use when creating a value for `embedding-secret-key`. @@ -1801,4 +1801,4 @@ Endpoint that checks if the supplied password meets the currently configured pas ##### PARAMS: -* **`password`** Insufficient password strength \ No newline at end of file +* **`password`** Insufficient password strength diff --git a/docs/users-guide/09-multi-series-charting.md b/docs/users-guide/09-multi-series-charting.md index bec40797ed17296cd0682e75b142695c5b664429..f626a7d641e247ff534e6490deb208321614a3fb 100644 --- a/docs/users-guide/09-multi-series-charting.md +++ b/docs/users-guide/09-multi-series-charting.md @@ -30,7 +30,7 @@ If you already have two or more saved questions you’d like to compare, and the  -The X and Y axis will automagically update if necessary and Metabase will create a legend using the existing card titles to help you understand which question maps to which series on the chart. Repeat this process as many times as you need. +The X and Y axis will automatically update if necessary and Metabase will create a legend using the existing card titles to help you understand which question maps to which series on the chart. Repeat this process as many times as you need. To remove a series either uncheck the box, or click the x next to the title in the legend above the chart. diff --git a/frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx b/frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx index 408f9c77a93b6fc7f8c1f0696d44813d9f1a327a..4d0a9af82e0b7026b8c1ade8d94ed592a74d0463 100644 --- a/frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx +++ b/frontend/src/metabase/admin/people/containers/PeopleListingApp.jsx @@ -386,6 +386,10 @@ export default class PeopleListingApp extends Component { <Tooltip tooltip="Signed up via Google"> <Icon name='google' /> </Tooltip> : null} + {user.ldap_auth ? + <Tooltip tooltip="Signed up via LDAP"> + <Icon name='ldap' /> + </Tooltip> : null } </td> <td>{user.email}</td> <td> diff --git a/frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx b/frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx new file mode 100644 index 0000000000000000000000000000000000000000..681bb537f7e2b03abb1d2c47915ffd813f1eace1 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx @@ -0,0 +1,250 @@ +import React, { Component, PropTypes } from "react"; + +import _ from "underscore"; +import cx from "classnames"; + +import Collapse from "react-collapse"; + +import DisclosureTriangle from "metabase/components/DisclosureTriangle"; +import MetabaseUtils from "metabase/lib/utils"; +import SettingsSetting from "./SettingsSetting"; + +export default class SettingsLdapForm extends Component { + constructor(props, context) { + super(props, context); + this.state = { + formData: {}, + showAttributes: false, + submitting: "default", + valid: false, + validationErrors: {} + } + } + + static propTypes = { + elements: PropTypes.array.isRequired, + formErrors: PropTypes.object, + updateLdapSettings: PropTypes.func.isRequired + }; + + componentWillMount() { + // this gives us an opportunity to load up our formData with any existing values for elements + this.updateFormData(this.props); + } + + componentWillReceiveProps(nextProps) { + this.updateFormData(nextProps); + } + + updateFormData(props) { + let formData = {}; + for (const element of props.elements) { + formData[element.key] = element.value; + } + this.setState({ formData }); + } + + componentDidMount() { + this.validateForm(); + } + + componentDidUpdate() { + this.validateForm(); + } + + setSubmitting(submitting) { + this.setState({submitting}); + } + + setFormErrors(formErrors) { + this.setState({formErrors}); + } + + // return null if element passes validation, otherwise return an error message + validateElement([validationType, validationMessage], value, element) { + if (MetabaseUtils.isEmpty(value)) return; + + switch (validationType) { + case "email": + return !MetabaseUtils.validEmail(value) ? (validationMessage || "That's not a valid email address") : null; + case "integer": + return isNaN(parseInt(value)) ? (validationMessage || "That's not a valid integer") : null; + case "ldap_filter": + return (value.match(/\(/g) || []).length !== (value.match(/\)/g) || []).length ? (validationMessage || "Check your parentheses") : null; + } + } + + validateForm() { + let { elements } = this.props; + let { formData } = this.state; + + let valid = true, + validationErrors = {}; + + // Validate form only if LDAP is enabled + if (formData['ldap-enabled']) { + elements.forEach(function(element) { + // test for required elements + if (element.required && MetabaseUtils.isEmpty(formData[element.key])) { + valid = false; + } + + if (element.validations) { + element.validations.forEach(function(validation) { + validationErrors[element.key] = this.validateElement(validation, formData[element.key], element); + if (validationErrors[element.key]) valid = false; + }, this); + } + }, this); + } + + if (this.state.valid !== valid || !_.isEqual(this.state.validationErrors, validationErrors)) { + this.setState({ valid, validationErrors }); + } + } + + handleChangeEvent(element, value, event) { + this.setState((previousState) => ({ formData: { + ...previousState.formData, + [element.key]: (MetabaseUtils.isEmpty(value)) ? null : value + } })); + } + + handleFormErrors(error) { + // parse and format + let formErrors = {}; + if (error.data && error.data.message) { + formErrors.message = error.data.message; + } else { + formErrors.message = "Looks like we ran into some problems"; + } + + if (error.data && error.data.errors) { + formErrors.elements = error.data.errors; + } + + return formErrors; + } + + handleAttributeToggle() { + this.setState((previousState) => ({ showAttributes: !previousState['showAttributes'] })); + } + + updateLdapSettings(e) { + e.preventDefault(); + + let { formData, valid } = this.state; + + if (valid) { + this.setState({ + formErrors: null, + submitting: "working" + }); + + this.props.updateLdapSettings(formData).then(() => { + this.setState({ submitting: "success" }); + + // show a confirmation for 3 seconds, then return to normal + setTimeout(() => this.setState({ submitting: "default" }), 3000); + }, (error) => { + this.setState({ + submitting: "default", + formErrors: this.handleFormErrors(error) + }); + }); + } + } + + render() { + const { elements } = this.props; + const { formData, formErrors, showAttributes, submitting, valid, validationErrors } = this.state; + + let sections = { + 'ldap-enabled': 'server', + 'ldap-host': 'server', + 'ldap-port': 'server', + 'ldap-security': 'server', + 'ldap-bind-dn': 'server', + 'ldap-password': 'server', + 'ldap-user-base': 'user', + 'ldap-user-filter': 'user', + 'ldap-attribute-email': 'attribute', + 'ldap-attribute-firstname': 'attribute', + 'ldap-attribute-lastname': 'attribute', + 'ldap-group-sync': 'group', + 'ldap-group-base': 'group' + } + + const toElement = (element) => { + // merge together data from a couple places to provide a complete view of the Element state + let errorMessage = (formErrors && formErrors.elements) ? formErrors.elements[element.key] : validationErrors[element.key]; + let value = formData[element.key] == null ? element.defaultValue : formData[element.key]; + + if (element.key === 'ldap-group-sync') { + return ( + <SettingsSetting + key={element.key} + setting={{ ...element, value }} + updateSetting={this.handleChangeEvent.bind(this, element)} + mappings={formData['ldap-group-mappings']} + updateMappings={this.handleChangeEvent.bind(this, { key: 'ldap-group-mappings' })} + errorMessage={errorMessage} + /> + ); + } + + return ( + <SettingsSetting + key={element.key} + setting={{ ...element, value }} + updateSetting={this.handleChangeEvent.bind(this, element)} + errorMessage={errorMessage} + /> + ); + }; + + let serverSettings = elements.filter(e => sections[e.key] === 'server').map(toElement); + let userSettings = elements.filter(e => sections[e.key] === 'user').map(toElement); + let attributeSettings = elements.filter(e => sections[e.key] === 'attribute').map(toElement); + let groupSettings = elements.filter(e => sections[e.key] === 'group').map(toElement); + + let saveSettingsButtonStates = { + default: "Save changes", + working: "Saving...", + success: "Changes saved!" + }; + + let disabled = (!valid || submitting !== "default"); + let saveButtonText = saveSettingsButtonStates[submitting]; + + return ( + <form noValidate> + <h2 className="mx2">Server Settings</h2> + <ul>{serverSettings}</ul> + <h2 className="mx2">User Schema</h2> + <ul>{userSettings}</ul> + <div className="mb4"> + <div className="inline-block ml1 cursor-pointer text-brand-hover" onClick={this.handleAttributeToggle.bind(this)}> + <div className="flex align-center"> + <DisclosureTriangle open={showAttributes} /> + <h3>Attributes</h3> + </div> + </div> + <Collapse isOpened={showAttributes} keepCollapsedContent> + <ul>{attributeSettings}</ul> + </Collapse> + </div> + <h2 className="mx2">Group Schema</h2> + <ul>{groupSettings}</ul> + <div className="m2 mb4"> + <button className={cx("Button mr2", {"Button--primary": !disabled}, {"Button--success-new": submitting === "success"})} disabled={disabled} onClick={this.updateLdapSettings.bind(this)}> + {saveButtonText} + </button> + { (formErrors && formErrors.message) ? ( + <span className="pl2 text-error text-bold">{formErrors.message}</span> + ) : null } + </div> + </form> + ); + } +} diff --git a/frontend/src/metabase/admin/settings/components/widgets/LdapGroupMappingsWidget.jsx b/frontend/src/metabase/admin/settings/components/widgets/LdapGroupMappingsWidget.jsx new file mode 100644 index 0000000000000000000000000000000000000000..660cd988163f5024d59fe5e9816522a27bafc9f6 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/widgets/LdapGroupMappingsWidget.jsx @@ -0,0 +1,280 @@ +// @flow + +import React from "react"; + +import { ModalFooter } from "metabase/components/ModalContent" +import AdminContentTable from "metabase/components/AdminContentTable"; +import Button from "metabase/components/Button"; +import GroupSelect from "metabase/admin/people/components/GroupSelect"; +import GroupSummary from "metabase/admin/people/components/GroupSummary"; +import Icon from "metabase/components/Icon"; +import LoadingSpinner from "metabase/components/LoadingSpinner"; +import Modal from "metabase/components/Modal"; +import PopoverWithTrigger from "metabase/components/PopoverWithTrigger"; + +import { PermissionsApi, SettingsApi } from "metabase/services"; + +import _ from "underscore"; + +import SettingToggle from './SettingToggle'; + +type Props = { + setting: any, + updateSetting: (value: any) => void, + mappings: { [string]: number[] }, + updateMappings: (value: { [string]: number[] }) => void +}; + +type State = { + showEditModal: boolean, + showAddRow: boolean, + groups: Object[], + mappings: { [string]: number[] } +}; + +export default class LdapGroupMappingsWidget extends React.Component { + props: Props; + state: State; + + constructor(props: Props, context: any) { + super(props, context); + this.state = { + showEditModal: false, + showAddRow: false, + groups: [], + mappings: {} + }; + } + + _showEditModal = (e: Event) => { + e.preventDefault(); + this.setState({ mappings: this.props.mappings || {}, showEditModal: true }); + PermissionsApi.groups().then((groups) => this.setState({ groups })); + } + + _showAddRow = (e: Event) => { + e.preventDefault(); + this.setState({ showAddRow: true }); + } + + _hideAddRow = () => { + this.setState({ showAddRow: false }); + } + + _addMapping = (dn: string) => { + this.setState((prevState: State) => ({ mappings: { ...prevState.mappings, [dn]: [] }, showAddRow: false })); + } + + _changeMapping = (dn: string) => (group: { id: number }, selected: boolean) => { + if (selected) { + this.setState((prevState: State) => ({ + mappings: { + ...prevState.mappings, + [dn]: [...prevState.mappings[dn], group.id] + } + })); + } else { + this.setState((prevState: State) => ({ + mappings: { + ...prevState.mappings, + [dn]: prevState.mappings[dn].filter(id => id !== group.id) + } + })); + } + } + + _deleteMapping = (dn: string) => (e: Event) => { + e.preventDefault(); + this.setState((prevState: State) => ({ mappings: _.omit(prevState.mappings, dn) })); + } + + _cancelClick = (e: Event) => { + e.preventDefault(); + this.setState({ showEditModal: false, showAddRow: false }); + } + + _saveClick = (e: Event) => { + e.preventDefault(); + const { state: { mappings }, props: { updateMappings } } = this; + SettingsApi.put({ key: "ldap-group-mappings", value: mappings }).then(() => { + updateMappings && updateMappings(mappings); + this.setState({ showEditModal: false, showAddRow: false }); + }); + } + + render() { + const { showEditModal, showAddRow, groups, mappings } = this.state; + + return ( + <div className="flex align-center"> + <SettingToggle {...this.props} /> + <div className="flex align-center pt1"> + <Button type="button" className="ml1" medium onClick={this._showEditModal}>Edit Mappings</Button> + </div> + { showEditModal ? ( + <Modal wide> + <div> + <div className="pt4 px4"> + <h2>Group Mappings</h2> + </div> + <div className="px4"> + <Button className="float-right" primary onClick={this._showAddRow}>Create a mapping</Button> + <p className="text-measure"> + Mappings allow Metabase to automatically add and remove users from groups based on the membership information provided by the + directory server. Membership to the Admin group can be granted through mappings, but will not be automatically removed as a + failsafe measure. + </p> + <AdminContentTable columnTitles={['Distinguished Name', 'Groups', '']}> + { showAddRow ? ( + <AddMappingRow mappings={mappings} onCancel={this._hideAddRow} onAdd={this._addMapping} /> + ) : null } + { ((Object.entries(mappings): any): Array<[string, number[]]>).map(([dn, ids]) => + <MappingRow + key={dn} + dn={dn} + groups={groups} + selectedGroups={ids} + onChange={this._changeMapping(dn)} + onDelete={this._deleteMapping(dn)} + /> + ) } + </AdminContentTable> + </div> + <ModalFooter> + <Button type="button" onClick={this._cancelClick}>Cancel</Button> + <Button primary onClick={this._saveClick}>Save</Button> + </ModalFooter> + </div> + </Modal> + ) : null } + </div> + ); + } +} + +type AddMappingRowProps = { + mappings: { [string]: number[] }, + onAdd?: (dn: string) => void, + onCancel?: () => void +}; + +type AddMappingRowState = { + value: '' +}; + +class AddMappingRow extends React.Component { + props: AddMappingRowProps; + state: AddMappingRowState; + + constructor(props: AddMappingRowProps, context: any) { + super(props, context); + this.state = { + value: '' + }; + } + + _handleCancelClick = (e: Event) => { + e.preventDefault(); + const { onCancel } = this.props; + onCancel && onCancel(); + this.setState({ value: '' }); + } + + _handleAddClick = (e: Event) => { + e.preventDefault(); + const { onAdd } = this.props; + onAdd && onAdd(this.state.value); + this.setState({ value: '' }); + } + + render() { + const { value } = this.state; + + const isValid = value && this.props.mappings[value] === undefined; + + return ( + <tr> + <td colSpan="3" style={{ padding: 0 }}> + <div className="my2 pl1 p1 bordered border-brand rounded relative flex align-center"> + <input + className="input--borderless h3 ml1 flex-full" + type="text" + value={value} + placeholder="cn=People,ou=Groups,dc=metabase,dc=com" + autoFocus + onChange={(e) => this.setState({ value: e.target.value })} + /> + <span className="link no-decoration cursor-pointer" onClick={this._handleCancelClick}>Cancel</span> + <Button className="ml2" primary={!!isValid} disabled={!isValid} onClick={this._handleAddClick}>Add</Button> + </div> + </td> + </tr> + ); + } +} + +class MappingGroupSelect extends React.Component { + props: { + groups: Array<{ id: number }>, + selectedGroups: number[], + onGroupChange?: (group: { id: number }, selected: boolean) => void + }; + + render() { + const { groups, selectedGroups, onGroupChange } = this.props; + + if (!groups || groups.length === 0) { + return <LoadingSpinner />; + } + + const selected = selectedGroups.reduce((g, id) => ({ ...g, [id]: true }), {}); + + return ( + <PopoverWithTrigger + ref="popover" + triggerElement={ + <div className="flex align-center"> + <span className="mr1 text-grey-4"> + <GroupSummary groups={groups} selectedGroups={selected} /> + </span> + <Icon className="text-grey-2" name="chevrondown" size={10}/> + </div> + } + triggerClasses="AdminSelectBorderless py1" + sizeToFit + > + <GroupSelect groups={groups} selectedGroups={selected} onGroupChange={onGroupChange} /> + </PopoverWithTrigger> + ); + } +} + +class MappingRow extends React.Component { + props: { + dn: string, + groups: Array<{ id: number }>, + selectedGroups: number[], + onChange?: (group: { id: number }, selected: boolean) => void, + onDelete?: (e: Event) => void + }; + + render() { + const { dn, groups, selectedGroups, onChange, onDelete } = this.props; + + return ( + <tr> + <td>{dn}</td> + <td> + <MappingGroupSelect + groups={groups} + selectedGroups={selectedGroups} + onGroupChange={onChange} + /> + </td> + <td className="Table-actions"> + <Button warning onClick={onDelete}>Remove</Button> + </td> + </tr> + ); + } +} diff --git a/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx b/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx index e00c797bb5f1250185bc79cccdfdda4b67800b0e..486bfa8df7b42ab7d42e585fd1eb8f0daecaceca 100644 --- a/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx +++ b/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx @@ -11,6 +11,7 @@ import AdminLayout from "metabase/components/AdminLayout.jsx"; import SettingsSetting from "../components/SettingsSetting.jsx"; import SettingsEmailForm from "../components/SettingsEmailForm.jsx"; import SettingsSlackForm from "../components/SettingsSlackForm.jsx"; +import SettingsLdapForm from "../components/SettingsLdapForm.jsx"; import SettingsSetupList from "../components/SettingsSetupList.jsx"; import SettingsUpdatesForm from "../components/SettingsUpdatesForm.jsx"; import SettingsSingleSignOnForm from "../components/SettingsSingleSignOnForm.jsx"; @@ -52,6 +53,7 @@ export default class SettingsEditorApp extends Component { updateSetting: PropTypes.func.isRequired, updateEmailSettings: PropTypes.func.isRequired, updateSlackSettings: PropTypes.func.isRequired, + updateLdapSettings: PropTypes.func.isRequired, sendTestEmail: PropTypes.func.isRequired }; @@ -145,8 +147,14 @@ export default class SettingsEditorApp extends Component { updateSetting={this.updateSetting} /> ); + } else if (activeSection.name === "LDAP") { + return ( + <SettingsLdapForm + elements={activeSection.settings} + updateLdapSettings={this.props.updateLdapSettings} + /> + ); } else { - return ( <ul> {activeSection.settings diff --git a/frontend/src/metabase/admin/settings/selectors.js b/frontend/src/metabase/admin/settings/selectors.js index 0434679965a1356633557fbbc2c2da590b097b54..e6fe25b576720987f9ed5ec26461b7a6d653abd1 100644 --- a/frontend/src/metabase/admin/settings/selectors.js +++ b/frontend/src/metabase/admin/settings/selectors.js @@ -12,6 +12,7 @@ import { } from "./components/widgets/PublicLinksListing.jsx"; import SecretKeyWidget from "./components/widgets/SecretKeyWidget.jsx"; import EmbeddingLegalese from "./components/widgets/EmbeddingLegalese"; +import LdapGroupMappingsWidget from "./components/widgets/LdapGroupMappingsWidget"; import { UtilApi } from "metabase/services"; @@ -157,6 +158,93 @@ const SECTIONS = [ } ] }, + { + name: "LDAP", + settings: [ + { + key: "ldap-enabled", + display_name: "LDAP Authentication", + description: null, + type: "boolean" + }, + { + key: "ldap-host", + display_name: "LDAP Host", + placeholder: "ldap.yourdomain.org", + type: "string", + required: true, + autoFocus: true + }, + { + key: "ldap-port", + display_name: "LDAP Port", + placeholder: "389", + type: "string", + validations: [["integer", "That's not a valid port number"]] + }, + { + key: "ldap-security", + display_name: "LDAP Security", + description: null, + type: "radio", + options: { none: "None", ssl: "SSL", starttls: "StartTLS" }, + defaultValue: "none" + }, + { + key: "ldap-bind-dn", + display_name: "Username or DN", + type: "string", + required: true + }, + { + key: "ldap-password", + display_name: "Password", + type: "password", + required: true + }, + { + key: "ldap-user-base", + display_name: "User search base", + type: "string", + required: true + }, + { + key: "ldap-user-filter", + display_name: "User filter", + type: "string", + validations: [["ldap_filter", "Check your parentheses"]] + }, + { + key: "ldap-attribute-email", + display_name: "Email attribute", + type: "string" + }, + { + key: "ldap-attribute-firstname", + display_name: "First name attribute", + type: "string" + }, + { + key: "ldap-attribute-lastname", + display_name: "Last name attribute", + type: "string" + }, + { + key: "ldap-group-sync", + display_name: "Synchronize group memberships", + description: null, + widget: LdapGroupMappingsWidget + }, + { + key: "ldap-group-base", + display_name: "Group search base", + type: "string" + }, + { + key: "ldap-group-mappings" + } + ] + }, { name: "Maps", settings: [ diff --git a/frontend/src/metabase/admin/settings/settings.js b/frontend/src/metabase/admin/settings/settings.js index cc63d4829c5fc1a0545b1fe181c2c951900b4cc2..366c0879685f4e3e2b916171379803757587df8c 100644 --- a/frontend/src/metabase/admin/settings/settings.js +++ b/frontend/src/metabase/admin/settings/settings.js @@ -1,7 +1,7 @@ import { createThunkAction, handleActions, combineReducers } from "metabase/lib/redux"; -import { SettingsApi, EmailApi, SlackApi } from "metabase/services"; +import { SettingsApi, EmailApi, SlackApi, LdapApi } from "metabase/services"; import { refreshSiteSettings } from "metabase/redux/settings"; @@ -71,6 +71,19 @@ export const updateSlackSettings = createThunkAction(UPDATE_SLACK_SETTINGS, func }; }, {}); +export const UPDATE_LDAP_SETTINGS = "metabase/admin/settings/UPDATE_LDAP_SETTINGS"; +export const updateLdapSettings = createThunkAction(UPDATE_LDAP_SETTINGS, function(settings) { + return async function(dispatch, getState) { + try { + await LdapApi.updateSettings(settings); + await dispatch(refreshSiteSettings()); + } catch(error) { + console.log("error updating LDAP settings", settings, error); + throw error; + } + }; +}); + export const RELOAD_SETTINGS = "metabase/admin/settings/RELOAD_SETTINGS"; export const reloadSettings = createThunkAction(RELOAD_SETTINGS, function() { return async function(dispatch, getState) { diff --git a/frontend/src/metabase/auth/auth.js b/frontend/src/metabase/auth/auth.js index b75b5db49a18de80cc2b5e3cf80e31f5d4ec263d..2e684474bf433ffb2642163c97b57e8c319adff2 100644 --- a/frontend/src/metabase/auth/auth.js +++ b/frontend/src/metabase/auth/auth.js @@ -6,6 +6,7 @@ import { push } from "react-router-redux"; import MetabaseCookies from "metabase/lib/cookies"; import MetabaseUtils from "metabase/lib/utils"; import MetabaseAnalytics from "metabase/lib/analytics"; +import MetabaseSettings from "metabase/lib/settings"; import { clearGoogleAuthCredentials } from "metabase/lib/auth"; @@ -19,7 +20,7 @@ export const LOGIN = "metabase/auth/LOGIN"; export const login = createThunkAction(LOGIN, function(credentials, redirectUrl) { return async function(dispatch, getState) { - if (!MetabaseUtils.validEmail(credentials.email)) { + if (!MetabaseSettings.ldapEnabled() && !MetabaseUtils.validEmail(credentials.username)) { return {'data': {'errors': {'email': "Please enter a valid formatted email address."}}}; } diff --git a/frontend/src/metabase/auth/containers/LoginApp.jsx b/frontend/src/metabase/auth/containers/LoginApp.jsx index 229febd771c0a21a04db540746c7fe5a041aeb16..a6c02e11903c620a4b85bb408f651459a839ff2b 100644 --- a/frontend/src/metabase/auth/containers/LoginApp.jsx +++ b/frontend/src/metabase/auth/containers/LoginApp.jsx @@ -11,7 +11,8 @@ import FormField from "metabase/components/form/FormField.jsx"; import FormLabel from "metabase/components/form/FormLabel.jsx"; import FormMessage from "metabase/components/form/FormMessage.jsx"; import LogoIcon from "metabase/components/LogoIcon.jsx"; -import Settings from "metabase/lib/settings.js"; +import Settings from "metabase/lib/settings"; +import Utils from "metabase/lib/utils"; import * as authActions from "../auth"; @@ -44,7 +45,7 @@ export default class LoginApp extends Component { let valid = true; - if (!credentials.email || !credentials.password) { + if (!credentials.username || !credentials.password) { valid = false; } @@ -128,9 +129,9 @@ export default class LoginApp extends Component { <FormMessage formError={loginError && loginError.data.message ? loginError : null} ></FormMessage> - <FormField key="email" fieldName="email" formError={loginError}> - <FormLabel title={"Email address"} fieldName={"email"} formError={loginError} /> - <input className="Form-input Form-offset full py1" name="email" placeholder="youlooknicetoday@email.com" type="text" onChange={(e) => this.onChange("email", e.target.value)} autoFocus /> + <FormField key="username" fieldName="username" formError={loginError}> + <FormLabel title={Settings.ldapEnabled() ? "Username or email address" : "Email address"} fieldName={"username"} formError={loginError} /> + <input className="Form-input Form-offset full py1" name="username" placeholder="youlooknicetoday@email.com" type="text" onChange={(e) => this.onChange("username", e.target.value)} autoFocus /> <span className="Form-charm"></span> </FormField> @@ -150,7 +151,7 @@ export default class LoginApp extends Component { <button className={cx("Button Grid-cell", {'Button--primary': this.state.valid})} disabled={!this.state.valid}> Sign in </button> - <Link to={"/auth/forgot_password"+(this.state.credentials.email ? "?email="+this.state.credentials.email : "")} className="Grid-cell py2 sm-py0 text-grey-3 md-text-right text-centered flex-full link" onClick={(e) => { window.OSX ? window.OSX.resetPassword() : null }}>I seem to have forgotten my password</Link> + <Link to={"/auth/forgot_password"+(Utils.validEmail(this.state.credentials.username) ? "?email="+this.state.credentials.username : "")} className="Grid-cell py2 sm-py0 text-grey-3 md-text-right text-centered flex-full link" onClick={(e) => { window.OSX ? window.OSX.resetPassword() : null }}>I seem to have forgotten my password</Link> </div> </form> </div> diff --git a/frontend/src/metabase/css/core/base.css b/frontend/src/metabase/css/core/base.css index 6ed9b2935a3893690157e4e8a137c034fb17492f..bc19008d1e96161ddebe4eb394d30987483a7059 100644 --- a/frontend/src/metabase/css/core/base.css +++ b/frontend/src/metabase/css/core/base.css @@ -17,8 +17,10 @@ body { height: 100%; /* ensure the entire page will fill the window */ display: flex; flex-direction: column; + text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } /* diff --git a/frontend/src/metabase/css/core/breakpoints.css b/frontend/src/metabase/css/core/breakpoints.css index 6eaab3b1d4aec6a573f0d8ab2b76173ebadbaafd..2688b3bef0213524e8c5ac40f387b828c5032cfc 100644 --- a/frontend/src/metabase/css/core/breakpoints.css +++ b/frontend/src/metabase/css/core/breakpoints.css @@ -1,10 +1,12 @@ /* TODO: we should really have these as variables */ +@custom-media --breakpoint-min-xs (min-width: 23em); @custom-media --breakpoint-min-sm (min-width: 40em); @custom-media --breakpoint-min-md (min-width: 60em); @custom-media --breakpoint-min-lg (min-width: 80em); @custom-media --breakpoint-min-xl (min-width: 120em); +@custom-media --breakpoint-max-xs (max-width: 23em); @custom-media --breakpoint-max-sm (max-width: 40em); @custom-media --breakpoint-max-md (max-width: 60em); @custom-media --breakpoint-max-lg (max-width: 80em); diff --git a/frontend/src/metabase/css/core/hide.css b/frontend/src/metabase/css/core/hide.css index c8a51968ad5b6559408958da6fce57a4e8c635e3..58ac6c1cdc5073c497ca5c53e55113b86fd78ace 100644 --- a/frontend/src/metabase/css/core/hide.css +++ b/frontend/src/metabase/css/core/hide.css @@ -1,5 +1,5 @@ .hide { display: none !important; } -.show { display: inheirt; } +.show { display: inherit; } .hidden { visibility: hidden; } @@ -8,6 +8,15 @@ .lg-show, .xl-show { display: none; } +/* extra-small */ + +@media screen and (--breakpoint-min-xs) { + .xs-hide { display: none !important; } +} +@media screen and (--breakpoint-min-xs) { + .xs-show { display: inherit !important; } +} + /* small */ @media screen and (--breakpoint-min-sm) { diff --git a/frontend/src/metabase/icon_paths.js b/frontend/src/metabase/icon_paths.js index 1d090d3c0340d3360a15f4d847738337cd514761..9c658befe881179fca8f13b1917ae7c912cafcaa 100644 --- a/frontend/src/metabase/icon_paths.js +++ b/frontend/src/metabase/icon_paths.js @@ -126,6 +126,10 @@ export var ICON_PATHS = { attrs: { fillRule: "evenodd" } }, label: 'M14.577 31.042a2.005 2.005 0 0 1-2.738-.733L1.707 12.759c-.277-.477-.298-1.265-.049-1.757L6.45 1.537C6.7 1.044 7.35.67 7.9.7l10.593.582c.551.03 1.22.44 1.498.921l10.132 17.55a2.002 2.002 0 0 1-.734 2.737l-14.812 8.552zm.215-22.763a3.016 3.016 0 1 0-5.224 3.016 3.016 3.016 0 0 0 5.224-3.016z', + ldap: { + path: 'M1.006 3h13.702c.554 0 1.178.41 1.39.915l.363.874c.21.504.827.915 1.376.915h13.169c.54 0 .994.448.994 1.001v20.952a.99.99 0 0 1-.992 1H1.002c-.54 0-.993-.45-.993-1.005l-.01-23.646C0 3.446.45 3 1.007 3zM16.5 19.164c1.944 0 3.52-1.828 3.52-4.082 0-2.254-1.576-4.082-3.52-4.082-1.945 0-3.52 1.828-3.52 4.082 0 2.254 1.575 4.082 3.52 4.082zm6.5 4.665c0-1.872-1.157-3.521-2.913-4.484-.927.97-2.192 1.568-3.587 1.568s-2.66-.597-3.587-1.568C11.157 20.308 10 21.957 10 23.83h13z', + attrs: { fillRule: 'evenodd' } + }, left: "M21,0 L5,16 L21,32 L21,5.47117907e-13 L21,0 Z", link: "M12.56 17.04c-1.08 1.384-1.303 1.963 1.755 4.04 3.058 2.076 7.29.143 8.587-1.062 1.404-1.304 4.81-4.697 7.567-7.842 2.758-3.144 1.338-8.238-.715-9.987-5.531-4.71-9.5-.554-11.088.773-2.606 2.176-5.207 5.144-5.207 5.144s1.747-.36 2.784 0c1.036.36 2.102.926 2.102.926l4.003-3.969s2.367-1.907 4.575 0 .674 4.404 0 5.189c-.674.784-6.668 6.742-6.668 6.742s-1.52.811-2.37.811c-.85 0-2.582-.528-2.582-.932 0-.405-1.665-1.22-2.744.166zm7.88-2.08c1.08-1.384 1.303-1.963-1.755-4.04-3.058-2.076-7.29-.143-8.587 1.062-1.404 1.304-4.81 4.697-7.567 7.842-2.758 3.144-1.338 8.238.715 9.987 5.531 4.71 9.5.554 11.088-.773 2.606-2.176 5.207-5.144 5.207-5.144s-1.747.36-2.784 0a17.379 17.379 0 0 1-2.102-.926l-4.003 3.969s-2.367 1.907-4.575 0-.674-4.404 0-5.189c.674-.784 6.668-6.742 6.668-6.742s1.52-.811 2.37-.811c.85 0 2.582.528 2.582.932 0 .405 1.665 1.22 2.744-.166z", line: 'M18.867 16.377l-3.074-3.184-.08.077-.002-.002.01-.01-.53-.528-.066-.07-.001.002-2.071-2.072L-.002 23.645l2.668 2.668 10.377-10.377 3.074 3.183.08-.076.001.003-.008.008.5.501.094.097.002-.001 2.072 2.072L31.912 8.669 29.244 6 18.867 16.377z', diff --git a/frontend/src/metabase/lib/query.js b/frontend/src/metabase/lib/query.js index 1b76eb778b6f5ac61750719881e6a5a901902144..3e8abb21cd8ad7434ab97328b5760cc37ad5a48f 100644 --- a/frontend/src/metabase/lib/query.js +++ b/frontend/src/metabase/lib/query.js @@ -331,6 +331,11 @@ var Query = { return Array.isArray(field) && mbqlEq(field[0], "aggregation"); }, + // field literal has the formal ["field-literal", <field-name>, <field-base-type>] + isFieldLiteral(field) { + return Array.isArray(field) && field.length === 3 && mbqlEq(field[0], "field-literal") && _.isString(field[1]) && _.isString(field[2]); + }, + isValidField(field) { return ( (Query.isRegularField(field)) || @@ -341,7 +346,8 @@ var Query = { (field[2] === "as" && typeof field[3] === "string") : // deprecated typeof field[2] === "string")) || (Query.isExpressionField(field) && _.isString(field[1])) || - (Query.isAggregateField(field) && typeof field[1] === "number") + (Query.isAggregateField(field) && typeof field[1] === "number") || + Query.isFieldLiteral(field) ); }, @@ -363,6 +369,8 @@ var Query = { return Query.getFieldTargetId(field[2]); } else if (Query.isDatetimeField(field)) { return Query.getFieldTargetId(field[1]); + } else if (Query.isFieldLiteral(field)) { + return field; } console.warn("Unknown field type: ", field); }, @@ -406,7 +414,9 @@ var Query = { table: tableDef, field: fieldDef, path: path - } + }; + } else if (Query.isFieldLiteral(field)) { + return { table: tableDef, field: Table.getField(tableDef, field), path }; // just pretend it's a normal field } console.warn("Unknown field type: ", field); diff --git a/frontend/src/metabase/lib/query/field.js b/frontend/src/metabase/lib/query/field.js index d4c928ff3c86082ca8db30d6ea830c9318460a25..ecf111155814c75064d803e054b277e6d4d5039b 100644 --- a/frontend/src/metabase/lib/query/field.js +++ b/frontend/src/metabase/lib/query/field.js @@ -18,6 +18,8 @@ export function getFieldTargetId(field: FieldReference): ?FieldId { } else if (isDatetimeField(field)) { // $FlowFixMe return getFieldTargetId(field[1]); + } else if (isFieldLiteral(field)) { + return field; } console.warn("Unknown field type: ", field); } @@ -38,6 +40,10 @@ export function isDatetimeField(field: FieldReference): boolean { return Array.isArray(field) && mbqlEq(field[0], "datetime-field"); } +export function isFieldLiteral(field: FieldReference): boolean { + return Array.isArray(field) && field.length === 3 && mbqlEq(field[0], "field-literal"); +} + export function isExpressionField(field: FieldReference): boolean { return Array.isArray(field) && field.length === 2 && mbqlEq(field[0], "expression"); } diff --git a/frontend/src/metabase/lib/query/util.js b/frontend/src/metabase/lib/query/util.js index 5c80c8130875e8d011f871ad34a773eac309f93c..806b6d3423fd27397de91a5925516b7e4522e524 100644 --- a/frontend/src/metabase/lib/query/util.js +++ b/frontend/src/metabase/lib/query/util.js @@ -8,6 +8,27 @@ export const mbql = (a: string):string => export const mbqlEq = (a: string, b: string): boolean => mbql(a) === mbql(b); +// determines whether 2 field IDs are equal. This is needed rather than +// doing a simple comparison because field IDs are not guaranteed to be numeric: +// the might be FieldLiterals, e.g. [field-literal <name> <unit>], instead. +export const fieldIdsEq = (a: any, b: any): boolean => { + if (typeof a !== typeof b) return false; + + if (typeof a === "number") return a === b; + + if (a == null && b == null) return true; + + // field literals + if (Array.isArray(a) && Array.isArray(b) && + a.length === 3 && b.length === 3 && + a[0] === "field-literal" && b[0] === "field-literal") { + return a[1] === b[1]; + } + + console.warn("Don't know how to compare these IDs:", a, b); + return false; +} + export const noNullValues = (clause: any[]): boolean => _.all(clause, c => c != null); diff --git a/frontend/src/metabase/lib/query_time.js b/frontend/src/metabase/lib/query_time.js index 9a56774d3ab4839d91c6f4ad20d288f67583cb15..335d9ab79fe9a2f96045fc18598de373e44fe9ba 100644 --- a/frontend/src/metabase/lib/query_time.js +++ b/frontend/src/metabase/lib/query_time.js @@ -183,14 +183,18 @@ export function parseFieldBucketing(field, defaultUnit = null) { if (Array.isArray(field)) { if (mbqlEq(field[0], "datetime-field")) { if (field.length === 4) { - // Deprecated legacy format, see DatetimeFieldDimension + // Deprecated legacy format [datetime-field field "as" unit], see DatetimeFieldDimension for more info return field[3]; } else { + // Current format [datetime-field field unit] return field[2] } } if (mbqlEq(field[0], "fk->") || mbqlEq(field[0], "field-id")) { return defaultUnit; - } else { + } if (mbqlEq(field[0], "field-literal")) { + return defaultUnit; + } + else { console.warn("Unknown field format", field); } } @@ -213,6 +217,7 @@ export function parseFieldTargetId(field) { if (mbqlEq(field[0], "field-id")) return field[1]; if (mbqlEq(field[0], "fk->")) return field[1]; if (mbqlEq(field[0], "datetime-field")) return parseFieldTargetId(field[1]); + if (mbqlEq(field[0], "field-literal")) return field; } console.warn("Unknown field format", field); diff --git a/frontend/src/metabase/lib/settings.js b/frontend/src/metabase/lib/settings.js index 5460a7b95681cf17c26a8c03735ade94d01ef0f2..4c0fdfaefbcdeaa6d6d91ebc95a184cc27127efe 100644 --- a/frontend/src/metabase/lib/settings.js +++ b/frontend/src/metabase/lib/settings.js @@ -51,6 +51,10 @@ const MetabaseSettings = { return mb_settings.google_auth_client_id != null; }, + ldapEnabled: function() { + return mb_settings.ldap_configured; + }, + newVersionAvailable: function(settings) { let versionInfo = _.findWhere(settings, {key: "version-info"}), currentVersion = MetabaseSettings.get("version").tag; diff --git a/frontend/src/metabase/meta/types/Query.js b/frontend/src/metabase/meta/types/Query.js index 831fceeba01a3955b281c1536efe850452c886a0..7be2639c27d0100bf081b69f12add8ce9ac05966 100644 --- a/frontend/src/metabase/meta/types/Query.js +++ b/frontend/src/metabase/meta/types/Query.js @@ -1,7 +1,7 @@ /* @flow */ import type { TableId } from "./Table"; -import type { FieldId } from "./Field"; +import type { FieldId, BaseType } from "./Field"; import type { SegmentId } from "./Segment"; import type { MetricId } from "./Metric"; import type { ParameterType } from "./Parameter"; @@ -156,6 +156,9 @@ export type ForeignFieldReference = export type ExpressionReference = ["expression", ExpressionName]; +export type FieldLiteral = + ["field-literal", string, BaseType]; // ["field-literal", name, base-type] + export type DatetimeField = ["datetime-field", LocalFieldReference | ForeignFieldReference, DatetimeUnit] | ["datetime-field", LocalFieldReference | ForeignFieldReference, "as", DatetimeUnit]; // @deprecated: don't include the "as" element diff --git a/frontend/src/metabase/nav/components/ProfileLink.jsx b/frontend/src/metabase/nav/components/ProfileLink.jsx index d9b3ad48a7087e8c4656980fdad314394150a644..6c8c4694e7259871be97917a661e8a2bd25084eb 100644 --- a/frontend/src/metabase/nav/components/ProfileLink.jsx +++ b/frontend/src/metabase/nav/components/ProfileLink.jsx @@ -77,11 +77,13 @@ export default class ProfileLink extends Component { <OnClickOutsideWrapper handleDismissal={this.closeDropdown}> <div className="NavDropdown-content right"> <ul className="NavDropdown-content-layer"> - <li> - <Link to="/user/edit_current" data-metabase-event={"Navbar;Profile Dropdown;Edit Profile"} onClick={this.closeDropdown} className="Dropdown-item block text-white no-decoration"> - Account Settings - </Link> - </li> + { !user.google_auth && !user.ldap_auth ? + <li> + <Link to="/user/edit_current" data-metabase-event={"Navbar;Profile Dropdown;Edit Profile"} onClick={this.closeDropdown} className="Dropdown-item block text-white no-decoration"> + Account Settings + </Link> + </li> + : null } { user.is_superuser && context !== 'admin' ? <li> diff --git a/frontend/src/metabase/nav/containers/Navbar.jsx b/frontend/src/metabase/nav/containers/Navbar.jsx index 81a395e7187a75ee434bab54004101e35b769c8e..8f99a3e6e9d6f123b480f638b095ab509cc3b339 100644 --- a/frontend/src/metabase/nav/containers/Navbar.jsx +++ b/frontend/src/metabase/nav/containers/Navbar.jsx @@ -125,10 +125,10 @@ export default class Navbar extends Component { <LogoIcon dark={true}></LogoIcon> </Link> </li> - <li className="pl3 hide sm-show"> + <li className="pl3 hide xs-show"> <MainNavLink to="/dashboards" name="Dashboards" eventName="Dashboards" /> </li> - <li className="pl1 hide sm-show"> + <li className="pl1 hide xs-show"> <MainNavLink to="/questions" name="Questions" eventName="Questions" /> </li> <li className="pl1 hide sm-show"> diff --git a/frontend/src/metabase/qb/lib/actions.js b/frontend/src/metabase/qb/lib/actions.js index a98ff56bf743dcf9e8c9bf33b0d99406bf86e198..2fd0786aecf4f8a901a2ffc009605de37f330097 100644 --- a/frontend/src/metabase/qb/lib/actions.js +++ b/frontend/src/metabase/qb/lib/actions.js @@ -3,6 +3,7 @@ import moment from "moment"; import Q from "metabase/lib/query"; // legacy query lib +import { fieldIdsEq } from "metabase/lib/query/util"; import * as Card from "metabase/meta/Card"; import * as Query from "metabase/lib/query/query"; import * as Field from "metabase/lib/query/field"; @@ -172,7 +173,7 @@ export const drillDownForDimensions = dimensions => { breakout: [ "datetime-field", getFieldRefFromColumn(column), - "as", + "as", // TODO - this is deprecated and should be removed. See https://github.com/metabase/metabase/wiki/Query-Language-'98#datetime-field nextUnit ] }; @@ -331,7 +332,7 @@ export const pivot = ( tableMetadata ); for (const [index, field] of breakoutFields.entries()) { - if (field && field.id === dimension.column.id) { + if (field && fieldIdsEq(field.id, dimension.column.id)) { newCard.dataset_query.query = Query.removeBreakout( newCard.dataset_query.query, index diff --git a/frontend/src/metabase/query_builder/components/FieldName.jsx b/frontend/src/metabase/query_builder/components/FieldName.jsx index f9887affef05735b8ffa7b87e63a28d8599276cf..52cfac407576f4a6b3ab6a15d9c60b4c676c136a 100644 --- a/frontend/src/metabase/query_builder/components/FieldName.jsx +++ b/frontend/src/metabase/query_builder/components/FieldName.jsx @@ -7,6 +7,7 @@ import Query from "metabase/lib/query"; import Dimension from "metabase-lib/lib/Dimension"; +import _ from "underscore"; import cx from "classnames"; export default class FieldName extends Component { @@ -21,6 +22,13 @@ export default class FieldName extends Component { className: "" }; + displayNameForFieldLiteral(tableMetadata, fieldLiteral) { + // see if we can find an entry in the table metadata that matches the field literal + let matchingField = _.find(tableMetadata.fields, (field) => Query.isFieldLiteral(field.id) && field.id[1] === fieldLiteral[1]); // check whether names of field literals match + + return (matchingField && matchingField.display_name) || fieldLiteral[1]; + } + render() { let { field, tableMetadata, className } = this.props; @@ -30,6 +38,16 @@ export default class FieldName extends Component { const dimension = Dimension.parseMBQL(field, tableMetadata && tableMetadata.metadata); if (dimension) { parts = dimension.render(); + } + // TODO Atte Keinänen 6/23/17: Move nested queries logic to Dimension subclasses + // if the Field in question is a field literal, e.g. ["field-literal", <name>, <type>] just use name as-is + else if (Query.isFieldLiteral(field)) { + parts.push(<span key="field">{this.displayNameForFieldLiteral(tableMetadata, field)}</span>); + } + // otherwise if for some weird reason we wound up with a Field Literal inside a field ID, + // e.g. ["field-id", ["field-literal", <name>, <type>], still just use the name as-is + else if (Query.isLocalField(field) && Query.isFieldLiteral(field[1])) { + parts.push(<span key="field">{this.displayNameForFieldLiteral(tableMetadata, field[1])}</span>); } else { parts.push(<span key="field">Unknown Field</span>); } diff --git a/frontend/src/metabase/query_builder/components/QueryHeader.jsx b/frontend/src/metabase/query_builder/components/QueryHeader.jsx index 2c64dbea8040275560954994be4bf4c43e2fc528..ad62ff03d5edbda925383e8fef1a3861542a8229 100644 --- a/frontend/src/metabase/query_builder/components/QueryHeader.jsx +++ b/frontend/src/metabase/query_builder/components/QueryHeader.jsx @@ -1,6 +1,7 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import { Link } from "react-router"; +import { connect } from "react-redux"; import QueryModeButton from "./QueryModeButton.jsx"; @@ -19,6 +20,8 @@ import ArchiveQuestionModal from "metabase/query_builder/containers/ArchiveQuest import SaveQuestionModal from 'metabase/containers/SaveQuestionModal.jsx'; +import { clearRequestState } from "metabase/redux/requests"; + import { CardApi, RevisionApi } from "metabase/services"; import MetabaseAnalytics from "metabase/lib/analytics"; @@ -31,7 +34,11 @@ import _ from "underscore"; import NativeQuery from "metabase-lib/lib/queries/NativeQuery"; import Utils from "metabase/lib/utils"; +const mapDispatchToProps = { + clearRequestState +}; +@connect(null, mapDispatchToProps) export default class QueryHeader extends Component { constructor(props, context) { super(props, context); @@ -92,6 +99,25 @@ export default class QueryHeader extends Component { return card } } + + /// Add result_metadata and metadata_checksum columns to card as expected by the endpoints used for saving + /// and updating Cards. These values are returned as part of Query Processor results and fetched from there + addResultMetadata(card) { + let metadata = this.props.result && this.props.result.data && this.props.result.data.results_metadata; + let metadataChecksum = metadata && metadata.checksum; + let metadataColumns = metadata && metadata.columns; + + card.result_metadata = metadataColumns; + card.metadata_checksum = metadataChecksum; + } + + /// remove the databases in the store that are used to populate the QB databases list. + /// This is done when saving a Card because the newly saved card will be elligable for use as a source query + /// so we want the databases list to be re-fetched next time we hit "New Question" so it shows up + clearQBDatabases() { + this.props.clearRequestState({ statePath: ["metadata", "databases"] }); + } + onCreate(card, addToDash) { // MBQL->NATIVE // if we are a native query with an MBQL query definition, remove the old MBQL stuff (happens when going from mbql -> native) @@ -102,9 +128,13 @@ export default class QueryHeader extends Component { // } const cleanedCard = this._getCleanedCard(card); + this.addResultMetadata(cleanedCard); + // TODO: reduxify this.requestPromise = cancelable(CardApi.create(cleanedCard)); return this.requestPromise.then(newCard => { + this.clearQBDatabases(); + this.props.notifyCardCreatedFn(newCard); this.setState({ @@ -124,9 +154,13 @@ export default class QueryHeader extends Component { // } const cleanedCard = this._getCleanedCard(card); + this.addResultMetadata(cleanedCard); + // TODO: reduxify this.requestPromise = cancelable(CardApi.update(cleanedCard)); return this.requestPromise.then(updatedCard => { + this.clearQBDatabases(); + if (this.props.fromUrl) { this.onGoBack(); return; diff --git a/frontend/src/metabase/query_builder/components/dataref/FieldPane.jsx b/frontend/src/metabase/query_builder/components/dataref/FieldPane.jsx index 4e8141f50f3f506f300d0bec8ae1f624fdbc16da..314b5be358513ff563938c82b6b646f11a019fd4 100644 --- a/frontend/src/metabase/query_builder/components/dataref/FieldPane.jsx +++ b/frontend/src/metabase/query_builder/components/dataref/FieldPane.jsx @@ -114,7 +114,7 @@ export default class FieldPane extends Component { } // TODO: allow for filters/grouping via foreign keys - if (!datasetQuery.query || datasetQuery.query.source_table == undefined || datasetQuery.query.source_table === field.table_id) { + if (!datasetQuery || !datasetQuery.query || datasetQuery.query.source_table == undefined || datasetQuery.query.source_table === field.table_id) { // NOTE: disabled this for now because we need a way to capture the completed filter before adding it to the query, or to pop open the filter widget here? // useForCurrentQuestion.push(<UseForButton title={"Filter by " + name} onClick={this.filterBy} />); diff --git a/frontend/src/metabase/questions/collections.js b/frontend/src/metabase/questions/collections.js index 592417105a325b7ba5ad1890a68ff19bc568216d..faa4142886464e036789768f8888776325b583b6 100644 --- a/frontend/src/metabase/questions/collections.js +++ b/frontend/src/metabase/questions/collections.js @@ -2,7 +2,6 @@ import { createAction, createThunkAction, handleActions, combineReducers } from "metabase/lib/redux"; import { reset } from 'redux-form'; import { replace } from "react-router-redux"; -import * as Urls from "metabase/lib/urls"; import _ from "underscore"; @@ -36,9 +35,9 @@ export const saveCollection = createThunkAction(SAVE_COLLECTION, (collection) => } if (response.id != null) { dispatch(reset("collection")); + // use `replace` so form url doesn't appear in history + dispatch(replace('/questions/')); } - // use `replace` so form url doesn't appear in history - dispatch(replace(Urls.collection(response))); return response; } catch (e) { // redux-form expects an object with either { field: error } or { _error: error } diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js index dcac3910d58d2fc00017a8d0e6a8976396f69db6..1c47d5c9c1325ca079a08e90c00f7d7090ae50b4 100644 --- a/frontend/src/metabase/services.js +++ b/frontend/src/metabase/services.js @@ -88,9 +88,13 @@ export const SlackApi = { updateSettings: PUT("/api/slack/settings"), }; +export const LdapApi = { + updateSettings: PUT("/api/ldap/settings") +}; + export const MetabaseApi = { db_list: GET("/api/database"), - db_list_with_tables: GET("/api/database?include_tables=true"), + db_list_with_tables: GET("/api/database?include_tables=true&include_cards=true"), db_create: POST("/api/database"), db_add_sample_dataset: POST("/api/database/sample_dataset"), db_get: GET("/api/database/:dbId"), diff --git a/frontend/src/metabase/user/components/UpdateUserDetails.jsx b/frontend/src/metabase/user/components/UpdateUserDetails.jsx index acff1daecbc5e3fabb3eb72368626272fd42ce13..f58c3c5ec05fe8ff75a8278a1e7a0b1df6258b12 100644 --- a/frontend/src/metabase/user/components/UpdateUserDetails.jsx +++ b/frontend/src/metabase/user/components/UpdateUserDetails.jsx @@ -83,7 +83,7 @@ export default class UpdateUserDetails extends Component { render() { const { updateUserResult, user } = this.props; const { formError, valid } = this.state; - const managed = user.google_auth + const managed = user.google_auth || user.ldap_auth; return ( <div> @@ -101,7 +101,7 @@ export default class UpdateUserDetails extends Component { </FormField> <FormField fieldName="email" formError={formError}> - <FormLabel title={ managed ? "Sign in with Google Email address" : "Email address"} fieldName="email" formError={formError} ></FormLabel> + <FormLabel title={ user.google_auth ? "Sign in with Google Email address" : "Email address"} fieldName="email" formError={formError} ></FormLabel> <input ref="email" className={ diff --git a/frontend/src/metabase/user/components/UserSettings.jsx b/frontend/src/metabase/user/components/UserSettings.jsx index 036ff8898b190217e7a2ae22610ef2bd5533cada..ed1ade9083e471000ce283e301c0bad2b8784c38 100644 --- a/frontend/src/metabase/user/components/UserSettings.jsx +++ b/frontend/src/metabase/user/components/UserSettings.jsx @@ -31,7 +31,7 @@ export default class UserSettings extends Component { render() { let { tab } = this.props; - const nonSSOManagedAccount = !this.props.user.google_auth + const nonSSOManagedAccount = !this.props.user.google_auth && !this.props.user.ldap_auth; let allClasses = "Grid-cell md-no-flex md-mt1 text-brand-hover bordered border-brand-hover rounded p1 md-p3 block cursor-pointer text-centered md-text-left", tabClasses = {}; diff --git a/frontend/test/e2e/admin/datamodel.spec.js b/frontend/test/e2e/admin/datamodel.spec.js index 10e9a67e6d43e50bfaaa5283fe42141da0899eb1..8908d894e583b9c8a83dc2c6b2541f6ea8524506 100644 --- a/frontend/test/e2e/admin/datamodel.spec.js +++ b/frontend/test/e2e/admin/datamodel.spec.js @@ -15,8 +15,9 @@ describeE2E("admin/datamodel", () => { ensureLoggedIn(server, driver, "bob@metabase.com", "12341234") ); + // TODO Atte Keinänen 6/22/17: Data model specs are easy to convert to Enzyme, disabled until conversion has been done describe("data model editor", () => { - it("should allow admin to edit data model", async () => { + xit("should allow admin to edit data model", async () => { await driver.get(`${server.host}/admin/datamodel/database`); // hide orders table @@ -45,7 +46,7 @@ describeE2E("admin/datamodel", () => { //TODO: verify tables and fields are hidden in query builder }); - it("should allow admin to create segments and metrics", async () => { + xit("should allow admin to create segments and metrics", async () => { await driver.get(`${server.host}/admin/datamodel/database/1/table/2`); // add a segment diff --git a/frontend/test/e2e/admin/settings.spec.js b/frontend/test/e2e/admin/settings.spec.js index 8960e4f39f5d578856abec63158b5b747e7341f5..84226d9ed7ce3a2309e076a274632eccd74e2ecb 100644 --- a/frontend/test/e2e/admin/settings.spec.js +++ b/frontend/test/e2e/admin/settings.spec.js @@ -11,8 +11,9 @@ describeE2E("admin/settings", () => { ensureLoggedIn(server, driver, "bob@metabase.com", "12341234") ); + // TODO Atte Keinänen 6/22/17: Disabled because we already have converted this to Jest&Enzyme in other branch describe("admin settings", () => { - it("should persist a setting", async () => { + xit("should persist a setting", async () => { // pick a random site name to try updating it to const siteName = "Metabase" + Math.random(); diff --git a/frontend/test/e2e/dashboard/dashboard.spec.js b/frontend/test/e2e/dashboard/dashboard.spec.js index 7731cbcc500de8c617eee51e406313043e4da739..484e75e29aefb2ef0a5f61708a302137c64edfce 100644 --- a/frontend/test/e2e/dashboard/dashboard.spec.js +++ b/frontend/test/e2e/dashboard/dashboard.spec.js @@ -17,6 +17,7 @@ describeE2E("dashboards/dashboards", () => { }); describe("dashboards list", () => { + // TODO Atte Keinänen 6/22/17: Failing test, disabled until converted to use Jest and Enzyme xit("should let you create new dashboards, see them, filter them and enter them", async () => { // Delegate dashboard creation to dashboard list test code await createDashboardInEmptyState(); diff --git a/frontend/test/e2e/dashboards/dashboards.spec.js b/frontend/test/e2e/dashboards/dashboards.spec.js index e8d18e433cfaa43af3558349cdd998f6dce0ee06..58d26e4055f012c928ae9436f3182da1902bc9a1 100644 --- a/frontend/test/e2e/dashboards/dashboards.spec.js +++ b/frontend/test/e2e/dashboards/dashboards.spec.js @@ -17,6 +17,7 @@ describeE2E("dashboards/dashboards", () => { await ensureLoggedIn(server, driver, "bob@metabase.com", "12341234"); }); + // TODO Atte Keinänen 6/22/17: Failing test, disabled until converted to use Jest and Enzyme xit("should let you create new dashboards, see them, filter them and enter them", async () => { await d.get("/dashboards"); await d.screenshot("screenshots/dashboards.png"); diff --git a/frontend/test/e2e/parameters/questions.spec.js b/frontend/test/e2e/parameters/questions.spec.js index 969ff8f1625a2335e33a72f2af74ee3a8184893a..1ec91bcc28850e06a3e1d74e037925104ff6bd4a 100644 --- a/frontend/test/e2e/parameters/questions.spec.js +++ b/frontend/test/e2e/parameters/questions.spec.js @@ -56,7 +56,9 @@ describeE2E("parameters", () => { // make sure it's enabled await d.select(":react(SettingsSetting) .flex .text-bold").waitText("Enabled"); }); - it("should allow users to create parameterized SQL questions", async () => { + + // TODO Atte Keinänen 6/22/17: Failing test, disabled until converted to use Jest and Enzyme + xit("should allow users to create parameterized SQL questions", async () => { await d::startNativeQuestion("select count(*) from products where {{category}}") await d.sleep(500); diff --git a/frontend/test/e2e/query_builder/query_builder.spec.js b/frontend/test/e2e/query_builder/query_builder.spec.js index 6209e3686b24b30b13a278f7a2fbe40658121869..44d3190311aa30dc7716594629d19bb04f151707 100644 --- a/frontend/test/e2e/query_builder/query_builder.spec.js +++ b/frontend/test/e2e/query_builder/query_builder.spec.js @@ -17,7 +17,8 @@ describeE2E("query_builder", () => { }); describe("tables", () => { - it("should allow users to create pivot tables", async () => { + // TODO Atte Keinänen 6/22/17: Failing test, disabled until converted to use Jest and Enzyme + xit("should allow users to create pivot tables", async () => { // load the query builder and screenshot blank await d.get("/question"); await d.screenshot("screenshots/qb-initial.png"); diff --git a/frontend/test/e2e/query_builder/tutorial.spec.js b/frontend/test/e2e/query_builder/tutorial.spec.js index 9298adaf0a18211a64e7647e82286bf14ddf5504..5c8aae8c95d29a9bd684bf413f1c60d766de4b81 100644 --- a/frontend/test/e2e/query_builder/tutorial.spec.js +++ b/frontend/test/e2e/query_builder/tutorial.spec.js @@ -16,7 +16,8 @@ describeE2E("tutorial", () => { await ensureLoggedIn(server, driver, "bob@metabase.com", "12341234"); }); - it("should guide users through query builder tutorial", async () => { + // TODO Atte Keinänen 6/22/17: Failing test, disabled until converted to use Jest and Enzyme + xit("should guide users through query builder tutorial", async () => { await driver.get(`${server.host}/?new`); await waitForUrl(driver, `${server.host}/?new`); diff --git a/frontend/test/unit/lib/formatting.spec.js b/frontend/test/unit/lib/formatting.spec.js index 3898e3b737cf2a86570bd87664617e415907022c..be2aa4cbaf57ccfe0bee36e30d78af7fe2fdf36c 100644 --- a/frontend/test/unit/lib/formatting.spec.js +++ b/frontend/test/unit/lib/formatting.spec.js @@ -1,5 +1,5 @@ -import { isElementOfType } from "react-addons-test-utils"; +import { isElementOfType } from "react-dom/test-utils"; import { formatNumber, formatValue, formatUrl } from 'metabase/lib/formatting'; import ExternalLink from "metabase/components/ExternalLink.jsx"; diff --git a/frontend/test/unit/query_builder/components/FieldName.spec.js b/frontend/test/unit/query_builder/components/FieldName.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c31755ccb13f02bec3b96540b266da0510596989 --- /dev/null +++ b/frontend/test/unit/query_builder/components/FieldName.spec.js @@ -0,0 +1,45 @@ + +import React from "react"; +import ReactDOM from "react-dom"; +import { renderIntoDocument } from "react-dom/test-utils"; + +import FieldName from "metabase/query_builder/components/FieldName.jsx"; + +const TABLE_A = { + fields: [ + { id: 1, display_name: "Foo" }, + { id: 2, display_name: "Baz", parent_id: 1 } + ] +} +const TABLE_B = { + fields: [ + { id: 3, display_name: "Bar", target: { table: TABLE_A } } + ] +} + +describe("FieldName", () => { + it("should render regular field correctly", () => { + let fieldName = renderIntoDocument(<FieldName field={1} tableMetadata={TABLE_A}/>); + expect(ReactDOM.findDOMNode(fieldName).textContent).toEqual("Foo"); + }); + it("should render local field correctly", () => { + let fieldName = renderIntoDocument(<FieldName field={["field-id", 1]} tableMetadata={TABLE_A}/>); + expect(ReactDOM.findDOMNode(fieldName).textContent).toEqual("Foo"); + }); + it("should render foreign key correctly", () => { + let fieldName = renderIntoDocument(<FieldName field={["fk->", 3, 1]} tableMetadata={TABLE_B}/>); + expect(ReactDOM.findDOMNode(fieldName).textContent).toEqual("BarFoo"); + }); + it("should render datetime correctly", () => { + let fieldName = renderIntoDocument(<FieldName field={["datetime-field", 1, "week"]} tableMetadata={TABLE_A}/>); + expect(ReactDOM.findDOMNode(fieldName).textContent).toEqual("Foo: Week"); + }); + it("should render nested field correctly", () => { + let fieldName = renderIntoDocument(<FieldName field={2} tableMetadata={TABLE_A}/>); + expect(ReactDOM.findDOMNode(fieldName).textContent).toEqual("Foo: Baz"); + }); + it("should render nested fk field correctly", () => { + let fieldName = renderIntoDocument(<FieldName field={["fk->", 3, 2]} tableMetadata={TABLE_B}/>); + expect(ReactDOM.findDOMNode(fieldName).textContent).toEqual("BarFoo: Baz"); + }); +}); diff --git a/frontend/test/unit/visualizations/components/LegendVertical.spec.js b/frontend/test/unit/visualizations/components/LegendVertical.spec.js index 9cc07f77f858ca458437ee036799735e13c8055d..03c9330c03f0efac3f2dd4f7d91fb21135890a05 100644 --- a/frontend/test/unit/visualizations/components/LegendVertical.spec.js +++ b/frontend/test/unit/visualizations/components/LegendVertical.spec.js @@ -1,7 +1,7 @@ import React from "react"; import ReactDOM from "react-dom"; -import { renderIntoDocument } from "react-addons-test-utils"; +import { renderIntoDocument } from "react-dom/test-utils"; import LegendVertical from "metabase/visualizations/components/LegendVertical.jsx"; diff --git a/frontend/test/unit/visualizations/components/Visualization.spec.js b/frontend/test/unit/visualizations/components/Visualization.spec.js index c9e20f9f396e7b17678eaa3169cd99a00881001a..459d760ce80d15a2497a5af140c2ed8fd9a5ec07 100644 --- a/frontend/test/unit/visualizations/components/Visualization.spec.js +++ b/frontend/test/unit/visualizations/components/Visualization.spec.js @@ -1,6 +1,6 @@ import React from "react"; -import { renderIntoDocument, scryRenderedComponentsWithType as scryWithType } from "react-addons-test-utils"; +import { renderIntoDocument, scryRenderedComponentsWithType as scryWithType } from "react-dom/test-utils"; import Visualization from "metabase/visualizations/components/Visualization.jsx"; diff --git a/package.json b/package.json index 53d148457277f4e91e79597ecc6af6de3e4cf5d0..8f0834dbac5ac015d5ff5544e9062452cf5817d5 100644 --- a/package.json +++ b/package.json @@ -131,7 +131,6 @@ "postcss-url": "^6.0.4", "prettier": "^0.22.0", "promise-loader": "^1.0.0", - "react-addons-test-utils": "^15.4.2", "react-hot-loader": "^1.3.0", "react-test-renderer": "^15.5.4", "sauce-connect-launcher": "^1.1.1", @@ -154,7 +153,7 @@ "test": "yarn run test-jest && yarn run test-integrated && yarn run test-karma", "test-karma": "karma start frontend/test/karma.conf.js --single-run", "test-karma-watch": "karma start frontend/test/karma.conf.js --auto-watch --reporters nyan", - "test-jest": "jest --config jest.unit.conf.json", + "test-jest": "jest --maxWorkers=10 --config jest.unit.conf.json", "test-jest-watch": "jest --config jest.unit.conf.json --watch", "test-integrated": "babel-node ./frontend/test/run-integrated-tests.js", "test-integrated-watch": "babel-node ./frontend/test/run-integrated-tests.js --watch", diff --git a/project.clj b/project.clj index db982ea4083b96cf23faf6cec520072d08129564..504c2d9d5979520b628a92dab9694b26c470faf4 100644 --- a/project.clj +++ b/project.clj @@ -69,6 +69,7 @@ [net.sf.cssbox/cssbox "4.12" ; HTML / CSS rendering :exclusions [org.slf4j/slf4j-api]] [net.sourceforge.jtds/jtds "1.3.1"] ; Open Source SQL Server driver + [org.clojars.pntblnk/clj-ldap "0.0.12"] ; LDAP client [org.liquibase/liquibase-core "3.5.3"] ; migration management (Java lib) [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) diff --git a/resources/migrations/000_migrations.yaml b/resources/migrations/000_migrations.yaml index 6c2af3c37612e335f64821cb334e9c5d7d9cd747..ce0bff1cc3e164e82fc416f1d77b7371459507e2 100644 --- a/resources/migrations/000_migrations.yaml +++ b/resources/migrations/000_migrations.yaml @@ -3021,7 +3021,7 @@ databaseChangeLog: columnNames: group_id, object ############################################################ Tweaks to metabase_table ############################################################ # Modify the length of metabase_table.schema from 256 -> 254 - # It turns out MySQL InnoDB indecies have to be 767 bytes or less (at least for older versions of MySQL) + # It turns out MySQL InnoDB indices have to be 767 bytes or less (at least for older versions of MySQL) # and 'utf8' text columns can use up to 3 bytes per character in MySQL -- see http://stackoverflow.com/a/22515986/1198455 # So 256 * 3 = 768 bytes (too large to index / add unique constraints) # Drop this to 254; 254 * 3 = 762, which should give us room to index it along with a 4-byte integer as well if need be @@ -3323,7 +3323,7 @@ databaseChangeLog: id: 49 author: camsaul changes: - ######################################## Card public_uuid & indecies ######################################## + ######################################## Card public_uuid & indices ######################################## - addColumn: tableName: report_card columns: @@ -3346,7 +3346,7 @@ databaseChangeLog: columns: column: name: public_uuid - ######################################## Dashboard public_uuid & indecies ######################################## + ######################################## Dashboard public_uuid & indices ######################################## - addColumn: tableName: report_dashboard columns: @@ -3646,3 +3646,27 @@ databaseChangeLog: columns: - column: name: dashboard_id + - changeSet: + id: 56 + author: wwwiiilll + changes: + - addColumn: + tableName: core_user + columns: + - column: + name: ldap_auth + type: boolean + defaultValueBoolean: false + constraints: + nullable: false + - changeSet: + id: 57 + author: camsaul + changes: + - addColumn: + tableName: report_card + columns: + - column: + name: result_metadata + type: text + remarks: 'Serialized JSON containing metadata about the result columns from running the query.' diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj index 53df71031d685b3f36db501ab2a73c826276eed1..69b37ea253e11aaca871e5d52cc6f7bf916d9e13 100644 --- a/src/metabase/api/card.clj +++ b/src/metabase/api/card.clj @@ -29,7 +29,9 @@ [metabase.query-processor [interface :as qpi] [util :as qputil]] - [metabase.query-processor.middleware.cache :as cache] + [metabase.query-processor.middleware + [cache :as cache] + [results-metadata :as results-metadata]] [metabase.util.schema :as su] [ring.util.codec :as codec] [schema.core :as s] @@ -206,14 +208,44 @@ ;;; ------------------------------------------------------------ Saving Cards ------------------------------------------------------------ + +;; When a new Card is saved, we wouldn't normally have the results metadata for it until the first time its query is ran. +;; As a workaround, we'll calculate this metadata and return it with all query responses, and then let the frontend +;; pass it back to us when saving or updating a Card. +;; As a basic step to make sure the Metadata is valid we'll also pass a simple checksum and have the frontend pass it back to us. +;; See the QP `results-metadata` middleware namespace for more details + +(s/defn ^:private ^:always-validate result-metadata-for-query :- results-metadata/ResultsMetadata + "Fetch the results metadata for a QUERY by running the query and seeing what the QP gives us in return. + This is obviously a bit wasteful so hopefully we can avoid having to do this." + [query] + (binding [qpi/*disable-qp-logging* true] + (get-in (qp/process-query query) [:data :results_metadata :columns]))) + +(s/defn ^:private ^:always-validate result-metadata :- (s/maybe results-metadata/ResultsMetadata) + "Get the right results metadata for this CARD. We'll check to see whether the METADATA passed in seems valid; + otherwise we'll run the query ourselves to get the right values." + [query metadata checksum] + (let [valid-metadata? (and (results-metadata/valid-checksum? metadata checksum) + (s/validate results-metadata/ResultsMetadata metadata))] + (log/info (str "Card results metadata passed in to API is " (cond + valid-metadata? "VALID. Thanks!" + metadata "INVALID. Running query to fetch correct metadata." + :else "MISSING. Running query to fetch correct metadata."))) + (if valid-metadata? + metadata + (result-metadata-for-query query)))) + (api/defendpoint POST "/" "Create a new `Card`." - [:as {{:keys [dataset_query description display name visualization_settings collection_id]} :body}] + [:as {{:keys [dataset_query description display name visualization_settings collection_id result_metadata metadata_checksum]} :body}] {name su/NonBlankString description (s/maybe su/NonBlankString) display su/NonBlankString visualization_settings su/Map - collection_id (s/maybe su/IntGreaterThanZero)} + collection_id (s/maybe su/IntGreaterThanZero) + result_metadata (s/maybe results-metadata/ResultsMetadata) + metadata_checksum (s/maybe su/NonBlankString)} ;; check that we have permissions to run the query that we're trying to save (api/check-403 (perms/set-has-full-permissions-for-set? @api/*current-user-permissions-set* (card/query-perms-set dataset_query :write))) ;; check that we have permissions for the collection we're trying to save this card to, if applicable @@ -227,7 +259,8 @@ :display display :name name :visualization_settings visualization_settings - :collection_id collection_id) + :collection_id collection_id + :result_metadata (result-metadata dataset_query result_metadata metadata_checksum)) (events/publish-event! :card-create))) @@ -275,6 +308,17 @@ (api/check-embedding-enabled) (api/check-superuser))) + +(defn- result-metadata-for-updating + "If CARD's query is being updated, return the value that should be saved for `result_metadata`. This *should* be passed + in to the API; if so, verifiy that it was correct (the checksum is valid); if not, go fetch it. + If the query has not changed, this returns `nil`, which means the value won't get updated below." + [card query metadata checksum] + (when (and query + (not= query (:dataset_query card))) + + (result-metadata query metadata checksum))) + (defn- publish-card-update! "Publish an event appropriate for the update(s) done to this CARD (`:card-update`, or archiving/unarchiving events)." [card archived?] @@ -290,7 +334,7 @@ (api/defendpoint PUT "/:id" "Update a `Card`." - [id :as {{:keys [dataset_query description display name visualization_settings archived collection_id enable_embedding embedding_params], :as body} :body}] + [id :as {{:keys [dataset_query description display name visualization_settings archived collection_id enable_embedding embedding_params result_metadata metadata_checksum], :as body} :body}] {name (s/maybe su/NonBlankString) dataset_query (s/maybe su/Map) display (s/maybe su/NonBlankString) @@ -299,19 +343,23 @@ archived (s/maybe s/Bool) enable_embedding (s/maybe s/Bool) embedding_params (s/maybe su/EmbeddingParams) - collection_id (s/maybe su/IntGreaterThanZero)} + collection_id (s/maybe su/IntGreaterThanZero) + result_metadata (s/maybe results-metadata/ResultsMetadata) + metadata_checksum (s/maybe su/NonBlankString)} (let [card (api/write-check Card id)] ;; Do various permissions checks (check-allowed-to-change-collection card collection_id) (check-allowed-to-modify-query card dataset_query) (check-allowed-to-unarchive card archived) (check-allowed-to-change-embedding card enable_embedding embedding_params) - ;; ok, now save the Card - (db/update! Card id - ;; `collection_id` and `description` can be `nil` (in order to unset them). Other values should only be modified if they're passed in as non-nil - (u/select-keys-when body - :present #{:collection_id :description} - :non-nil #{:dataset_query :display :name :visualization_settings :archived :enable_embedding :embedding_params})) + ;; make sure we have the correct `result_metadata` + (let [body (assoc body :result_metadata (result-metadata-for-updating card dataset_query result_metadata metadata_checksum))] + ;; ok, now save the Card + (db/update! Card id + ;; `collection_id` and `description` can be `nil` (in order to unset them). Other values should only be modified if they're passed in as non-nil + (u/select-keys-when body + :present #{:collection_id :description} + :non-nil #{:dataset_query :display :name :visualization_settings :archived :enable_embedding :embedding_params :result_metadata}))) ;; Fetch the updated Card from the DB (let [card (Card id)] (publish-card-update! card archived) diff --git a/src/metabase/api/database.clj b/src/metabase/api/database.clj index e6e3808ee17a9df05d497388f9de07d93ddcf9bd..116edef95aa2afb74e60248a8a5a142948e1ce5c 100644 --- a/src/metabase/api/database.clj +++ b/src/metabase/api/database.clj @@ -11,11 +11,13 @@ [util :as u]] [metabase.api.common :as api] [metabase.models + [card :refer [Card]] [database :as database :refer [Database protected-password]] [field :refer [Field]] [interface :as mi] [permissions :as perms] [table :refer [Table]]] + [metabase.query-processor.util :as qputil] [metabase.util.schema :as su] [schema.core :as s] [toucan @@ -30,12 +32,11 @@ ;;; ------------------------------------------------------------ GET /api/database ------------------------------------------------------------ - (defn- add-tables [dbs] (let [db-id->tables (group-by :db_id (filter mi/can-read? (db/select Table - :active true - :db_id [:in (map :id dbs)] - {:order-by [[:%lower.display_name :asc]]})))] + :active true + :db_id [:in (map :id dbs)] + {:order-by [[:%lower.display_name :asc]]})))] (for [db dbs] (assoc db :tables (get db-id->tables (:id db) []))))) @@ -47,16 +48,91 @@ (user-has-perms? perms/native-read-path) :read :else :none))))) -(defn- dbs-list [include-tables?] +(defn- card-database-supports-nested-queries? [{{database-id :database} :dataset_query, :as card}] + (when database-id + (when-let [driver (driver/database-id->driver database-id)] + (driver/driver-supports? driver :nested-queries) + (mi/can-read? card)))) + +(defn- card-has-ambiguous-columns? + "We know a card has ambiguous columns if any of the columns that come back end in `_2` (etc.) because that's what + clojure.java.jdbc 'helpfully' does for us automatically. + Presence of ambiguous columns disqualifies a query for use as a source query because something like + + SELECT name + FROM ( + SELECT x.name, y.name + FROM x + LEFT JOIN y on x.id = y.id + ) + + would be ambiguous. Too many things break when attempting to use a query like this. In the future, this may be + supported, but it will likely require rewriting the source SQL query to add appropriate aliases (this is even + trickier if the source query uses `SELECT *`)." + [{result-metadata :result_metadata}] + (some (partial re-find #"_2$") + (map (comp name :name) result-metadata))) + +(defn- card-uses-unnestable-aggregation? + "Since cumulative count and cumulative sum aggregations are done in Clojure-land we can't use Cards that + use queries with those aggregations as source queries. This function determines whether CARD is using one + of those queries so we can filter it out in Clojure-land." + [{{{aggregations :aggregation} :query} :dataset_query}] + (when (seq aggregations) + (some (fn [[ag-type]] + (contains? #{:cum-count :cum-sum} (qputil/normalize-token ag-type))) + ;; if we were passed in old-style [ag] instead of [[ag1], [ag2]] convert to new-style so we can iterate over list of ags + (if-not (sequential? (first aggregations)) + [aggregations] + aggregations)))) + +(defn- source-query-cards + "Fetch the Cards that can be used as source queries (e.g. presented as virtual tables)." + [] + (as-> (db/select [Card :name :description :database_id :dataset_query :id :collection_id :result_metadata] + :result_metadata [:not= nil] + {:order-by [[:%lower.name :asc]]}) <> + (filter card-database-supports-nested-queries? <>) + (remove card-uses-unnestable-aggregation? <>) + (remove card-has-ambiguous-columns? <>) + (map #(dissoc % :result_metadata) <>) ; frontend has no use for result_metadata just yet so strip it out because it can be big + (hydrate <> :collection))) + +(defn- cards-virtual-tables + "Return a sequence of 'virtual' Table metadata for eligible Cards. + (This takes the Cards from `source-query-cards` and returns them in a format suitable for consumption by the Query Builder.)" + [] + (for [card (source-query-cards)] + {:id (str "card__" (u/get-id card)) + :db_id database/virtual-id + :display_name (:name card) + :schema (get-in card [:collection :name] "All questions") + :description (:description card)})) + +;; "Virtual" tables for saved cards simulate the db->schema->table hierarchy by doing fake-db->collection->card +(defn- add-virtual-tables-for-saved-cards [dbs] + (let [virtual-tables (cards-virtual-tables)] + ;; only add the 'Saved Questions' DB if there are Cards that can be used + (if-not (seq virtual-tables) + dbs + (conj (vec dbs) + {:name "Saved Questions" + :id database/virtual-id + :features #{:basic-aggregations} + :tables virtual-tables})))) + +(defn- dbs-list [include-tables? include-cards?] (when-let [dbs (seq (filter mi/can-read? (db/select Database {:order-by [:%lower.name]})))] - (add-native-perms-info (if-not include-tables? - dbs - (add-tables dbs))))) + (cond-> (add-native-perms-info dbs) + include-tables? add-tables + include-cards? add-virtual-tables-for-saved-cards))) (api/defendpoint GET "/" "Fetch all `Databases`." - [include_tables] - (or (dbs-list include_tables) + [include_tables include_cards] + {include_tables (s/maybe su/BooleanString) + include_cards (s/maybe su/BooleanString)} + (or (dbs-list include_tables include_cards) [])) diff --git a/src/metabase/api/dataset.clj b/src/metabase/api/dataset.clj index 913c177238aca7600ac3581214c5b264b6910bff..22e476c51aac6a9c5ca20aeb0c85e364ff1a5106 100644 --- a/src/metabase/api/dataset.clj +++ b/src/metabase/api/dataset.clj @@ -3,6 +3,7 @@ (:require [cheshire.core :as json] [clojure.data.csv :as csv] [clojure.string :as str] + [clojure.tools.logging :as log] [compojure.core :refer [POST]] [dk.ative.docjure.spreadsheet :as spreadsheet] [metabase @@ -12,7 +13,8 @@ [metabase.api.common :as api] [metabase.api.common.internal :refer [route-fn-name]] [metabase.models - [database :refer [Database]] + [card :refer [Card]] + [database :as database :refer [Database]] [query :as query]] [metabase.query-processor.util :as qputil] [metabase.util.schema :as su] @@ -37,14 +39,28 @@ ;;; ------------------------------------------------------------ Running a Query Normally ------------------------------------------------------------ +(defn- query->source-card-id + "Return the ID of the Card used as the \"source\" query of this query, if applicable; otherwise return `nil`. + Used so `:card-id` context can be passed along with the query so Collections perms checking is done if appropriate." + [outer-query] + (let [source-table (qputil/get-in-normalized outer-query [:query :source-table])] + (when (string? source-table) + (when-let [[_ card-id-str] (re-matches #"^card__(\d+$)" source-table)] + (log/info (str "Source query for this query is Card " card-id-str)) + (u/prog1 (Integer/parseInt card-id-str) + (api/read-check Card <>)))))) + (api/defendpoint POST "/" "Execute a query and retrieve the results in the usual format." [:as {{:keys [database], :as query} :body}] {database s/Int} - (api/read-check Database database) + ;; don't permissions check the 'database' if it's the virtual database. That database doesn't actually exist :-) + (when-not (= database database/virtual-id) + (api/read-check Database database)) ;; add sensible constraints for results limits on our query - (let [query (assoc query :constraints default-query-constraints)] - (qp/process-query-and-save-execution! query {:executed-by api/*current-user-id*, :context :ad-hoc}))) + (let [source-card-id (query->source-card-id query)] + (qp/process-query-and-save-execution! (assoc query :constraints default-query-constraints) + {:executed-by api/*current-user-id*, :context :ad-hoc, :card-id source-card-id, :nested? (boolean source-card-id)}))) ;;; ------------------------------------------------------------ Downloading Query Results in Other Formats ------------------------------------------------------------ diff --git a/src/metabase/api/ldap.clj b/src/metabase/api/ldap.clj new file mode 100644 index 0000000000000000000000000000000000000000..074ffc92c0f80b38bd66f0bf8494d78eb2d11059 --- /dev/null +++ b/src/metabase/api/ldap.clj @@ -0,0 +1,111 @@ +(ns metabase.api.ldap + "/api/ldap endpoints" + (:require [clojure.tools.logging :as log] + [clojure.set :as set] + [compojure.core :refer [PUT]] + [metabase.api.common :refer :all] + [metabase.config :as config] + [metabase.integrations.ldap :as ldap] + [metabase.models.setting :as setting] + [metabase.util.schema :as su])) + +(def ^:private ^:const mb-settings->ldap-details + {:ldap-enabled :enabled + :ldap-host :host + :ldap-port :port + :ldap-bind-dn :bind-dn + :ldap-password :password + :ldap-security :security + :ldap-user-base :user-base + :ldap-user-filter :user-filter + :ldap-attribute-email :attribute-email + :ldap-attribute-firstname :attribute-firstname + :ldap-attribute-lastname :attribute-lastname + :ldap-group-sync :group-sync + :ldap-group-base :group-base}) + +(defn- humanize-error-messages + "Convert raw error message responses from our LDAP tests into our normal api error response structure." + [{:keys [status message]}] + (when (not= :SUCCESS status) + (log/warn "Problem connecting to LDAP server:" message) + (let [conn-error {:errors {:ldap-host "Wrong host or port" + :ldap-port "Wrong host or port"}} + security-error {:errors {:ldap-port "Wrong port or security setting" + :ldap-security "Wrong port or security setting"}} + bind-dn-error {:errors {:ldap-bind-dn "Wrong bind DN"}} + creds-error {:errors {:ldap-bind-dn "Wrong bind DN or password" + :ldap-password "Wrong bind DN or password"}}] + (condp re-matches message + #".*UnknownHostException.*" + conn-error + + #".*ConnectException.*" + conn-error + + #".*SocketException.*" + security-error + + #".*SSLException.*" + security-error + + #"^For input string.*" + {:errors {:ldap-host "Invalid hostname, do not add the 'ldap://' or 'ldaps://' prefix"}} + + #".*password was incorrect.*" + {:errors {:ldap-password "Password was incorrect"}} + + #"^Unable to bind as user.*" + bind-dn-error + + #"^Unable to parse bind DN.*" + {:errors {:ldap-bind-dn "Invalid bind DN"}} + + #".*AcceptSecurityContext error, data 525,.*" + bind-dn-error + + #".*AcceptSecurityContext error, data 52e,.*" + creds-error + + #".*AcceptSecurityContext error, data 532,.*" + {:errors {:ldap-password "Password is expired"}} + + #".*AcceptSecurityContext error, data 533,.*" + {:errors {:ldap-bind-dn "Account is disabled"}} + + #".*AcceptSecurityContext error, data 701,.*" + {:errors {:ldap-bind-dn "Account is expired"}} + + #"^User search base does not exist .*" + {:errors {:ldap-user-base "User search base does not exist or is unreadable"}} + + #"^Group search base does not exist .*" + {:errors {:ldap-group-base "Group search base does not exist or is unreadable"}} + + ;; everything else :( + #"(?s).*" + {:message message})))) + +(defendpoint PUT "/settings" + "Update LDAP related settings. You must be a superuser to do this." + [:as {settings :body}] + {settings su/Map} + (check-superuser) + (let [ldap-settings (select-keys settings (keys mb-settings->ldap-details)) + ldap-details (-> (set/rename-keys ldap-settings mb-settings->ldap-details) + (assoc :port + (when-not (empty? (:ldap-port settings)) + (Integer/parseInt (:ldap-port settings))))) + results (if-not (:ldap-enabled settings) + ;; when disabled just respond with a success message + {:status :SUCCESS} + ;; otherwise validate settings + (ldap/test-ldap-connection ldap-details))] + (if (= :SUCCESS (:status results)) + ;; test succeeded, save our settings + (setting/set-many! ldap-settings) + ;; test failed, return result message + {:status 500 + :body (humanize-error-messages results)}))) + +(define-routes) diff --git a/src/metabase/api/routes.clj b/src/metabase/api/routes.clj index 78e16f295229f6e846b8a0fb1a97f1b0805395cf..90b1b20751da34b986d2c1ab3526e4239a6eb504 100644 --- a/src/metabase/api/routes.clj +++ b/src/metabase/api/routes.clj @@ -15,6 +15,7 @@ [geojson :as geojson] [getting-started :as getting-started] [label :as label] + [ldap :as ldap] [metric :as metric] [notify :as notify] [permissions :as permissions] @@ -63,6 +64,7 @@ (context "/getting_started" [] (+auth getting-started/routes)) (context "/geojson" [] (+auth geojson/routes)) (context "/label" [] (+auth label/routes)) + (context "/ldap" [] (+auth ldap/routes)) (context "/metric" [] (+auth metric/routes)) (context "/notify" [] (+apikey notify/routes)) (context "/permissions" [] (+auth permissions/routes)) diff --git a/src/metabase/api/session.clj b/src/metabase/api/session.clj index 8432ae542678b85c31ae247352b8add6e707af41..dfe3116f2e33ac4a45f23dd8b42f56eebcc9e68c 100644 --- a/src/metabase/api/session.clj +++ b/src/metabase/api/session.clj @@ -11,6 +11,7 @@ [util :as u]] [metabase.api.common :as api] [metabase.email.messages :as email] + [metabase.integrations.ldap :as ldap] [metabase.models [session :refer [Session]] [setting :refer [defsetting]] @@ -33,28 +34,42 @@ :user_id (:id user)) (events/publish-event! :user-login {:user_id (:id user), :session_id <>, :first_login (not (boolean (:last_login user)))}))) - ;;; ## API Endpoints (def ^:private login-throttlers - {:email (throttle/make-throttler :email) - :ip-address (throttle/make-throttler :email, :attempts-threshold 50)}) ; IP Address doesn't have an actual UI field so just show error by email + {:username (throttle/make-throttler :username) + :ip-address (throttle/make-throttler :username, :attempts-threshold 50)}) ; IP Address doesn't have an actual UI field so just show error by username (api/defendpoint POST "/" "Login." - [:as {{:keys [email password]} :body, remote-address :remote-addr}] - {email su/Email + [:as {{:keys [username password]} :body, remote-address :remote-addr}] + {username su/NonBlankString password su/NonBlankString} (throttle/check (login-throttlers :ip-address) remote-address) - (throttle/check (login-throttlers :email) email) - (let [user (db/select-one [User :id :password_salt :password :last_login], :email email, :is_active true)] + (throttle/check (login-throttlers :username) username) + ;; Primitive "strategy implementation", should be reworked for modular providers in #3210 + (or + ;; First try LDAP if it's enabled + (when (ldap/ldap-configured?) + (try + (when-let [user-info (ldap/find-user username)] + (if (ldap/verify-password user-info password) + {:id (create-session! (ldap/fetch-or-create-user! user-info password))} + ;; Since LDAP knows about the user, fail here to prevent the local strategy to be tried with a possibly outdated password + (throw (ex-info "Password did not match stored password." {:status-code 400 + :errors {:password "did not match stored password"}})))) + (catch com.unboundid.util.LDAPSDKException e + (log/error (u/format-color 'red "Problem connecting to LDAP server, will fallback to local authentication") (.getMessage e))))) + + ;; Then try local authentication + (when-let [user (db/select-one [User :id :password_salt :password :last_login], :email username, :is_active true)] + (when (pass/verify-password password (:password_salt user) (:password user)) + {:id (create-session! user)})) + + ;; If nothing succeeded complain about it ;; Don't leak whether the account doesn't exist or the password was incorrect - (when-not (and user - (pass/verify-password password (:password_salt user) (:password user))) - (throw (ex-info "Password did not match stored password." {:status-code 400 - :errors {:password "did not match stored password"}}))) - {:id (create-session! user)})) - + (throw (ex-info "Password did not match stored password." {:status-code 400 + :errors {:password "did not match stored password"}})))) (api/defendpoint DELETE "/" "Logout." @@ -192,7 +207,7 @@ (throttle/check (login-throttlers :ip-address) remote-address) ;; Verify the token is valid with Google (let [{:keys [given_name family_name email]} (google-auth-token-info token)] - (log/info "Successfully authenicated Google Auth token for:" given_name family_name) + (log/info "Successfully authenticated Google Auth token for:" given_name family_name) (google-auth-fetch-or-create-user! given_name family_name email))) diff --git a/src/metabase/api/table.clj b/src/metabase/api/table.clj index 83354618e2af92ea5db48f8b3557830cbb0e4777..01869fbca08cc230e60b8ecd4ef31fd50c676ca4 100644 --- a/src/metabase/api/table.clj +++ b/src/metabase/api/table.clj @@ -8,6 +8,8 @@ [util :as u]] [metabase.api.common :as api] [metabase.models + [card :refer [Card]] + [database :as database] [field :refer [Field]] [interface :as mi] [table :as table :refer [Table]]] @@ -96,6 +98,29 @@ (partial filter (fn [{:keys [visibility_type]}] (not= (keyword visibility_type) :sensitive))))))) +(api/defendpoint GET "/card__:id/query_metadata" + "Return metadata for the 'virtual' table for a Card." + [id] + (let [{metadata :result_metadata, card-name :name} (api/read-check (db/select-one [Card :dataset_query :result_metadata :name], :id id))] + {:display_name card-name + :db_id database/virtual-id + :id (str "card__" id) + :fields (for [col metadata] + (assoc col + :table_id (str "card__" id) + :id [:field-literal (:name col) (:base_type col)] + ;; don't return :special_type if it's a PK or FK because it confuses the frontend since it can't actually be used that way IRL + :special_type (when-let [special-type (keyword (:special_type col))] + (when-not (or (isa? special-type :type/PK) + (isa? special-type :type/FK)) + special-type))))})) + +(api/defendpoint GET "/card__:id/fks" + "Return FK info for the 'virtual' table for a Card. This is always empty, so this endpoint + serves mainly as a placeholder to avoid having to change anything on the frontend." + [] + []) ; return empty array + (api/defendpoint GET "/:id/fks" "Get all foreign keys whose destination is a `Field` that belongs to this `Table`." diff --git a/src/metabase/api/user.clj b/src/metabase/api/user.clj index 8358b439365942f93d99e34d8119fa637e6e3672..73fcd511c230f21d8b2984725ee0c14437825c67 100644 --- a/src/metabase/api/user.clj +++ b/src/metabase/api/user.clj @@ -5,6 +5,7 @@ [common :as api] [session :as session-api]] [metabase.email.messages :as email] + [metabase.integrations.ldap :as ldap] [metabase.models.user :as user :refer [User]] [metabase.util :as u] [metabase.util.schema :as su] @@ -21,7 +22,7 @@ (api/defendpoint GET "/" "Fetch a list of all active `Users` for the admin People page." [] - (db/select [User :id :first_name :last_name :email :is_superuser :google_auth :last_login], :is_active true)) + (db/select [User :id :first_name :last_name :email :is_superuser :google_auth :ldap_auth :last_login], :is_active true)) (defn- reactivate-user! [existing-user first-name last-name] (when-not (:is_active existing-user) @@ -32,7 +33,9 @@ :is_superuser false ;; if the user orignally logged in via Google Auth and it's no longer enabled, convert them into a regular user (see Issue #3323) :google_auth (boolean (and (:google_auth existing-user) - (session-api/google-auth-client-id))))) ; if google-auth-client-id is set it means Google Auth is enabled + (session-api/google-auth-client-id))) ; if google-auth-client-id is set it means Google Auth is enabled + :ldap_auth (boolean (and (:ldap_auth existing-user) + (ldap/ldap-configured?))))) ;; now return the existing user whether they were originally active or not (User (u/get-id existing-user))) diff --git a/src/metabase/driver.clj b/src/metabase/driver.clj index cf7dd70f3b0bf975f1d5eeb5d0cd30b52799f130..833386226226971f4fad626c5a6143ae65f6cab1 100644 --- a/src/metabase/driver.clj +++ b/src/metabase/driver.clj @@ -138,7 +138,8 @@ * `:expressions` - Does this driver support [expressions](https://github.com/metabase/metabase/wiki/Query-Language-'98#expressions) (e.g. adding the values of 2 columns together)? * `:dynamic-schema` - Does this Database have no fixed definitions of schemas? (e.g. Mongo) * `:native-parameters` - Does the driver support parameter substitution on native queries? - * `:expression-aggregations` - Does the driver support using expressions inside aggregations? e.g. something like \"sum(x) + count(y)\" or \"avg(x + y)\"") + * `:expression-aggregations` - Does the driver support using expressions inside aggregations? e.g. something like \"sum(x) + count(y)\" or \"avg(x + y)\" + * `:nested-queries` - Does the driver support using a query as the `:source-query` of another MBQL query? Examples are CTEs or subselects in SQL queries.") (field-values-lazy-seq ^clojure.lang.Sequential [this, ^FieldInstance field] "Return a lazy sequence of all values of FIELD. @@ -361,7 +362,8 @@ (let [db-id->engine (memoize (fn [db-id] (db/select-one-field :engine Database, :id db-id)))] (fn [db-id] {:pre [db-id]} - (engine->driver (db-id->engine db-id))))) + (when-let [engine (db-id->engine db-id)] + (engine->driver engine))))) ;; ## Implementation-Agnostic Driver API diff --git a/src/metabase/driver/generic_sql.clj b/src/metabase/driver/generic_sql.clj index a2157fa9076811d95f4403b76c97ff81cd1f7d8d..eadfb16ddd560e89c2359bd327b43fd7edbf9357 100644 --- a/src/metabase/driver/generic_sql.clj +++ b/src/metabase/driver/generic_sql.clj @@ -338,7 +338,8 @@ :foreign-keys :expressions :expression-aggregations - :native-parameters} + :native-parameters + :nested-queries} (set-timezone-sql driver) (conj :set-timezone))) diff --git a/src/metabase/driver/generic_sql/query_processor.clj b/src/metabase/driver/generic_sql/query_processor.clj index e8c254dd6856d5a4a02953c76f42941590c1fee2..cdb3391ed95ef699ea7799509dc72c978f95d4ed 100644 --- a/src/metabase/driver/generic_sql/query_processor.clj +++ b/src/metabase/driver/generic_sql/query_processor.clj @@ -1,6 +1,7 @@ (ns metabase.driver.generic-sql.query-processor "The Query Processor is responsible for translating the Metabase Query Language into HoneySQL SQL forms." (:require [clojure.java.jdbc :as jdbc] + [clojure.string :as str] [clojure.tools.logging :as log] [honeysql [core :as hsql] @@ -17,12 +18,19 @@ [metabase.util.honeysql-extensions :as hx]) (:import clojure.lang.Keyword java.sql.SQLException - [metabase.query_processor.interface AgFieldRef DateTimeField DateTimeValue Expression ExpressionRef Field RelativeDateTimeValue Value])) + [metabase.query_processor.interface AgFieldRef DateTimeField DateTimeValue Expression ExpressionRef Field FieldLiteral RelativeDateTimeValue Value])) (def ^:dynamic *query* "The outer query currently being processed." nil) +(def ^:private ^:dynamic *nested-query-level* + "How many levels deep are we into nested queries? (0 = top level.) + We keep track of this so we know what level to find referenced aggregations + (otherwise something like [:aggregate-field 0] could be ambiguous in a nested query). + Each nested query increments this counter by 1." + 0) + (defn- driver [] {:pre [(map? *query*)]} (:driver *query*)) ;; register the function "distinct-count" with HoneySQL @@ -47,6 +55,17 @@ (or (get-in *query* [:query :expressions (keyword expression-name)]) (:expressions (:query *query*)) (throw (Exception. (format "No expression named '%s'." (name expression-name)))))) +(defn- aggregation-at-index + "Fetch the aggregation at index. This is intended to power aggregate field references (e.g. [:aggregate-field 0]). + This also handles nested queries, which could be potentially ambiguous if multiple levels had aggregations." + ([index] + (aggregation-at-index index (:query *query*) *nested-query-level*)) + ;; keep recursing deeper into the query until we get to the same level the aggregation reference was defined at + ([index query aggregation-level] + (if (zero? aggregation-level) + (nth (:aggregation query) index) + (recur index (:source-query query) (dec aggregation-level))))) + ;; TODO - maybe this fn should be called `->honeysql` instead. (defprotocol ^:private IGenericSQLFormattable (formatted [this] @@ -77,6 +96,10 @@ (isa? special-type :type/UNIXTimestampMilliseconds) (sql/unix-timestamp->timestamp (driver) field :milliseconds) :else field))) + FieldLiteral + (formatted [{:keys [field-name]}] + (keyword (hx/escape-dots (name field-name)))) + DateTimeField (formatted [{unit :unit, field :field}] (sql/date (driver) unit (formatted field))) @@ -84,7 +107,7 @@ ;; e.g. the ["aggregation" 0] fields we allow in order-by AgFieldRef (formatted [{index :index}] - (let [{:keys [aggregation-type]} (nth (:aggregation (:query *query*)) index)] + (let [{:keys [aggregation-type]} (aggregation-at-index index)] ;; For some arcane reason we name the results of a distinct aggregation "count", ;; everything else is named the same as the aggregation (if (= aggregation-type :distinct) @@ -247,12 +270,24 @@ {:pre [table-name]} (h/from honeysql-form (hx/qualify-and-escape-dots schema table-name))) +(declare apply-clauses) + +(defn- apply-source-query [driver honeysql-form {{:keys [native], :as source-query} :source-query}] + ;; TODO - what alias should we give the source query? + (assoc honeysql-form + :from [[(if native + (hsql/raw (str "(" (str/replace native #";+\s*$" "") ")")) ; strip off any trailing slashes + (binding [*nested-query-level* (inc *nested-query-level*)] + (apply-clauses driver {} source-query))) + :source]])) + (def ^:private clause-handlers ;; 1) Use the vars rather than the functions themselves because them implementation ;; will get swapped around and we'll be left with old version of the function that nobody implements ;; 2) This is a vector rather than a map because the order the clauses get handled is important for some drivers. ;; For example, Oracle needs to wrap the entire query in order to apply its version of limit (`WHERE ROWNUM`). [:source-table (u/drop-first-arg apply-source-table) + :source-query apply-source-query :aggregation #'sql/apply-aggregation :breakout #'sql/apply-breakout :fields #'sql/apply-fields @@ -271,7 +306,8 @@ honeysql-form)] (if (seq more) (recur honeysql-form more) - honeysql-form)))) + ;; ok, we're done; if no `:select` clause was specified (for whatever reason) put a default (`SELECT *`) one in + (update honeysql-form :select #(if (seq %) % [:*])))))) (defn build-honeysql-form diff --git a/src/metabase/events/activity_feed.clj b/src/metabase/events/activity_feed.clj index 878a30038731898ea14f5b62647146dbd450986a..f233c5d4d7106459637bb3bda6666d969291fb91 100644 --- a/src/metabase/events/activity_feed.clj +++ b/src/metabase/events/activity_feed.clj @@ -1,12 +1,16 @@ (ns metabase.events.activity-feed (:require [clojure.core.async :as async] [clojure.tools.logging :as log] - [metabase.events :as events] + [metabase + [events :as events] + [query-processor :as qp] + [util :as u]] [metabase.models [activity :as activity :refer [Activity]] [card :refer [Card]] [dashboard :refer [Dashboard]] [table :as table]] + [metabase.query-processor.util :as qputil] [toucan.db :as db])) (def ^:const activity-feed-topics @@ -36,11 +40,20 @@ ;;; ## ---------------------------------------- EVENT PROCESSING ---------------------------------------- +(defn- inner-query->source-table-id + "Recurse through INNER-QUERY source-queries as needed until we can return the ID of this query's source-table." + [inner-query] + (or (when-let [source-table (qputil/get-normalized inner-query :source-table)] + (u/get-id source-table)) + (when-let [source-query (qputil/get-normalized inner-query :source-query)] + (recur source-query)))) (defn- process-card-activity! [topic object] (let [details-fn #(select-keys % [:name :description]) - database-id (get-in object [:dataset_query :database]) - table-id (get-in object [:dataset_query :query :source_table])] + query (u/ignore-exceptions (qp/expand (:dataset_query object))) + database-id (when-let [database (:database query)] + (u/get-id database)) + table-id (inner-query->source-table-id (:query query))] (activity/record-activity! :topic topic :object object diff --git a/src/metabase/integrations/ldap.clj b/src/metabase/integrations/ldap.clj new file mode 100644 index 0000000000000000000000000000000000000000..ec227ba2a142548a41c2aa3d111510d14c9f95bf --- /dev/null +++ b/src/metabase/integrations/ldap.clj @@ -0,0 +1,227 @@ +(ns metabase.integrations.ldap + (:require [clj-ldap.client :as ldap] + [clojure + [set :as set] + [string :as str]] + [metabase.models + [permissions-group :as group :refer [PermissionsGroup]] + [setting :as setting :refer [defsetting]] + [user :as user :refer [User]]] + [metabase.util :as u] + [toucan.db :as db]) + (:import [com.unboundid.ldap.sdk LDAPConnectionPool LDAPException])) + +(def ^:private filter-placeholder + "{login}") + +(defsetting ldap-enabled + "Enable LDAP authentication." + :type :boolean + :default false) + +(defsetting ldap-host + "Server hostname.") + +(defsetting ldap-port + "Server port, usually 389 or 636 if SSL is used." + :default "389") + +(defsetting ldap-security + "Use SSL, TLS or plain text." + :default "none" + :setter (fn [new-value] + (when-not (nil? new-value) + (assert (contains? #{"none" "ssl" "starttls"} new-value))) + (setting/set-string! :ldap-security new-value))) + +(defsetting ldap-bind-dn + "The Distinguished Name to bind as, this user will be used to lookup information about other users.") + +(defsetting ldap-password + "The password to bind with for the lookup user.") + +(defsetting ldap-user-base + "Search base for users. (Will be searched recursively)") + +(defsetting ldap-user-filter + "User lookup filter, the placeholder {login} will be replaced by the user supplied login." + :default "(&(objectClass=inetOrgPerson)(|(uid={login})(mail={login})))") + +(defsetting ldap-attribute-email + "Attribute to use for the user's email. (usually 'mail', 'email' or 'userPrincipalName')" + :default "mail") + +(defsetting ldap-attribute-firstname + "Attribute to use for the user's first name. (usually 'givenName')" + :default "givenName") + +(defsetting ldap-attribute-lastname + "Attribute to use for the user's last name. (usually 'sn')" + :default "sn") + +(defsetting ldap-group-sync + "Enable group membership synchronization with LDAP." + :type :boolean + :default false) + +(defsetting ldap-group-base + "Search base for groups, not required if your LDAP directory provides a 'memberOf' overlay. (Will be searched recursively)") + +(defsetting ldap-group-mappings + ;; Should be in the form: {"cn=Some Group,dc=...": [1, 2, 3]} where keys are LDAP groups and values are lists of MB groups IDs + "JSON containing LDAP to Metabase group mappings." + :type :json + :default {}) + +(defn ldap-configured? + "Check if LDAP is enabled and that the mandatory settings are configured." + [] + (boolean (and (ldap-enabled) + (ldap-host) + (ldap-bind-dn) + (ldap-password) + (ldap-user-base)))) + +(defn- details->ldap-options [{:keys [host port bind-dn password security]}] + {:host (str host ":" port) + :bind-dn bind-dn + :password password + :ssl? (= security "ssl") + :startTLS? (= security "starttls")}) + +(defn- settings->ldap-options [] + (details->ldap-options {:host (ldap-host) + :port (ldap-port) + :bind-dn (ldap-bind-dn) + :password (ldap-password) + :security (ldap-security)})) + +(defn- escape-value + "Escapes a value for use in an LDAP filter expression." + [value] + (str/replace value #"[\*\(\)\\\\0]" (comp (partial format "\\%02X") int first))) + +(defn- get-connection + "Connects to LDAP with the currently set settings and returns the connection." + ^LDAPConnectionPool [] + (ldap/connect (settings->ldap-options))) + +(defn- with-connection + "Applies `f` with a connection and `args`" + [f & args] + (with-open [conn (get-connection)] + (apply f conn args))) + +(defn- ldap-groups->mb-group-ids + "Will translate a set of DNs to a set of MB group IDs using the configured mappings." + [ldap-groups] + (-> (ldap-group-mappings) + (select-keys (map keyword ldap-groups)) + vals + flatten + set)) + +(defn- get-user-groups + "Retrieve groups for a supplied DN." + ([dn] + (with-connection get-user-groups dn)) + ([conn dn] + (when (ldap-group-base) + (let [results (ldap/search conn (ldap-group-base) {:scope :sub + :filter (str "member=" (escape-value dn)) + :attributes [:dn :distinguishedName]})] + (filter some? + (for [result results] + (or (:dn result) (:distinguishedName result)))))))) + +(def ^:private user-base-error {:status :ERROR, :message "User search base does not exist or is unreadable"}) +(def ^:private group-base-error {:status :ERROR, :message "Group search base does not exist or is unreadable"}) + +(defn test-ldap-connection + "Test the connection to an LDAP server to determine if we can find the search base. + + Takes in a dictionary of properties such as: + {:host \"localhost\" + :port 389 + :bind-dn \"cn=Directory Manager\" + :password \"password\" + :security \"none\" + :user-base \"ou=Birds,dc=metabase,dc=com\" + :group-base \"ou=Groups,dc=metabase,dc=com\"}" + [{:keys [user-base group-base], :as details}] + (try + (with-open [^LDAPConnectionPool conn (ldap/connect (details->ldap-options details))] + (or + (try + (when-not (ldap/get conn user-base) + user-base-error) + (catch Exception e + user-base-error)) + (when group-base + (try + (when-not (ldap/get conn group-base) + group-base-error) + (catch Exception e + group-base-error))) + {:status :SUCCESS})) + (catch LDAPException e + {:status :ERROR, :message (.getMessage e), :code (.getResultCode e)}) + (catch Exception e + {:status :ERROR, :message (.getMessage e)}))) + +(defn find-user + "Gets user information for the supplied username." + ([username] + (with-connection find-user username)) + ([conn username] + (let [fname-attr (keyword (ldap-attribute-firstname)) + lname-attr (keyword (ldap-attribute-lastname)) + email-attr (keyword (ldap-attribute-email))] + (when-let [[result] (ldap/search conn (ldap-user-base) {:scope :sub + :filter (str/replace (ldap-user-filter) filter-placeholder (escape-value username)) + :attributes [:dn :distinguishedName fname-attr lname-attr email-attr :memberOf] + :size-limit 1})] + (let [dn (or (:dn result) (:distinguishedName result)) + fname (get result fname-attr) + lname (get result lname-attr) + email (get result email-attr)] + ;; Make sure we got everything as these are all required for new accounts + (when-not (or (empty? dn) (empty? fname) (empty? lname) (empty? email)) + ;; ActiveDirectory (and others?) will supply a `memberOf` overlay attribute for groups + ;; Otherwise we have to make the inverse query to get them + (let [groups (when (ldap-group-sync) + (or (:memberOf result) (get-user-groups dn) []))] + {:dn dn + :first-name fname + :last-name lname + :email email + :groups groups}))))))) + +(defn verify-password + "Verifies if the supplied password is valid for the `user-info` (from `find-user`) or DN." + ([user-info password] + (with-connection verify-password user-info password)) + ([conn user-info password] + (if (string? user-info) + (ldap/bind? conn user-info password) + (ldap/bind? conn (:dn user-info) password)))) + +(defn fetch-or-create-user! + "Using the `user-info` (from `find-user`) get the corresponding Metabase user, creating it if necessary." + [{:keys [first-name last-name email groups]} password] + (let [user (or (db/select-one [User :id :last_login] :email email) + (user/create-new-ldap-auth-user! first-name last-name email password))] + (u/prog1 user + (when password + (user/set-password! (:id user) password)) + (when (ldap-group-sync) + (let [special-ids #{(:id (group/admin)) (:id (group/all-users))} + current-ids (set (map :group_id (db/select ['PermissionsGroupMembership :group_id] :user_id (:id user)))) + ldap-ids (when-let [ids (seq (ldap-groups->mb-group-ids groups))] + (set (map :id (db/select [PermissionsGroup :id] :id [:in ids])))) + to-remove (set/difference current-ids ldap-ids special-ids) + to-add (set/difference ldap-ids current-ids)] + (when (seq to-remove) + (db/delete! 'PermissionsGroupMembership :group_id [:in to-remove], :user_id (:id user))) + (doseq [id to-add] + (db/insert! 'PermissionsGroupMembership :group_id id, :user_id (:id user)))))))) diff --git a/src/metabase/middleware.clj b/src/metabase/middleware.clj index 9ca52e30797b1ab6cade62ca6eda95ef22a6581a..5511be6662c28fbb09f209513c9ec947c27ce773 100644 --- a/src/metabase/middleware.clj +++ b/src/metabase/middleware.clj @@ -123,7 +123,7 @@ response-unauthentic))) (def ^:private current-user-fields - (vec (concat [User :is_active :google_auth] (models/default-fields User)))) + (vec (concat [User :is_active :google_auth :ldap_auth] (models/default-fields User)))) (defn bind-current-user "Middleware that binds `metabase.api.common/*current-user*`, `*current-user-id*`, `*is-superuser?*`, and `*current-user-permissions-set*`. diff --git a/src/metabase/models/card.clj b/src/metabase/models/card.clj index 7e2e1de81a47bf7bd42c93d5ed6fcf1ce9abd60d..f276c68303fbca7250951ece0872c0f2f701b394 100644 --- a/src/metabase/models/card.clj +++ b/src/metabase/models/card.clj @@ -53,10 +53,12 @@ (defn- query->source-and-join-tables "Return a sequence of all Tables (as TableInstance maps) referenced by QUERY." - [{:keys [source-table join-tables native], :as query}] + [{:keys [source-table join-tables source-query native], :as query}] (cond ;; if we come across a native query just put a placeholder (`::native`) there so we know we need to add native permissions to the complete set below. native [::native] + ;; if we have a source-query just recur until we hit either the native source or the MBQL source + source-query (recur source-query) ;; for root MBQL queries just return source-table + join-tables :else (cons source-table join-tables))) @@ -86,6 +88,7 @@ ;; it takes a lot of DB calls and function calls to expand/resolve a query, and since they're pure functions we can save ourselves some a lot of DB calls ;; by caching the results. Cache the permissions reqquired to run a given query dictionary for up to 6 hours +;; TODO - what if the query uses a source query, and that query changes? Not sure if that will cause an issue or not. May need to revisit this (defn- query-perms-set* [{query-type :type, database :database, :as query} read-or-write] (cond (= query {}) #{} @@ -135,12 +138,15 @@ ;;; ------------------------------------------------------------ Lifecycle ------------------------------------------------------------ (defn- query->database-and-table-ids - "Return a map with `:database-id` and source `:table-id` that should be saved for a Card." + "Return a map with `:database-id` and source `:table-id` that should be saved for a Card. Handles queries that use other queries as their source + (ones that come in with a `:source-table` like `card__100`) recursively, as well as normal queries." [outer-query] - (let [database (qputil/get-normalized outer-query :database) + (let [database-id (qputil/get-normalized outer-query :database) source-table (qputil/get-in-normalized outer-query [:query :source-table])] - (when source-table - {:database-id (u/get-id database), :table-id (u/get-id source-table)}))) + (cond + (integer? source-table) {:database-id database-id, :table-id source-table} + (string? source-table) (let [[_ card-id] (re-find #"^card__(\d+)$" source-table)] + (db/select-one [Card [:table_id :table-id] [:database_id :database-id]] :id (Integer/parseInt card-id)))))) (defn- populate-query-fields [{{query-type :type, :as outer-query} :dataset_query, :as card}] (merge (when query-type @@ -184,6 +190,7 @@ :display :keyword :embedding_params :json :query_type :keyword + :result_metadata :json :visualization_settings :json}) :properties (constantly {:timestamped? true}) :pre-update (comp populate-query-fields pre-update) diff --git a/src/metabase/models/database.clj b/src/metabase/models/database.clj index f84d61e3a169d6ebfdd73290ecd40117a694b5db..9964675ef9b8a84418783e475cbe463aec055f63 100644 --- a/src/metabase/models/database.clj +++ b/src/metabase/models/database.clj @@ -12,6 +12,23 @@ [db :as db] [models :as models]])) + +;;; ------------------------------------------------------------ Constants ------------------------------------------------------------ + +(def ^:const ^Integer virtual-id + "The ID used to signify that a database is 'virtual' rather than physical. + + A fake integer ID is used so as to minimize the number of changes that need to be made on the frontend -- by using something that would otherwise + be a legal ID, *nothing* need change there, and the frontend can query against this 'database' none the wiser. (This integer ID is negative + which means it will never conflict with a *real* database ID.) + + This ID acts as a sort of flag. The relevant places in the middleware can check whether the DB we're querying is this 'virtual' database and + take the appropriate actions." + -1337) +;; To the reader: yes, this seems sort of hacky, but one of the goals of the Nested Query Initiative™ was to minimize if not completely eliminate +;; any changes to the frontend. After experimenting with several possible ways to do this this implementation seemed simplest and best met the goal. +;; Luckily this is the only place this "magic number" is defined and the entire frontend can remain blissfully unaware of its value. + ;;; ------------------------------------------------------------ Entity & Lifecycle ------------------------------------------------------------ (models/defmodel Database :metabase_database) diff --git a/src/metabase/models/permissions_group.clj b/src/metabase/models/permissions_group.clj index b3e04e75d68eb6f46c77ad0c5d3cb370182ebf11..a146b5a95596b370c8f42447ca4d4cc6a531e2b2 100644 --- a/src/metabase/models/permissions_group.clj +++ b/src/metabase/models/permissions_group.clj @@ -1,6 +1,7 @@ (ns metabase.models.permissions-group (:require [clojure.string :as s] [clojure.tools.logging :as log] + [metabase.models.setting :as setting] [metabase.util :as u] [toucan [db :as db] @@ -69,8 +70,14 @@ (defn- pre-delete [{id :id, :as group}] (check-not-magic-group group) - (db/delete! 'Permissions :group_id id) - (db/delete! 'PermissionsGroupMembership :group_id id)) + (db/delete! 'Permissions :group_id id) + (db/delete! 'PermissionsGroupMembership :group_id id) + ;; Remove from LDAP mappings + (setting/set-json! :ldap-group-mappings + (when-let [mappings (setting/get-json :ldap-group-mappings)] + (zipmap (keys mappings) + (for [val (vals mappings)] + (remove (partial = id) val)))))) (defn- pre-update [{group-name :name, :as group}] (u/prog1 group diff --git a/src/metabase/models/user.clj b/src/metabase/models/user.clj index 6e147cda3c8ff6fdf27e327e2ffe2afe81c3ac74..488a39e7a29a85a2361c3ba499a510b8ad139b43 100644 --- a/src/metabase/models/user.clj +++ b/src/metabase/models/user.clj @@ -143,7 +143,15 @@ ;; send an email to everyone including the site admin if that's set (email/send-user-joined-admin-notification-email! <>, :google-auth? true))) - +(defn create-new-ldap-auth-user! + "Convenience for creating a new user via LDAP. This account is considered active immediately; thus all active admins will recieve an email right away." + [first-name last-name email-address password] + {:pre [(string? first-name) (string? last-name) (u/is-email? email-address)]} + (db/insert! User :email email-address + :first_name first-name + :last_name last-name + :password password + :ldap_auth true)) (defn set-password! "Updates the stored password for a specified `User` by hashing the password with a random salt." diff --git a/src/metabase/public_settings.clj b/src/metabase/public_settings.clj index e12e00b76333902185c79931085edb385a5f3290..2c744a6f7b2170c62586f7c95aefb86ffb6175c1 100644 --- a/src/metabase/public_settings.clj +++ b/src/metabase/public_settings.clj @@ -121,6 +121,7 @@ :engines ((resolve 'metabase.driver/available-drivers)) :ga_code "UA-60817802-1" :google_auth_client_id (setting/get :google-auth-client-id) + :ldap_configured ((resolve 'metabase.integrations.ldap/ldap-configured?)) :has_sample_dataset (db/exists? 'Database, :is_sample true) :map_tile_server_url (map-tile-server-url) :password_complexity password/active-password-complexity diff --git a/src/metabase/query_processor.clj b/src/metabase/query_processor.clj index 9f486d28aa308146171f94da7bfff64f7fdd823f..708986eb302154d7ac56f3dfe0ddcb67ae477e2c 100644 --- a/src/metabase/query_processor.clj +++ b/src/metabase/query_processor.clj @@ -19,12 +19,14 @@ [driver-specific :as driver-specific] [expand-macros :as expand-macros] [expand-resolve :as expand-resolve] + [fetch-source-query :as fetch-source-query] [format-rows :as format-rows] [limit :as limit] [log :as log-query] [mbql-to-native :as mbql-to-native] [parameters :as parameters] [permissions :as perms] + [results-metadata :as results-metadata] [resolve-driver :as resolve-driver]] [metabase.query-processor.util :as qputil] [metabase.util.schema :as su] @@ -85,6 +87,7 @@ cumulative-ags/handle-cumulative-aggregations implicit-clauses/add-implicit-clauses format-rows/format-rows + results-metadata/record-and-return-metadata! expand-resolve/expand-resolve ; ▲▲▲ QUERY EXPANSION POINT ▲▲▲ All functions *above* will see EXPANDED query during PRE-PROCESSING row-count-and-status/add-row-count-and-status ; ▼▼▼ RESULTS WRAPPING POINT ▼▼▼ All functions *below* will see results WRAPPED in `:data` during POST-PROCESSING parameters/substitute-parameters @@ -92,8 +95,10 @@ driver-specific/process-query-in-context ; (drivers can inject custom middleware if they implement IDriver's `process-query-in-context`) add-settings/add-settings resolve-driver/resolve-driver ; ▲▲▲ DRIVER RESOLUTION POINT ▲▲▲ All functions *above* will have access to the driver during PRE- *and* POST-PROCESSING + fetch-source-query/fetch-source-query log-query/log-initial-query cache/maybe-return-cached-results + log-query/log-results-metadata catch-exceptions/catch-exceptions)) ;; ▲▲▲ PRE-PROCESSING ▲▲▲ happens from BOTTOM-TO-TOP, e.g. the results of `expand-macros` are (eventually) passed to `expand-resolve` @@ -101,8 +106,10 @@ "Return the native form for QUERY (e.g. for a MBQL query on Postgres this would return a map containing the compiled SQL form)." {:style/indent 0} [query] - (-> ((qp-pipeline identity) query) - (get-in [:data :native_form]))) + (let [results ((qp-pipeline identity) query)] + (or (get-in results [:data :native_form]) + (throw (ex-info "No native form returned." + results))))) (defn process-query "A pipeline of various QP functions (including middleware) that are used to process MB queries." @@ -116,7 +123,8 @@ (->> identity expand-resolve/expand-resolve parameters/substitute-parameters - expand-macros/expand-macros)) + expand-macros/expand-macros + fetch-source-query/fetch-source-query)) ;; ▲▲▲ This only does PRE-PROCESSING, so it happens from bottom to top, eventually returning the preprocessed query instead of running it @@ -230,7 +238,8 @@ (s/optional-key :executed-by) (s/maybe su/IntGreaterThanZero) (s/optional-key :card-id) (s/maybe su/IntGreaterThanZero) (s/optional-key :dashboard-id) (s/maybe su/IntGreaterThanZero) - (s/optional-key :pulse-id) (s/maybe su/IntGreaterThanZero)} + (s/optional-key :pulse-id) (s/maybe su/IntGreaterThanZero) + (s/optional-key :nested?) (s/maybe s/Bool)} (fn [{:keys [executed-by]}] (or (integer? executed-by) *allow-queries-with-no-executor-id*)) diff --git a/src/metabase/query_processor/annotate.clj b/src/metabase/query_processor/annotate.clj index c51c24072db3e8c1d805b68dc14f1fcc0fe067a5..4b5436ed249fa63820fd9e9662ff1575e5f99fb4 100644 --- a/src/metabase/query_processor/annotate.clj +++ b/src/metabase/query_processor/annotate.clj @@ -21,6 +21,13 @@ ;;; ## Field Resolution +(defn- valid-collected-field? [keep-date-time-fields? f] + (or (instance? metabase.query_processor.interface.Field f) + (instance? metabase.query_processor.interface.FieldLiteral f) + (instance? metabase.query_processor.interface.ExpressionRef f) + (when keep-date-time-fields? + (instance? metabase.query_processor.interface.DateTimeField f)))) + (defn collect-fields "Return a sequence of all the `Fields` inside THIS, recursing as needed for collections. For maps, add or `conj` to property `:path`, recording the keypath used to reach each `Field.` @@ -28,12 +35,9 @@ (collect-fields {:name \"id\", ...}) -> [{:name \"id\", ...}] (collect-fields [{:name \"id\", ...}]) -> [{:name \"id\", ...}] (collect-fields {:a {:name \"id\", ...}) -> [{:name \"id\", :path [:a], ...}]" + {:style/indent 0} [this & [keep-date-time-fields?]] - {:post [(every? (fn [f] - (or (instance? metabase.query_processor.interface.Field f) - (instance? metabase.query_processor.interface.ExpressionRef f) - (when keep-date-time-fields? - (instance? metabase.query_processor.interface.DateTimeField f)))) %)]} + {:post [(every? (partial valid-collected-field? keep-date-time-fields?) %)]} (condp instance? this ;; For a DateTimeField we'll flatten it back into regular Field but include the :unit info for the frontend. ;; Recurse so it is otherwise handled normally @@ -50,14 +54,37 @@ [this parent] [this]) + metabase.query_processor.interface.FieldLiteral + [(assoc this + :field-id [:field-literal (:field-name this) (:base-type this)] + :field-display-name (humanization/name->human-readable-name (:field-name this)))] + metabase.query_processor.interface.ExpressionRef [(assoc this :field-display-name (:expression-name this))] + ;; for every value in a map in the query we'll descend into the map and find all the fields contained therein and mark the key as each field's source. + ;; e.g. if we descend into the `:breakout` columns for a query each field returned will get a `:source` of `:breakout` + ;; The source is important since it is used to determine sort order for for columns clojure.lang.IPersistentMap (for [[k v] (seq this) field (collect-fields v keep-date-time-fields?) :when field] - (assoc field :source k)) + (if (= k :source-query) + ;; For columns collected from a source query... + ;; 1) Make sure they didn't accidentally pick up an integer ID if the fields clause was added implicitly. If it does + ;; the frontend won't know how to use the field since it won't match up with the same field in the "virtual" table metadata. + ;; 2) Keep the original `:source` rather than replacing it with `:source-query` since the frontend doesn't know what to do with that. + (if (= (:unit field) :year) + ;; if the field is broken out by year we don't want to advertise it as type/DateTime because you can't do a datetime breakout on the years that come back + ;; (they come back as text). So instead just tell people it's a Text column + (assoc field + :field-id [:field-literal (:field-name field) :type/Text] + :base-type :type/Text + :unit nil) + (assoc field + :field-id [:field-literal (:field-name field) (:base-type field)])) + ;; For all other fields just add `:source` as described above + (assoc field :source k))) clojure.lang.Sequential (for [[i field] (m/indexed (mapcat (u/rpartial collect-fields keep-date-time-fields?) this))] @@ -149,6 +176,18 @@ :field-name k :field-display-name (humanization/name->human-readable-name (name k))}) +;; TODO - I'm not 100% sure the code reaches this point any more since the `collect-fields` logic now handles nested queries +;; maybe this is used for queries where the source query is native? +(defn- info-for-column-from-source-query + "Return information about a column that comes back when we're using a source query. + (This is basically the same as the generic information, but we also add `:id` and `:source` + columns so drill-through operations can be done on it)." + [k & [initial-values]] + (let [col (generic-info-for-missing-key k initial-values)] + (assoc col + :id [:field-literal k (:base-type col)] + :source :fields))) + (defn- info-for-duplicate-field "The Clojure JDBC driver automatically appends suffixes like `count_2` to duplicate columns if multiple columns come back with the same name; @@ -163,25 +202,27 @@ fields))) (defn- info-for-missing-key - "Metadata for a field named K, which we weren't able to resolve normally. - If possible, we work around This defaults to generic information " - [fields k initial-values] - (or (info-for-duplicate-field fields k) + "Metadata for a field named K, which we weren't able to resolve normally." + [inner-query fields k initial-values] + (or (when (:source-query inner-query) + (info-for-column-from-source-query k initial-values)) + (info-for-duplicate-field fields k) (generic-info-for-missing-key k initial-values))) (defn- add-unknown-fields-if-needed "When create info maps for any fields we didn't expect to come back from the query. Ideally, this should never happen, but on the off chance it does we still want to return it in the results." - [actual-keys initial-rows fields] - {:pre [(set? actual-keys) (every? keyword? actual-keys)]} + [inner-query actual-keys initial-rows fields] + {:pre [(sequential? actual-keys) (every? keyword? actual-keys)]} (let [expected-keys (u/prog1 (set (map :field-name fields)) (assert (every? keyword? <>))) - missing-keys (set/difference actual-keys expected-keys)] + missing-keys (set/difference (set actual-keys) expected-keys)] (when (seq missing-keys) - (log/warn (u/format-color 'yellow "There are fields we weren't expecting in the results: %s\nExpected: %s\nActual: %s" - missing-keys expected-keys actual-keys))) - (concat fields (for [k missing-keys] - (info-for-missing-key fields k (map k initial-rows)))))) + (log/warn (u/format-color 'yellow "There are fields we (maybe) weren't expecting in the results: %s\nExpected: %s\nActual: %s" + missing-keys expected-keys (set actual-keys)))) + (concat fields (for [k actual-keys + :when (contains? missing-keys k)] + (info-for-missing-key inner-query fields k (map k initial-rows)))))) (defn- convert-field-to-expected-format "Rename keys, provide default values, etc. for FIELD so it is in the format expected by the frontend." @@ -192,7 +233,7 @@ :id nil :table_id nil}] (-> (merge defaults field) - (update :field-display-name name) + (update :field-display-name #(when % (name %))) (set/rename-keys {:base-type :base_type :field-display-name :display_name :field-id :id @@ -209,7 +250,8 @@ "Fetch fk info and return a function that returns the destination Field of a given Field." ([fields] (or (fk-field->dest-fn fields (for [{:keys [special_type id]} fields - :when (isa? special_type :type/FK)] + :when (and (isa? special_type :type/FK) + (integer? id))] id)) (constantly nil))) ;; Fetch the foreign key fields whose origin is in the returned Fields, create a map of origin-field-id->destination-field-id @@ -242,20 +284,29 @@ {})))))) (defn- resolve-sort-and-format-columns - "Collect the Fields referenced in QUERY, sort them according to the rules at the top + "Collect the Fields referenced in INNER-QUERY, sort them according to the rules at the top of this page, format them as expected by the frontend, and return the results." - [query result-keys initial-rows] - {:pre [(set? result-keys)]} + [inner-query result-keys initial-rows] + {:pre [(sequential? result-keys)]} (when (seq result-keys) - (->> (collect-fields (dissoc query :expressions)) + (->> (collect-fields (dissoc inner-query :expressions)) + ;; qualify the field name to make sure it matches what will come back. (For Mongo nested queries only) (map qualify-field-name) - (add-aggregate-fields-if-needed query) + ;; add entries for aggregate fields + (add-aggregate-fields-if-needed inner-query) + ;; make field-name a keyword (map (u/rpartial update :field-name keyword)) - (add-unknown-fields-if-needed result-keys initial-rows) - (sort/sort-fields query) + ;; add entries for fields we weren't expecting + (add-unknown-fields-if-needed inner-query result-keys initial-rows) + ;; remove expected fields not present in the results, and make sure they're unique + (filter (comp (partial contains? (set result-keys)) :field-name)) + ;; now sort the fields + (sort/sort-fields inner-query) + ;; remove any duplicate entires + (m/distinct-by :field-name) + ;; convert them to the format expected by the frontend (map convert-field-to-expected-format) - (filter (comp (partial contains? result-keys) :name)) - (m/distinct-by :name) + ;; add FK info add-extra-info-to-fk-fields))) (defn annotate-and-sort @@ -267,7 +318,7 @@ [query {:keys [columns rows], :as results}] (let [row-maps (for [row rows] (zipmap columns row)) - cols (resolve-sort-and-format-columns (:query query) (set columns) (take 10 row-maps)) + cols (resolve-sort-and-format-columns (:query query) (distinct columns) (take 10 row-maps)) columns (mapv :name cols)] (assoc results :cols (vec (for [col cols] diff --git a/src/metabase/query_processor/expand.clj b/src/metabase/query_processor/expand.clj index 97d22d5bc9a0a8d372d610c86f29b8393b6a6a32..e08ba5c528299c8216e42fad469bd17d479ff58c 100644 --- a/src/metabase/query_processor/expand.clj +++ b/src/metabase/query_processor/expand.clj @@ -10,8 +10,8 @@ [metabase.util :as u] [metabase.util.schema :as su] [schema.core :as s]) - (:import [metabase.query_processor.interface AgFieldRef BetweenFilter ComparisonFilter CompoundFilter Expression ExpressionRef - FieldPlaceholder RelativeDatetime StringFilter Value ValuePlaceholder])) + (:import [metabase.query_processor.interface AgFieldRef BetweenFilter ComparisonFilter CompoundFilter DateTimeValue DateTimeField Expression + ExpressionRef FieldLiteral FieldPlaceholder RelativeDatetime RelativeDateTimeValue StringFilter Value ValuePlaceholder])) ;;; # ------------------------------------------------------------ Clause Handlers ------------------------------------------------------------ @@ -24,10 +24,17 @@ [index :- s/Int] (i/map->AgFieldRef {:index index})) -(s/defn ^:ql ^:always-validate field-id :- FieldPlaceholder +(s/defn ^:ql ^:always-validate field-id :- i/AnyField "Create a generic reference to a `Field` with ID." - [id :- su/IntGreaterThanZero] - (i/map->FieldPlaceholder {:field-id id})) + [id] + ;; If for some reason we were passed a field literal (e.g. [field-id [field-literal ...]]) + ;; we should technically barf but since we know what people meant we'll be nice for once and fix it for them :D + (if (instance? FieldLiteral id) + (do + (log/warn (u/format-color 'yellow (str "It doesn't make sense to use `field-literal` forms inside `field-id` forms.\n" + "Instead of [field-id [field-literal ...]], just do [field-literal ...]."))) + id) + (i/map->FieldPlaceholder {:field-id id}))) (s/defn ^:private ^:always-validate field :- i/AnyField "Generic reference to a `Field`. F can be an integer Field ID, or various other forms like `fk->` or `aggregation`." @@ -37,6 +44,12 @@ (field-id f)) f)) +(s/defn ^:ql ^:always-validate field-literal :- FieldLiteral + "Generic reference to a Field by FIELD-NAME. This is intended for use when using nested queries so as to allow one to refer to the fields coming back from + the source query." + [field-name :- su/KeywordOrString, field-type :- su/KeywordOrString] + (i/map->FieldLiteral {:field-name (u/keyword->qualified-name field-name), :base-type (keyword field-type)})) + (s/defn ^:ql ^:always-validate named :- i/Aggregation "Specify a CUSTOM-NAME to use for a top-level AGGREGATION-OR-EXPRESSION in the results. (This will probably be extended to support Fields in the future, but for now, only the `:aggregation` clause is supported.)" @@ -44,12 +57,19 @@ [aggregation-or-expression :- i/Aggregation, custom-name :- su/NonBlankString] (assoc aggregation-or-expression :custom-name custom-name)) -(s/defn ^:ql ^:always-validate datetime-field :- FieldPlaceholder +(s/defn ^:ql ^:always-validate datetime-field :- i/AnyField "Reference to a `DateTimeField`. This is just a `Field` reference with an associated datetime UNIT." - ([f _ unit] (log/warn (u/format-color 'yellow (str "The syntax for datetime-field has changed in MBQL '98. [:datetime-field <field> :as <unit>] is deprecated. " - "Prefer [:datetime-field <field> <unit>] instead."))) - (datetime-field f unit)) - ([f unit] (assoc (field f) :datetime-unit (qputil/normalize-token unit)))) + ([f _ unit] + (log/warn (u/format-color 'yellow (str "The syntax for datetime-field has changed in MBQL '98. [:datetime-field <field> :as <unit>] is deprecated. " + "Prefer [:datetime-field <field> <unit>] instead."))) + (datetime-field f unit)) + ([f unit] + (cond + (instance? DateTimeField f) f + (instance? FieldLiteral f) (i/map->DateTimeField {:field f, :unit (qputil/normalize-token unit)}) + ;; if it already has a datetime unit don't replace it with a new one (?) + ;; (:datetime-unit f) f + :else (assoc (field f) :datetime-unit (qputil/normalize-token unit))))) (s/defn ^:ql ^:always-validate fk-> :- FieldPlaceholder "Reference to a `Field` that belongs to another `Table`. DEST-FIELD-ID is the ID of this Field, and FK-FIELD-ID is the ID of the foreign key field @@ -62,18 +82,34 @@ (i/assert-driver-supports :foreign-keys) (i/map->FieldPlaceholder {:fk-field-id fk-field-id, :field-id dest-field-id})) +(defn- datetime-unit + "Determine the appropriate datetime unit that should be used for a field F and a value V. + (Sometimes the value may already have a 'default' value that should be replaced with the + value from the field it is being used with, e.g. in a filter clause. + For example when filtering by minute it is important both F and V are bucketed as minutes, + and thus both most have the same unit." + [f v] + (qputil/normalize-token (core/or (:datetime-unit f) + (:unit f) + (:unit v)))) -(s/defn ^:private ^:always-validate value :- (s/cond-pre Value ValuePlaceholder) +(s/defn ^:private ^:always-validate value :- i/AnyValue "Literal value. F is the `Field` it relates to, and V is `nil`, or a boolean, string, numerical, or datetime value." [f v] (cond - (instance? ValuePlaceholder v) v - (instance? Value v) v - :else (i/map->ValuePlaceholder {:field-placeholder (field f), :value v}))) + (instance? ValuePlaceholder v) v + (instance? Value v) v + (instance? RelativeDateTimeValue v) v + (instance? DateTimeValue v) v + (instance? RelativeDatetime v) (i/map->RelativeDateTimeValue (assoc v :unit (datetime-unit f v), :field (datetime-field f (datetime-unit f v)))) + (instance? DateTimeField f) (i/map->DateTimeValue {:value (u/->Timestamp v), :field f}) + (instance? FieldLiteral f) (i/map->Value {:value v, :field f}) + :else (i/map->ValuePlaceholder {:field-placeholder (field f), :value v}))) (s/defn ^:private ^:always-validate field-or-value "Use instead of `value` when something may be either a field or a value." [f v] + (if (core/or (instance? FieldPlaceholder v) (instance? ExpressionRef v)) v @@ -372,12 +408,26 @@ ;;; ## source-table (s/defn ^:ql ^:always-validate source-table - "Specify the ID of the table to query (required). + "Specify the ID of the table to query. + Queries must specify *either* `:source-table` or `:source-query`. (source-table {} 100)" [query, table-id :- s/Int] (assoc query :source-table table-id)) +(declare expand-inner) + +(s/defn ^:ql ^:always-validate source-query + "Specify a query to use as the source for this query (e.g., as a `SUBSELECT`). + Queries must specify *either* `:source-table` or `:source-query`. + + (source-query {} (-> (source-table {} 100) + (limit 10)))" + {:added "0.25.0"} + [query, source-query :- su/Map] + (assoc query :source-query (if (:native source-query) + source-query + (expand-inner source-query)))) ;;; ## calculated columns diff --git a/src/metabase/query_processor/interface.clj b/src/metabase/query_processor/interface.clj index c2aa5bee4fcdd20904c9fcf9a6d568332c1fe420..fd174c75fba45cd08cee0a3928d3e33393afec49 100644 --- a/src/metabase/query_processor/interface.clj +++ b/src/metabase/query_processor/interface.clj @@ -75,8 +75,10 @@ (defprotocol IField "Methods specific to the Query Expander `Field` record type." (qualified-name-components [this] - "Return a vector of name components of the form `[table-name parent-names... field-name]`")) - + "Return a vector of name components of the form `[table-name parent-names... field-name]` + (This should always return AT LEAST 2 components. If no table name should be used, return + `nil` as the first part.)")) +;; TODO - Yes, I know, that makes no sense. `annotate/qualify-field-name` expects it that way tho ;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+ ;;; | FIELDS | @@ -102,7 +104,7 @@ (getName [_] field-name) ; (name <field>) returns the *unqualified* name of the field, #obvi IField - (qualified-name-components [this] + (qualified-name-components [_] (conj (if parent (qualified-name-components parent) [table-name]) @@ -132,9 +134,17 @@ [unit] (contains? relative-datetime-value-units (keyword unit))) +;; TODO - maybe we should figure out some way to have the schema validate that the driver supports field literals, like we do for some of the other clauses. +;; Ideally we'd do that in a more generic way (perhaps in expand, we could make the clauses specify required feature metadata and have that get checked automatically?) +(s/defrecord FieldLiteral [field-name :- su/NonBlankString + base-type :- su/FieldType] + clojure.lang.Named + (getName [_] field-name) + IField + (qualified-name-components [_] [nil field-name])) ;; DateTimeField is just a simple wrapper around Field -(s/defrecord DateTimeField [field :- Field +(s/defrecord DateTimeField [field :- (s/cond-pre Field FieldLiteral) unit :- DatetimeFieldUnit] clojure.lang.Named (getName [_] (name field))) @@ -154,19 +164,11 @@ fk-field-id :- (s/maybe (s/constrained su/IntGreaterThanZero (fn [_] (or (assert-driver-supports :foreign-keys) true)) ; assert-driver-supports will throw Exception if driver is bound "foreign-keys is not supported by this driver.")) ; and driver does not support foreign keys - datetime-unit :- (s/maybe (apply s/enum datetime-field-units))]) + datetime-unit :- (s/maybe DatetimeFieldUnit)]) (s/defrecord AgFieldRef [index :- s/Int]) ;; TODO - add a method to get matching expression from the query? - - - -(def FieldPlaceholderOrExpressionRef - "Schema for either a `FieldPlaceholder` or `ExpressionRef`." - (s/named (s/cond-pre FieldPlaceholder ExpressionRef) - "Valid field or expression reference.")) - (s/defrecord RelativeDatetime [amount :- s/Int unit :- DatetimeValueUnit]) @@ -182,9 +184,11 @@ (def AnyField - "Schema for a anything that is considered a valid 'field'." + "Schema for anything that is considered a valid 'field' including placeholders, expressions, and literals." (s/named (s/cond-pre Field FieldPlaceholder + DateTimeField + FieldLiteral AgFieldRef Expression ExpressionRef) @@ -207,13 +211,13 @@ "Schema for an MBQL datetime value: an ISO-8601 string, `java.sql.Date`, or a relative dateitme form." (s/named (s/cond-pre RelativeDatetime LiteralDatetime) "Valid datetime (must ISO-8601 string literal or a relative-datetime form)")) -(def OrderableValue +(def OrderableValueLiteral "Schema for something that is orderable value in MBQL (either a number or datetime)." (s/named (s/cond-pre s/Num Datetime) "Valid orderable value (must be number or datetime)")) (def AnyValueLiteral "Schema for anything that is a considered a valid value literal in MBQL - `nil`, a `Boolean`, `Number`, `String`, or relative datetime form." - (s/named (s/maybe (s/cond-pre s/Bool su/NonBlankString OrderableValue)) "Valid value (must be nil, boolean, number, string, or a relative-datetime form)")) + (s/named (s/maybe (s/cond-pre s/Bool su/NonBlankString OrderableValueLiteral)) "Valid value (must be nil, boolean, number, string, or a relative-datetime form)")) ;; Value is the expansion of a value within a QL clause @@ -222,14 +226,28 @@ (s/defrecord Value [value :- AnyValueLiteral field :- (s/recursive #'AnyField)]) +(s/defrecord RelativeDateTimeValue [amount :- s/Int + unit :- DatetimeValueUnit + field :- (s/cond-pre DateTimeField + FieldPlaceholder)]) + +(def OrderableValue + "Schema for an instance of `Value` whose `:value` property is itself orderable (a datetime or number, i.e. a `OrderableValueLiteral`)." + (s/named (s/cond-pre + RelativeDateTimeValue + (s/constrained Value (fn [{value :value}] + (nil? (s/check OrderableValueLiteral value))))) + "Value that is orderable (Value whose :value is something orderable, like a datetime or number)")) + +(def StringValue + "Schema for an instance of `Value` whose `:value` property is itself a string (a datetime or string, i.e. a `OrderableValueLiteral`)." + (s/named (s/constrained Value (comp string? :value)) + "Value that is a string (Value whose :value is a string)")) + ;; e.g. an absolute point in time (literal) (s/defrecord DateTimeValue [value :- Timestamp field :- DateTimeField]) -(s/defrecord RelativeDateTimeValue [amount :- s/Int - unit :- DatetimeValueUnit - field :- DateTimeField]) - (defprotocol ^:private IDateTimeValue (unit [this] "Get the `unit` associated with a `DateTimeValue` or `RelativeDateTimeValue`.") @@ -251,20 +269,34 @@ ;; Replace values with these during first pass over Query. ;; Include associated Field ID so appropriate the info can be found during Field resolution -(s/defrecord ValuePlaceholder [field-placeholder :- FieldPlaceholderOrExpressionRef +(s/defrecord ValuePlaceholder [field-placeholder :- AnyField value :- AnyValueLiteral]) (def OrderableValuePlaceholder "`ValuePlaceholder` schema with the additional constraint that the value be orderable (a number or datetime)." - (s/constrained ValuePlaceholder (comp (complement (s/checker OrderableValue)) :value) ":value must be orderable (number or datetime)")) + (s/constrained ValuePlaceholder (comp (complement (s/checker OrderableValueLiteral)) :value) ":value must be orderable (number or datetime)")) + +(def OrderableValueOrPlaceholder + "Schema for an `OrderableValue` (instance of `Value` whose `:value` is orderable) or a placeholder for one." + (s/named (s/cond-pre OrderableValue OrderableValuePlaceholder) + "Must be an OrderableValue or OrderableValuePlaceholder")) (def StringValuePlaceholder "`ValuePlaceholder` schema with the additional constraint that the value be a string/" (s/constrained ValuePlaceholder (comp string? :value) ":value must be a string")) +(def StringValueOrPlaceholder + "Schema for an `StringValue` (instance of `Value` whose `:value` is a string) or a placeholder for one." + (s/named (s/cond-pre StringValue StringValuePlaceholder) + "Must be an StringValue or StringValuePlaceholder")) + +(def AnyValue + "Schema that accepts anything normally considered a value or value placeholder." + (s/named (s/cond-pre DateTimeValue RelativeDateTimeValue Value ValuePlaceholder) "Valid value")) + (def AnyFieldOrValue - "Schema that accepts anything normally considered a field (including expressions and literals) *or* a value or value placehoder." - (s/named (s/cond-pre AnyField Value ValuePlaceholder) "Field or value")) + "Schema that accepts anything normally considered a field or value." + (s/named (s/cond-pre AnyField AnyValue) "Field or value")) ;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+ @@ -279,7 +311,7 @@ (s/defrecord AggregationWithField [aggregation-type :- (s/named (s/enum :avg :count :cumulative-sum :distinct :max :min :stddev :sum) "Valid aggregation type") - field :- (s/cond-pre FieldPlaceholderOrExpressionRef + field :- (s/cond-pre AnyField Expression) custom-name :- (s/maybe su/NonBlankString)]) @@ -299,21 +331,21 @@ ;;; filter (s/defrecord EqualityFilter [filter-type :- (s/enum := :!=) - field :- FieldPlaceholderOrExpressionRef + field :- AnyField value :- AnyFieldOrValue]) (s/defrecord ComparisonFilter [filter-type :- (s/enum :< :<= :> :>=) - field :- FieldPlaceholderOrExpressionRef - value :- OrderableValuePlaceholder]) + field :- AnyField + value :- OrderableValueOrPlaceholder]) (s/defrecord BetweenFilter [filter-type :- (s/eq :between) - min-val :- OrderableValuePlaceholder - field :- FieldPlaceholderOrExpressionRef - max-val :- OrderableValuePlaceholder]) + min-val :- OrderableValueOrPlaceholder + field :- AnyField + max-val :- OrderableValueOrPlaceholder]) (s/defrecord StringFilter [filter-type :- (s/enum :starts-with :contains :ends-with) - field :- FieldPlaceholderOrExpressionRef - value :- StringValuePlaceholder]) + field :- AnyField + value :- (s/cond-pre s/Str StringValueOrPlaceholder)]) ; TODO - not 100% sure why this is also allowed to accept a plain string (def SimpleFilterClause "Schema for a non-compound, non-`not` MBQL `filter` clause." @@ -356,18 +388,39 @@ "Valid page clause")) +;;; source-query + +(declare Query) + +(def SourceQuery + "Schema for a valid value for a `:source-query` clause." + (s/if :native + {:native s/Any + (s/optional-key :template_tags) s/Any} + (s/recursive #'Query))) + ;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+ ;;; | QUERY | ;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+ (def Query "Schema for an MBQL query." - {(s/optional-key :aggregation) [Aggregation] - (s/optional-key :breakout) [FieldPlaceholderOrExpressionRef] - (s/optional-key :fields) [AnyField] - (s/optional-key :filter) Filter - (s/optional-key :limit) su/IntGreaterThanZero - (s/optional-key :order-by) [OrderBy] - (s/optional-key :page) Page - (s/optional-key :expressions) {s/Keyword Expression} - :source-table su/IntGreaterThanZero}) + (s/constrained + {(s/optional-key :aggregation) [Aggregation] + (s/optional-key :breakout) [AnyField] + (s/optional-key :fields) [AnyField] + (s/optional-key :filter) Filter + (s/optional-key :limit) su/IntGreaterThanZero + (s/optional-key :order-by) [OrderBy] + (s/optional-key :page) Page + (s/optional-key :expressions) {s/Keyword Expression} + (s/optional-key :source-table) su/IntGreaterThanZero + (s/optional-key :source-query) SourceQuery} + (fn [{:keys [source-table source-query native-source-query]}] + (and (or source-table + source-query + native-source-query) + (not (and source-table + source-query + native-source-query)))) + "Query must specify either `:source-table` or `:source-query`, but not both.")) diff --git a/src/metabase/query_processor/middleware/add_implicit_clauses.clj b/src/metabase/query_processor/middleware/add_implicit_clauses.clj index 705a461061f3224a9bf9bb770ea26ed6337fe64e..21483b4ca8499cce00e6137be0994fbaaf8d222a 100644 --- a/src/metabase/query_processor/middleware/add_implicit_clauses.clj +++ b/src/metabase/query_processor/middleware/add_implicit_clauses.clj @@ -5,60 +5,74 @@ [metabase.query-processor [interface :as i] [resolve :as resolve] + [sort :as sort] [util :as qputil]] [toucan.db :as db])) +(defn- fetch-fields-for-souce-table-id [source-table-id] + (map resolve/rename-mb-field-keys + (db/select [Field :name :display_name :base_type :special_type :visibility_type :table_id :id :position :description] + :table_id source-table-id + :visibility_type [:not-in ["sensitive" "retired"]] + :parent_id nil + {:order-by [[:position :asc] + [:id :desc]]}))) + (defn- fields-for-source-table "Return the all fields for SOURCE-TABLE, for use as an implicit `:fields` clause." - [{source-table-id :id, :as source-table}] - (for [field (db/select [Field :name :display_name :base_type :special_type :visibility_type :table_id :id :position :description] - :table_id source-table-id - :visibility_type [:not-in ["sensitive" "retired"]] - :parent_id nil - {:order-by [[:position :asc] - [:id :desc]]})] - (let [field (resolve/resolve-table (i/map->Field (resolve/rename-mb-field-keys field)) - {[nil source-table-id] source-table})] - (if (qputil/datetime-field? field) - (i/map->DateTimeField {:field field, :unit :default}) - field)))) + [{{source-table-id :id, :as source-table} :source-table, :as inner-query}] + ;; Sort the implicit FIELDS so the SQL (or other native query) that gets generated (mostly) approximates the 'magic' sorting + ;; we do on the results. This is done so when the outer query we generate is a `SELECT *` the order doesn't change + (for [field (sort/sort-fields inner-query (fetch-fields-for-souce-table-id source-table-id)) + :let [field (resolve/resolve-table (i/map->Field field) {[nil source-table-id] source-table})]] + (if (qputil/datetime-field? field) + (i/map->DateTimeField {:field field, :unit :default}) + field))) -(defn- should-add-implicit-fields? [{{:keys [fields breakout], aggregations :aggregation} :query, :as query}] - (and (qputil/mbql-query? query) +(defn- should-add-implicit-fields? [{:keys [fields breakout source-table], aggregations :aggregation}] + (and source-table ; if query is using another query as its source then there will be no table to add nested fields for (not (or (seq aggregations) (seq breakout) (seq fields))))) -(defn- add-implicit-fields [{{:keys [source-table]} :query, :as query}] - (if-not (should-add-implicit-fields? query) - query +(defn- add-implicit-fields [{:keys [source-table], :as inner-query}] + (if-not (should-add-implicit-fields? inner-query) + inner-query ;; this is a structured `:rows` query, so lets add a `:fields` clause with all fields from the source table + expressions - (let [fields (fields-for-source-table source-table) - expressions (for [[expression-name] (get-in query [:query :expressions])] + (let [inner-query (assoc inner-query :fields-is-implicit true) + fields (fields-for-source-table inner-query) + expressions (for [[expression-name] (:expressions inner-query)] (i/strict-map->ExpressionRef {:expression-name (name expression-name)}))] (when-not (seq fields) (log/warn (format "Table '%s' has no Fields associated with it." (:name source-table)))) - (-> query - (assoc-in [:query :fields-is-implicit] true) - (assoc-in [:query :fields] (concat fields expressions)))))) + (assoc inner-query + :fields (concat fields expressions))))) (defn- add-implicit-breakout-order-by "`Fields` specified in `breakout` should add an implicit ascending `order-by` subclause *unless* that field is *explicitly* referenced in `order-by`." - [{{breakout-fields :breakout, order-by :order-by} :query, :as query}] + [{breakout-fields :breakout, order-by :order-by, :as inner-query}] + (let [order-by-fields (set (map :field order-by)) + implicit-breakout-order-by-fields (filter (partial (complement contains?) order-by-fields) + breakout-fields)] + (cond-> inner-query + (seq implicit-breakout-order-by-fields) (update :order-by concat (for [field implicit-breakout-order-by-fields] + {:field field, :direction :ascending}))))) + +(defn- add-implicit-clauses-to-inner-query [inner-query] + (cond-> (add-implicit-fields (add-implicit-breakout-order-by inner-query)) + ;; if query has a source query recursively add implicit clauses to that too as needed + (:source-query inner-query) (update :source-query add-implicit-clauses-to-inner-query))) + +(defn- maybe-add-implicit-clauses [query] (if-not (qputil/mbql-query? query) query - (let [order-by-fields (set (map :field order-by)) - implicit-breakout-order-by-fields (filter (partial (complement contains?) order-by-fields) - breakout-fields)] - (cond-> query - (seq implicit-breakout-order-by-fields) (update-in [:query :order-by] concat (for [field implicit-breakout-order-by-fields] - {:field field, :direction :ascending})))))) + (update query :query add-implicit-clauses-to-inner-query))) (defn add-implicit-clauses "Add an implicit `fields` clause to queries with no `:aggregation`, `breakout`, or explicit `:fields` clauses. Add implicit `:order-by` clauses for fields specified in a `:breakout`." [qp] - (comp qp add-implicit-fields add-implicit-breakout-order-by)) + (comp qp maybe-add-implicit-clauses)) diff --git a/src/metabase/query_processor/middleware/fetch_source_query.clj b/src/metabase/query_processor/middleware/fetch_source_query.clj new file mode 100644 index 0000000000000000000000000000000000000000..fc3e6eeaceea475203c67bf178a85656bce0ccb2 --- /dev/null +++ b/src/metabase/query_processor/middleware/fetch_source_query.clj @@ -0,0 +1,61 @@ +(ns metabase.query-processor.middleware.fetch-source-query + (:require [clojure.tools.logging :as log] + [metabase.query-processor + [interface :as i] + [util :as qputil]] + [metabase.util :as u] + [toucan.db :as db])) + +(defn- card-id->source-query + "Return the source query info for Card with CARD-ID." + [card-id] + (let [card (db/select-one ['Card :dataset_query :database_id] :id card-id) + card-query (:dataset_query card)] + (assoc (or (:query card-query) + (when-let [native (:native card-query)] + {:native (:query native) + :template_tags (:template_tags native)}) + (throw (Exception. (str "Missing source query in Card " card-id)))) + ;; include database ID as well; we'll pass that up the chain so it eventually gets put in its spot in the outer-query + :database (:database card-query)))) + +(defn- source-table-str->source-query + "Given a SOURCE-TABLE-STR like `card__100` return the appropriate source query." + [source-table-str] + (let [[_ card-id-str] (re-find #"^card__(\d+)$" source-table-str)] + (u/prog1 (card-id->source-query (Integer/parseInt card-id-str)) + (when-not i/*disable-qp-logging* + (log/info "\nFETCHED SOURCE QUERY FROM CARD" card-id-str ":\n" (u/pprint-to-str 'yellow <>)))))) + +(defn- expand-card-source-tables + "If `source-table` is a Card reference (a string like `card__100`) then replace that with appropriate `:source-query` information. + Does nothing if `source-table` is a normal ID. Recurses for nested-nested queries." + [inner-query] + (let [source-table (qputil/get-normalized inner-query :source-table)] + (if-not (string? source-table) + inner-query + ;; (recursively) expand the source query + (let [source-query (expand-card-source-tables (source-table-str->source-query source-table))] + (-> inner-query + ;; remove `source-table` `card__id` key + (qputil/dissoc-normalized :source-table) + ;; Add new `source-query` info in its place. Pass the database ID up the chain, removing it from the source query + (assoc + :source-query (dissoc source-query :database) + :database (:database source-query))))))) + +(defn- fetch-source-query* [{inner-query :query, :as outer-query}] + (if-not inner-query + ;; for non-MBQL queries there's nothing to do since they have nested queries + outer-query + ;; otherwise attempt to expand any source queries as needed + (let [expanded-inner-query (expand-card-source-tables inner-query)] + (merge outer-query + {:query (dissoc expanded-inner-query :database)} + (when-let [database (:database expanded-inner-query)] + {:database database}))))) + +(defn fetch-source-query + "Middleware that assocs the `:source-query` for this query if it was specified using the shorthand `:source-table` `card__n` format." + [qp] + (comp qp fetch-source-query*)) diff --git a/src/metabase/query_processor/middleware/log.clj b/src/metabase/query_processor/middleware/log.clj index 6ac5031e61c05de6f50fbaa8a26f7abf72efe870..183668ba9705e625ba3cd1ae21c80d3a404efdc1 100644 --- a/src/metabase/query_processor/middleware/log.clj +++ b/src/metabase/query_processor/middleware/log.clj @@ -20,7 +20,7 @@ (walk/prewalk (fn [f] (if-not (map? f) f - (m/filter-vals identity (into {} f)))) + (m/filter-vals (complement nil?) (into {} f)))) ;; obscure DB details when logging. Just log the name of driver because we don't care about its properties (-> query (assoc-in [:database :details] (u/emoji "😋 ")) ; :yum: @@ -42,3 +42,16 @@ "Middleware for logging a query when it is very first encountered, before it is expanded." [qp] (comp qp log-initial-query*)) + + +(defn- log-results-metadata* [results] + (u/prog1 results + (when-not i/*disable-qp-logging* + (log/debug "Result Metadata:\n" + (u/pprint-to-str 'blue (for [col (get-in <> [:data :cols])] + (m/filter-vals (complement nil?) col))))))) + +(defn log-results-metadata + "Middleware that logs the column metadata that comes back with the results." + [qp] + (comp log-results-metadata* qp)) diff --git a/src/metabase/query_processor/middleware/parameters.clj b/src/metabase/query_processor/middleware/parameters.clj index 53d25787bb1c887e2257f4d4b3c7dea5878bdf85..f6d013ca8835d96a8cb69fb6316db395ee26e405 100644 --- a/src/metabase/query_processor/middleware/parameters.clj +++ b/src/metabase/query_processor/middleware/parameters.clj @@ -2,21 +2,49 @@ "Middleware for substituting parameters in queries." (:require [clojure.data :as data] [clojure.tools.logging :as log] - [metabase.query-processor.interface :as i] + [metabase.driver.generic-sql.util.unprepare :as unprepare] + [metabase.query-processor + [interface :as i] + [util :as qputil]] [metabase.query-processor.middleware.parameters [mbql :as mbql-params] [sql :as sql-params]] [metabase.util :as u])) -(defn- expand-parameters +(defn- expand-parameters* "Expand any :parameters set on the QUERY-DICT and apply them to the query definition. This function removes the :parameters attribute from the QUERY-DICT as part of its execution." [{:keys [parameters], :as query-dict}] ;; params in native queries are currently only supported for SQL drivers - (if (= :query (keyword (:type query-dict))) + (if (qputil/mbql-query? query-dict) (mbql-params/expand (dissoc query-dict :parameters) parameters) (sql-params/expand query-dict))) +(defn- expand-params-in-native-source-query + "Expand parameters in a native source query." + [{{{original-query :native, tags :template_tags} :source-query} :query, :as outer-query}] + ;; TODO - This isn't recursive for nested-nested queries + ;; TODO - Yes, this approach is hacky. But hacky & working > not working + (let [{{new-query :query, new-params :params} :native} (sql-params/expand (assoc outer-query + :type :native + :native {:query original-query + :template_tags tags}))] + (if (= original-query new-query) + ;; if the native query didn't change, we don't need to do anything; return as-is + outer-query + ;; otherwise replace the native query with the param-substituted version. + ;; 'Unprepare' the args because making sure args get passed in the right order is too tricky for nested queries + ;; TODO - This might not work for all drivers. We should make 'unprepare' a Generic SQL method + ;; so different drivers can invoke unprepare/unprepare with the correct args + (-> outer-query + (assoc-in [:query :source-query :native] (unprepare/unprepare (cons new-query new-params))))))) + +(defn- expand-parameters + "Expand parameters in the OUTER-QUERY, and if the query is using a native source query, expand params in that as well." + [outer-query] + (cond-> (expand-parameters* outer-query) + (get-in outer-query [:query :source-query :native]) expand-params-in-native-source-query)) + (defn- substitute-parameters* "If any parameters were supplied then substitute them into the query." [query] diff --git a/src/metabase/query_processor/middleware/permissions.clj b/src/metabase/query_processor/middleware/permissions.clj index fddb9f6e7a491074e95259c186310c17d429a036..2fdf81f66e30dc3a419f9d5596a255f347e5682b 100644 --- a/src/metabase/query_processor/middleware/permissions.clj +++ b/src/metabase/query_processor/middleware/permissions.clj @@ -79,9 +79,11 @@ ;; TODO - why is this the only function here that takes `user-id`? (defn- throw-if-cannot-run-query "Throw an exception if USER-ID doesn't have permissions to run QUERY." - [user-id {:keys [source-table join-tables]}] - (doseq [table (cons source-table join-tables)] - (throw-if-cannot-run-query-referencing-table user-id table))) + [user-id {:keys [source-table join-tables source-query]}] + (if source-query + (recur user-id source-query) + (doseq [table (cons source-table join-tables)] + (throw-if-cannot-run-query-referencing-table user-id table)))) ;;; ------------------------------------------------------------ Permissions for Native Queries ------------------------------------------------------------ @@ -120,7 +122,7 @@ (defn- check-query-permissions-for-user "Check that User with USER-ID has permissions to run QUERY, or throw an exception." - [user-id {query-type :type, database :database, query :query, {card-id :card-id} :info, :as outer-query}] + [user-id {query-type :type, database :database, {:keys [source-query], :as query} :query, {:keys [card-id]} :info, :as outer-query}] {:pre [(integer? user-id) (map? outer-query)]} (let [native? (= (keyword query-type) :native) collection-id (db/select-one-field :collection_id 'Card :id card-id)] @@ -128,6 +130,11 @@ ;; if the card is in a COLLECTION, then see if the current user has permissions for that collection collection-id (throw-if-user-doesnt-have-access-to-collection collection-id) + ;; Otherwise if this is a NESTED query then we should check permissions for the source query + source-query + (if (:native source-query) + (throw-if-cannot-run-existing-native-query-referencing-db database) + (throw-if-cannot-run-query user-id source-query)) ;; for native queries that are *not* part of an existing card, check that we have native permissions for the DB (and native? (not card-id)) (throw-if-cannot-run-new-native-query-referencing-db database) diff --git a/src/metabase/query_processor/middleware/results_metadata.clj b/src/metabase/query_processor/middleware/results_metadata.clj new file mode 100644 index 0000000000000000000000000000000000000000..824b54e0a22a97fd20d3d93d3ef3013b8cd05ae3 --- /dev/null +++ b/src/metabase/query_processor/middleware/results_metadata.clj @@ -0,0 +1,98 @@ +(ns metabase.query-processor.middleware.results-metadata + "Middleware that stores metadata about results column types after running a query for a Card, + and returns that metadata (which can be passed *back* to the backend when saving a Card) as well + as a checksum in the API response." + (:require [buddy.core.hash :as hash] + [cheshire.core :as json] + [metabase.models.humanization :as humanization] + [metabase.query-processor.interface :as i] + [metabase.util :as u] + [metabase.util + [encryption :as encryption] + [schema :as su]] + [ring.util.codec :as codec] + [schema.core :as s] + [toucan.db :as db])) + +(def ^:private DateTimeUnitKeywordOrString + "Schema for a valid datetime unit string like \"default\" or \"minute-of-hour\"." + (s/constrained su/KeywordOrString + (fn [unit] + (contains? i/datetime-field-units (keyword unit))) + "Valid field datetime unit keyword or string")) + +(def ResultsMetadata + "Schema for valid values of the `result_metadata` column." + (su/with-api-error-message (s/named [{:name su/NonBlankString + :display_name su/NonBlankString + (s/optional-key :description) (s/maybe su/NonBlankString) + :base_type su/FieldTypeKeywordOrString + (s/optional-key :special_type) (s/maybe su/FieldTypeKeywordOrString) + (s/optional-key :unit) (s/maybe DateTimeUnitKeywordOrString)}] + "Valid array of results column metadata maps") + "value must be an array of valid results column metadata maps.")) + +(s/defn ^:private ^:always-validate results->column-metadata :- (s/maybe ResultsMetadata) + "Return the desired storage format for the column metadata coming back from RESULTS, or `nil` if no columns were returned." + [results] + ;; rarely certain queries will return columns with no names, for example `SELECT COUNT(*)` in SQL Server seems to come back with no name + ;; since we can't use those as field literals in subsequent queries just filter them out + (seq (for [col (:cols results) + :when (seq (:name col))] + (merge + ;; if base-type isn't set put a default one in there. Similarly just use humanized value of `:name` for `:display_name` if one isn't set + {:base_type :type/* + :display_name (humanization/name->human-readable-name (name (:name col)))} + (u/select-non-nil-keys col [:name :display_name :description :base_type :special_type :unit]) + ;; since years are actually returned as text they can't be used for breakout purposes so don't advertise them as DateTime columns + (when (= (:unit col) :year) + {:base_type :type/Text + :unit nil}))))) + +;; TODO - is there some way we could avoid doing this every single time a Card is ran? Perhaps by passing the current Card +;; metadata as part of the query context so we can compare for changes +(defn- record-metadata! [card-id metadata] + (when metadata + (db/update! 'Card card-id + :result_metadata metadata))) + +(defn- metadata-checksum + "Simple, checksum of the column results METADATA. + Results metadata is returned as part of all query results, with the hope that the frontend will pass it back to + us when a Card is saved or updated. This checksum (also passed) is a simple way for us to check whether the metadata + is valid and hasn't been accidentally tampered with. + + By default, this is not cryptographically secure, nor is it meant to be. Of course, a bad actor could alter the + metadata and return a new, correct checksum. But intentionally saving bad metadata would only help in letting you + write bad queries; the field literals can only refer to columns in the original 'source' query at any rate, so you + wouldn't, for example, be able to give yourself access to columns in a different table. + + However, if `MB_ENCRYPTION_SECRET_KEY` is set, we'll go ahead and use it to encypt the checksum so it becomes it becomes + impossible to alter the metadata and produce a correct checksum at any rate." + [metadata] + (when metadata + (encryption/maybe-encrypt (codec/base64-encode (hash/md5 (json/generate-string metadata)))))) + +(defn valid-checksum? + "Is the CHECKSUM the right one for this column METADATA?" + [metadata checksum] + (and metadata + checksum + (= (encryption/maybe-decrypt (metadata-checksum metadata)) + (encryption/maybe-decrypt checksum)))) + +(defn record-and-return-metadata! + "Middleware that records metadata about the columns returned when running the query if it is associated with a Card." + [qp] + (fn [{{:keys [card-id nested?]} :info, :as query}] + (let [results (qp query) + metadata (results->column-metadata results)] + ;; At the very least we can skip the Extra DB call to update this Card's metadata results + ;; if its DB doesn't support nested queries in the first place + (when (i/driver-supports? :nested-queries) + (when (and card-id + (not nested?)) + (record-metadata! card-id metadata))) + ;; add the metadata and checksum to the response + (assoc results :results_metadata {:checksum (metadata-checksum metadata) + :columns metadata})))) diff --git a/src/metabase/query_processor/resolve.clj b/src/metabase/query_processor/resolve.clj index 33fcc695de7d22f22f57002f615b6123898b679d..667bd5230951332897ce8b8b31510e3ae0154187 100644 --- a/src/metabase/query_processor/resolve.clj +++ b/src/metabase/query_processor/resolve.clj @@ -245,19 +245,25 @@ (defn- resolve-tables "Resolve the `Tables` in an EXPANDED-QUERY-DICT." [{{source-table-id :source-table} :query, :keys [table-ids fk-field-ids], :as expanded-query-dict}] - {:pre [(integer? source-table-id)]} - (let [table-ids (conj table-ids source-table-id) - source-table (or (db/select-one [Table :schema :name :id], :id source-table-id) - (throw (Exception. (format "Query expansion failed: could not find source table %d." source-table-id)))) - joined-tables (fk-field-ids->joined-tables source-table-id fk-field-ids) - fk-id+table-id->table (into {[nil source-table-id] source-table} - (for [{:keys [source-field table-id join-alias]} joined-tables] - {[(:field-id source-field) table-id] {:name join-alias - :id table-id}}))] - (as-> expanded-query-dict <> - (assoc-in <> [:query :source-table] source-table) - (assoc-in <> [:query :join-tables] joined-tables) - (walk/postwalk #(resolve-table % fk-id+table-id->table) <>)))) + (if-not source-table-id + ;; if we have a `source-query`, recurse and resolve tables in that + (update-in expanded-query-dict [:query :source-query] (fn [source-query] + (if (:native source-query) + source-query + (:query (resolve-tables (assoc expanded-query-dict :query source-query)))))) + ;; otherwise we can resolve tables in the (current) top-level + (let [table-ids (conj table-ids source-table-id) + source-table (or (db/select-one [Table :schema :name :id], :id source-table-id) + (throw (Exception. (format "Query expansion failed: could not find source table %d." source-table-id)))) + joined-tables (fk-field-ids->joined-tables source-table-id fk-field-ids) + fk-id+table-id->table (into {[nil source-table-id] source-table} + (for [{:keys [source-field table-id join-alias]} joined-tables] + {[(:field-id source-field) table-id] {:name join-alias + :id table-id}}))] + (as-> expanded-query-dict <> + (assoc-in <> [:query :source-table] source-table) + (assoc-in <> [:query :join-tables] joined-tables) + (walk/postwalk #(resolve-table % fk-id+table-id->table) <>))))) ;;; # ------------------------------------------------------------ PUBLIC INTERFACE ------------------------------------------------------------ diff --git a/src/metabase/query_processor/sort.clj b/src/metabase/query_processor/sort.clj index 2cf435974626f8df0c583ee47a7982d77367b117..f78153c9e12126cfdc9b8cfbec0c363d8252c742 100644 --- a/src/metabase/query_processor/sort.clj +++ b/src/metabase/query_processor/sort.clj @@ -65,10 +65,22 @@ (special-type-importance field) field-name]))) +(defn- should-sort? [inner-query] + (or + ;; if there's no source query then always sort + (not (:source-query inner-query)) + ;; if the source query is MBQL then sort + (not (get-in inner-query [:source-query :native])) + ;; otherwise only sort queries with *NATIVE* source queries if the query has an aggregation and/or breakout + (:aggregation inner-query) + (:breakout inner-query))) + (defn sort-fields "Sort FIELDS by their \"importance\" vectors." - [query fields] - (let [field-importance (field-importance-fn query)] - (when-not i/*disable-qp-logging* - (log/debug (u/format-color 'yellow "Sorted fields:\n%s" (u/pprint-to-str (sort (map field-importance fields)))))) - (sort-by field-importance fields))) + [inner-query fields] + (if-not (should-sort? inner-query) + fields + (let [field-importance (field-importance-fn inner-query)] + (when-not i/*disable-qp-logging* + (log/debug (u/format-color 'yellow "Sorted fields:\n%s" (u/pprint-to-str (sort (map field-importance fields)))))) + (sort-by field-importance fields)))) diff --git a/test/metabase/api/card_test.clj b/test/metabase/api/card_test.clj index 0c4f47d260a5fae320702ba3a9214d4baa27bf99..1553fa6bebef29291cf8b4e457e784715dc2ba8d 100644 --- a/test/metabase/api/card_test.clj +++ b/test/metabase/api/card_test.clj @@ -40,7 +40,8 @@ :made_public_by_id nil :public_uuid nil :query_type "query" - :cache_ttl nil}) + :cache_ttl nil + :result_metadata nil}) (defn- do-with-self-cleaning-random-card-name "Generate a random card name (or use CARD-NAME), pass it to F, then delete any Cards with that name afterwords." @@ -181,12 +182,11 @@ [card-2-id] (map :id ((user->client :rasta) :get 200 "card", :label "more_toucans"))) ; filtering is done by slug -(defn- mbql-count-query [database-id table-id] - {:database database-id - :type "query" - :query {:source-table table-id, :aggregation {:aggregation-type "count"}}}) -;; ## POST /api/card +;;; +------------------------------------------------------------------------------------------------------------------------+ +;;; | CREATING A CARD | +;;; +------------------------------------------------------------------------------------------------------------------------+ + ;; Test that we can make a card (let [card-name (random-name)] (tt/expect-with-temp [Database [{database-id :id}] @@ -199,12 +199,45 @@ :database_id database-id ; these should be inferred automatically :table_id table-id}) (with-self-cleaning-random-card-name [_ card-name] - (dissoc ((user->client :rasta) :post 200 "card" {:name card-name - :display "scalar" - :dataset_query (mbql-count-query database-id table-id) - :visualization_settings {:global {:title nil}}}) + (dissoc ((user->client :rasta) :post 200 "card" (card-with-name-and-query card-name (mbql-count-query database-id table-id))) :created_at :updated_at :id)))) +;; Make sure when saving a Card the query metadata is saved (if correct) +(expect + [{:base_type "type/Integer" + :display_name "Count Chocula" + :name "count_chocula" + :special_type "type/Number"}] + (let [metadata [{:base_type :type/Integer + :display_name "Count Chocula" + :name "count_chocula" + :special_type :type/Number}]] + (with-self-cleaning-random-card-name [card-name] + ;; create a card with the metadata + ((user->client :rasta) :post 200 "card" (assoc (card-with-name-and-query card-name (mbql-count-query (data/id) (data/id :venues))) + :result_metadata metadata + :metadata_checksum ((resolve 'metabase.query-processor.middleware.results-metadata/metadata-checksum) metadata))) + ;; now check the metadata that was saved in the DB + (db/select-one-field :result_metadata Card :name card-name)))) + +;; make sure when saving a Card the correct query metadata is fetched (if incorrect) +(expect + [{:base_type "type/Integer" + :display_name "count" + :name "count" + :special_type "type/Number"}] + (let [metadata [{:base_type :type/Integer + :display_name "Count Chocula" + :name "count_chocula" + :special_type :type/Number}]] + (with-self-cleaning-random-card-name [card-name] + ;; create a card with the metadata + ((user->client :rasta) :post 200 "card" (assoc (card-with-name-and-query card-name (mbql-count-query (data/id) (data/id :venues))) + :result_metadata metadata + :metadata_checksum "ABCDEF")) ; bad checksum + ;; now check the correct metadata was fetched and was saved in the DB + (db/select-one-field :result_metadata Card :name card-name)))) + ;;; +------------------------------------------------------------------------------------------------------------------------+ ;;; | FETCHING A SPECIFIC CARD | @@ -323,7 +356,50 @@ (tu/with-temporary-setting-values [enable-embedding false] ((user->client :crowberto) :put 400 (str "card/" (u/get-id card)) {:embedding_params {:abc "enabled"}})))) -;; ## DELETE /api/card/:id +;; make sure when updating a Card the query metadata is saved (if correct) +(expect + [{:base_type "type/Integer" + :display_name "Count Chocula" + :name "count_chocula" + :special_type "type/Number"}] + (let [metadata [{:base_type :type/Integer + :display_name "Count Chocula" + :name "count_chocula" + :special_type :type/Number}]] + (tt/with-temp Card [card] + ;; update the Card's query + ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) + {:dataset_query (mbql-count-query (data/id) (data/id :venues)) + :result_metadata metadata + :metadata_checksum ((resolve 'metabase.query-processor.middleware.results-metadata/metadata-checksum) metadata)}) + ;; now check the metadata that was saved in the DB + (db/select-one-field :result_metadata Card :id (u/get-id card))))) + +;; Make sure when updating a Card the correct query metadata is fetched (if incorrect) +(expect + [{:base_type "type/Integer" + :display_name "count" + :name "count" + :special_type "type/Number"}] + (let [metadata [{:base_type :type/Integer + :display_name "Count Chocula" + :name "count_chocula" + :special_type :type/Number}]] + (tt/with-temp Card [card] + ;; update the Card's query + ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) + {:dataset_query (mbql-count-query (data/id) (data/id :venues)) + :result_metadata metadata + :metadata_checksum "ABC123"}) ; invalid checksum + ;; now check the metadata that was saved in the DB + (db/select-one-field :result_metadata Card :id (u/get-id card))))) + + +;;; +------------------------------------------------------------------------------------------------------------------------+ +;;; | DELETING A CARD (DEPRECATED) | +;;; +------------------------------------------------------------------------------------------------------------------------+ +;; Deprecated because you're not supposed to delete cards anymore. Archive them instead + ;; Check that we can delete a card (expect nil diff --git a/test/metabase/api/dashboard_test.clj b/test/metabase/api/dashboard_test.clj index d2fdb6bff2bff3e7f842e658073e04e25395a5fb..ec02621cd5ee9b79cc596f2905acc3a319442218 100644 --- a/test/metabase/api/dashboard_test.clj +++ b/test/metabase/api/dashboard_test.clj @@ -133,7 +133,8 @@ :query_type nil :dataset_query {} :visualization_settings {} - :query_average_duration nil}) + :query_average_duration nil + :result_metadata nil}) :series []}]}) ;; fetch a dashboard WITH a dashboard card on it (tt/with-temp* [Dashboard [{dashboard-id :id} {:name "Test Dashboard"}] diff --git a/test/metabase/api/database_test.clj b/test/metabase/api/database_test.clj index 0262b362f2346f4be35014560c2f73c07961168d..a3e0df90b3637c31defee4e9fdb6ce676a6c7088 100644 --- a/test/metabase/api/database_test.clj +++ b/test/metabase/api/database_test.clj @@ -4,7 +4,9 @@ [driver :as driver] [util :as u]] [metabase.models - [database :refer [Database]] + [card :refer [Card]] + [collection :refer [Collection]] + [database :as database :refer [Database]] [field :refer [Field]] [table :refer [Table]]] [metabase.test @@ -15,7 +17,8 @@ [users :refer :all]] [toucan [db :as db] - [hydrate :as hydrate]])) + [hydrate :as hydrate]] + [toucan.util.test :as tt])) ;; HELPER FNS @@ -315,3 +318,112 @@ [["CATEGORIES" "Table"] ["CATEGORY_ID" "VENUES :type/Integer :type/FK"]] ((user->client :rasta) :get 200 (format "database/%d/autocomplete_suggestions" (id)) :prefix "cat")) + + +;;; GET /api/database?include_cards=true +;; Check that we get back 'virtual' tables for Saved Questions +(defn- card-with-native-query {:style/indent 1} [card-name & {:as kvs}] + (merge {:name card-name + :database_id (data/id) + :dataset_query {:database (data/id) + :type :native + :native {:query (format "SELECT * FROM VENUES")}}} + kvs)) + +(defn- card-with-mbql-query {:style/indent 1} [card-name & {:as inner-query-clauses}] + {:name card-name + :database_id (data/id) + :dataset_query {:database (data/id) + :type :query + :query inner-query-clauses}}) + +(defn- saved-questions-virtual-db {:style/indent 0} [& card-tables] + {:name "Saved Questions" + :id database/virtual-id + :features ["basic-aggregations"] + :tables card-tables}) + +(tt/expect-with-temp [Card [card (card-with-native-query "Kanye West Quote Views Per Month")]] + (saved-questions-virtual-db + {:id (format "card__%d" (u/get-id card)) + :db_id database/virtual-id + :display_name "Kanye West Quote Views Per Month" + :schema "All questions" + :description nil}) + (do + ;; run the Card which will populate its result_metadata column + ((user->client :crowberto) :post 200 (format "card/%d/query" (u/get-id card))) + + ;; Now fetch the database list. The 'Saved Questions' DB should be last on the list + (last ((user->client :crowberto) :get 200 "database" :include_cards true)))) + +;; make sure that GET /api/database?include_cards=true groups pretends COLLECTIONS are SCHEMAS +(tt/expect-with-temp [Collection [stamp-collection {:name "Stamps"}] + Collection [coin-collection {:name "Coins"}] + Card [stamp-card (card-with-native-query "Total Stamp Count", :collection_id (u/get-id stamp-collection))] + Card [coin-card (card-with-native-query "Total Coin Count", :collection_id (u/get-id coin-collection))]] + (saved-questions-virtual-db + {:id (format "card__%d" (u/get-id coin-card)) + :db_id database/virtual-id + :display_name "Total Coin Count" + :schema "Coins" + :description nil} + {:id (format "card__%d" (u/get-id stamp-card)) + :db_id database/virtual-id + :display_name "Total Stamp Count" + :schema "Stamps" + :description nil}) + (do + ;; run the Cards which will populate their result_metadata columns + (doseq [card [stamp-card coin-card]] + ((user->client :crowberto) :post 200 (format "card/%d/query" (u/get-id card)))) + ;; Now fetch the database list. The 'Saved Questions' DB should be last on the list. Cards should have their Collection name as their Schema + (last ((user->client :crowberto) :get 200 "database" :include_cards true)))) + +(defn- virtual-table-for-card [card] + {:id (format "card__%d" (u/get-id card)) + :db_id database/virtual-id + :display_name (:name card) + :schema "All questions" + :description nil}) + +(defn- fetch-virtual-database [] + (some #(when (= (:name %) "Saved Questions") + %) + ((user->client :crowberto) :get 200 "database" :include_cards true))) + +;; make sure that GET /api/database?include_cards=true removes Cards that have ambiguous columns +(tt/expect-with-temp [Card [ok-card (assoc (card-with-native-query "OK Card") :result_metadata [{:name "cam"}])] + Card [cambiguous-card (assoc (card-with-native-query "Cambiguous Card") :result_metadata [{:name "cam"} {:name "cam_2"}])]] + (saved-questions-virtual-db + (virtual-table-for-card ok-card)) + (fetch-virtual-database)) + + +;; make sure that GET /api/database?include_cards=true removes Cards that use cumulative-sum and cumulative-count aggregations +(defn- ok-mbql-card [] + (assoc (card-with-mbql-query "OK Card" + :source-table (data/id :checkins)) + :result_metadata [{:name "num_toucans"}])) + +;; cum count using the new-style multiple aggregation syntax +(tt/expect-with-temp [Card [ok-card (ok-mbql-card)] + Card [_ (assoc (card-with-mbql-query "Cum Count Card" + :source-table (data/id :checkins) + :aggregation [[:cum-count]] + :breakout [[:datetime-field [:field-id (data/id :checkins :date) :month]]]) + :result_metadata [{:name "num_toucans"}])]] + (saved-questions-virtual-db + (virtual-table-for-card ok-card)) + (fetch-virtual-database)) + +;; cum sum using old-style single aggregation syntax +(tt/expect-with-temp [Card [ok-card (ok-mbql-card)] + Card [_ (assoc (card-with-mbql-query "Cum Sum Card" + :source-table (data/id :checkins) + :aggregation [:cum-sum] + :breakout [[:datetime-field [:field-id (data/id :checkins :date) :month]]]) + :result_metadata [{:name "num_toucans"}])]] + (saved-questions-virtual-db + (virtual-table-for-card ok-card)) + (fetch-virtual-database)) diff --git a/test/metabase/api/dataset_test.clj b/test/metabase/api/dataset_test.clj index 1355b9394aa9a781cdfac6a53f39c14492fb5d11..ca0ab8aef463964486c23b0662740c1253c9808a 100644 --- a/test/metabase/api/dataset_test.clj +++ b/test/metabase/api/dataset_test.clj @@ -1,6 +1,7 @@ (ns metabase.api.dataset-test "Unit tests for /api/dataset endpoints." (:require [expectations :refer :all] + [medley.core :as m] [metabase.api.dataset :refer [default-query-constraints]] [metabase.models.query-execution :refer [QueryExecution]] [metabase.query-processor.expand :as ql] @@ -36,7 +37,7 @@ [k (f v)])))))) (defn format-response [m] - (into {} (for [[k v] m] + (into {} (for [[k v] (m/dissoc-in m [:data :results_metadata])] (cond (contains? #{:id :started_at :running_time :hash} k) [k (boolean v)] (= :data k) [k (if-not (contains? v :native_form) diff --git a/test/metabase/api/session_test.clj b/test/metabase/api/session_test.clj index 4b8d97e722071bac826bed21aaa8dbfc9a480a51..5dda57327cc527ac28a25153eae1c2c85badc9aa 100644 --- a/test/metabase/api/session_test.clj +++ b/test/metabase/api/session_test.clj @@ -11,8 +11,9 @@ [user :refer [User]]] [metabase.test [data :refer :all] - [util :as tu :refer [resolve-private-vars]]] + [util :as tu :refer [resolve-private-vars with-temporary-setting-values]]] [metabase.test.data.users :refer :all] + [metabase.test.integrations.ldap :refer [expect-with-ldap-server]] [toucan.db :as db] [toucan.util.test :as tt])) @@ -24,11 +25,11 @@ (tu/is-uuid-string? (:id (client :post 200 "session" (user->credentials :rasta)))))) ;; Test for required params -(expect {:errors {:email "value must be a valid email address."}} +(expect {:errors {:username "value must be a non-blank string."}} (client :post 400 "session" {})) (expect {:errors {:password "value must be a non-blank string."}} - (client :post 400 "session" {:email "anything@metabase.com"})) + (client :post 400 "session" {:username "anything@metabase.com"})) ;; Test for inactive user (user shouldn't be able to login if :is_active = false) ;; Return same error as incorrect password to avoid leaking existence of user @@ -43,9 +44,9 @@ ;; Test that people get blocked from attempting to login if they try too many times ;; (Check that throttling works at the API level -- more tests in the throttle library itself: https://github.com/metabase/throttle) (expect - [{:errors {:email "Too many attempts! You must wait 15 seconds before trying again."}} - {:errors {:email "Too many attempts! You must wait 15 seconds before trying again."}}] - (let [login #(client :post 400 "session" {:email "fakeaccount3000@metabase.com", :password "toucans"})] + [{:errors {:username "Too many attempts! You must wait 15 seconds before trying again."}} + {:errors {:username "Too many attempts! You must wait 15 seconds before trying again."}}] + (let [login #(client :post 400 "session" {:username "fakeaccount3000@metabase.com", :password "toucans"})] ;; attempt to log in 10 times (dorun (repeatedly 10 login)) ;; throttling should now be triggered @@ -74,7 +75,7 @@ (db/update! User (user->id :rasta), :reset_token nil, :reset_triggered nil) (assert (not (reset-fields-set?))) ;; issue reset request (token & timestamp should be saved) - ((user->client :rasta) :post 200 "session/forgot_password" {:email (:email (user->credentials :rasta))}) + ((user->client :rasta) :post 200 "session/forgot_password" {:email (:username (user->credentials :rasta))}) ;; TODO - how can we test email sent here? (reset-fields-set?))) @@ -99,9 +100,9 @@ (let [token (u/prog1 (str id "_" (java.util.UUID/randomUUID)) (db/update! User id, :reset_token <>)) creds {:old {:password (:old password) - :email email} + :username email} :new {:password (:new password) - :email email}}] + :username email}}] ;; Check that creds work (client :post 200 "session" (:old creds)) @@ -251,3 +252,40 @@ admin-email "rasta@toucans.com"] (u/prog1 (is-session? (google-auth-fetch-or-create-user! "Rasta" "Toucan" "rasta@sf-toucannery.com")) (db/delete! User :email "rasta@sf-toucannery.com")))) ; clean up after ourselves + + +;;; ------------------------------------------------------------ TESTS FOR LDAP AUTH STUFF ------------------------------------------------------------ + +;; Test that we can login with LDAP +(expect-with-ldap-server + true + ;; delete all other sessions for the bird first, otherwise test doesn't seem to work (TODO - why?) + (do (db/simple-delete! Session, :user_id (user->id :rasta)) + (tu/is-uuid-string? (:id (client :post 200 "session" (user->credentials :rasta)))))) + +;; Test that login will fallback to local for users not in LDAP +(expect-with-ldap-server + true + ;; delete all other sessions for the bird first, otherwise test doesn't seem to work (TODO - why?) + (do (db/simple-delete! Session, :user_id (user->id :crowberto)) + (tu/is-uuid-string? (:id (client :post 200 "session" (user->credentials :crowberto)))))) + +;; Test that login will NOT fallback for users in LDAP but with an invalid password +(expect-with-ldap-server + {:errors {:password "did not match stored password"}} + (client :post 400 "session" (user->credentials :lucky))) ; NOTE: there's a different password in LDAP for Lucky + +;; Test that login will fallback to local for broken LDAP settings +;; NOTE: This will ERROR out in the logs, it's normal +(expect-with-ldap-server + true + (tu/with-temporary-setting-values [ldap-user-base "cn=wrong,cn=com"] + ;; delete all other sessions for the bird first, otherwise test doesn't seem to work (TODO - why?) + (do (db/simple-delete! Session, :user_id (user->id :rasta)) + (tu/is-uuid-string? (:id (client :post 200 "session" (user->credentials :rasta))))))) + +;; Test that we can login with LDAP with new user +(expect-with-ldap-server + true + (u/prog1 (tu/is-uuid-string? (:id (client :post 200 "session" {:username "sbrown20", :password "1234"}))) + (db/delete! User :email "sally.brown@metabase.com"))) ; clean up diff --git a/test/metabase/api/table_test.clj b/test/metabase/api/table_test.clj index f323184f32e0ae11017065fa54c944abae4b98cc..8020c4191fc9f93b8299d8f68f5c52a942cdb45d 100644 --- a/test/metabase/api/table_test.clj +++ b/test/metabase/api/table_test.clj @@ -5,16 +5,17 @@ [driver :as driver] [http-client :as http] [middleware :as middleware] - [util :as u] - [sync-database :as sync-database]] + [sync-database :as sync-database] + [util :as u]] [metabase.models - [database :refer [Database]] + [card :refer [Card]] + [database :as database :refer [Database]] [field :refer [Field]] [permissions :as perms] [permissions-group :as perms-group] [table :refer [Table]]] [metabase.test - [data :refer :all] + [data :as data :refer :all] [util :as tu :refer [match-$ resolve-private-vars]]] [metabase.test.data [dataset-definitions :as defs] @@ -456,3 +457,50 @@ :raw_table_id $ :created_at $}))}))}]) ((user->client :rasta) :get 200 (format "table/%d/fks" (id :users)))) + + +;; Make sure metadata for 'virtual' tables comes back as expected from GET /api/table/:id/query_metadata +(tt/expect-with-temp [Card [card {:name "Go Dubs!" + :database_id (data/id) + :dataset_query {:database (data/id) + :type :native + :native {:query (format "SELECT NAME, ID, PRICE, LATITUDE FROM VENUES")}}}]] + (let [card-virtual-table-id (str "card__" (u/get-id card))] + {:display_name "Go Dubs!" + :db_id database/virtual-id + :id card-virtual-table-id + :fields [{:name "NAME" + :display_name "Name" + :base_type "type/Text" + :table_id card-virtual-table-id + :id ["field-literal" "NAME" "type/Text"] + :special_type nil} + {:name "ID" + :display_name "ID" + :base_type "type/Integer" + :table_id card-virtual-table-id + :id ["field-literal" "ID" "type/Integer"] + :special_type nil} + {:name "PRICE" + :display_name "Price" + :base_type "type/Integer" + :table_id card-virtual-table-id + :id ["field-literal" "PRICE" "type/Integer"] + :special_type nil} + {:name "LATITUDE" + :display_name "Latitude" + :base_type "type/Float" + :table_id card-virtual-table-id + :id ["field-literal" "LATITUDE" "type/Float"] + :special_type nil}]}) + (do + ;; run the Card which will populate its result_metadata column + ((user->client :crowberto) :post 200 (format "card/%d/query" (u/get-id card))) + + ;; Now fetch the metadata for this "table" + ((user->client :crowberto) :get 200 (format "table/card__%d/query_metadata" (u/get-id card))))) + +;; make sure GET /api/table/:id/fks just returns nothing for 'virtual' tables +(expect + [] + ((user->client :crowberto) :get 200 "table/card__1000/fks")) diff --git a/test/metabase/api/user_test.clj b/test/metabase/api/user_test.clj index 015c5d0884d08d08c8464f7e197fbd9f0c41601d..8e82e371e6890feb60e58ffeb2798ab0fe10a31c 100644 --- a/test/metabase/api/user_test.clj +++ b/test/metabase/api/user_test.clj @@ -32,7 +32,8 @@ :last_login $ :first_name "Crowberto" :email "crowberto@metabase.com" - :google_auth false}) + :google_auth false + :ldap_auth false}) (match-$ (fetch-user :lucky) {:common_name "Lucky Pigeon" :last_name "Pigeon" @@ -41,7 +42,8 @@ :last_login $ :first_name "Lucky" :email "lucky@metabase.com" - :google_auth false}) + :google_auth false + :ldap_auth false}) (match-$ (fetch-user :rasta) {:common_name "Rasta Toucan" :last_name "Toucan" @@ -50,7 +52,8 @@ :last_login $ :first_name "Rasta" :email "rasta@metabase.com" - :google_auth false})} + :google_auth false + :ldap_auth false})} (do ;; Delete all the other random Users we've created so far (let [user-ids (set (map user->id [:crowberto :rasta :lucky :trashbird]))] @@ -132,6 +135,7 @@ :is_superuser false :is_qbnewb true :google_auth false + :ldap_auth false :id $}) ((user->client :rasta) :get 200 "user/current")) @@ -209,7 +213,7 @@ ;; Test that a User can change their password (superuser and non-superuser) (defn- user-can-reset-password? [superuser?] (tt/with-temp User [user {:password "def", :is_superuser (boolean superuser?)}] - (let [creds {:email (:email user), :password "def"} + (let [creds {:username (:email user), :password "def"} hashed-password (db/select-one-field :password User, :email (:email user))] ;; use API to reset the users password (http/client creds :put 200 (format "user/%d/password" (:id user)) {:password "abc123!!DEF" @@ -247,7 +251,7 @@ :last_name (random-name) :email "def@metabase.com" :password "def123"}] - (let [creds {:email "def@metabase.com" + (let [creds {:username "def@metabase.com" :password "def123"}] [(metabase.http-client/client creds :put 200 (format "user/%d/qbnewb" id)) (db/select-one-field :is_qbnewb User, :id id)]))) diff --git a/test/metabase/driver/bigquery_test.clj b/test/metabase/driver/bigquery_test.clj index 2105db2bae7ed78a1c33deeb1ee1d1c712202b60..571c0160be03c443fffa9e179e0caef036f06b1f 100644 --- a/test/metabase/driver/bigquery_test.clj +++ b/test/metabase/driver/bigquery_test.clj @@ -21,10 +21,10 @@ ;; make sure that BigQuery native queries maintain the column ordering specified in the SQL -- post-processing ordering shouldn't apply (Issue #2821) (expect-with-engine :bigquery - {:columns ["venue_id" "user_id" "checkins_id"] - :cols [{:name "venue_id", :base_type :type/Integer} - {:name "user_id", :base_type :type/Integer} - {:name "checkins_id", :base_type :type/Integer}]} + {:cols [{:name "venue_id", :display_name "Venue ID", :base_type :type/Integer} + {:name "user_id", :display_name "User ID" :base_type :type/Integer} + {:name "checkins_id", :display_name "Checkins ID" :base_type :type/Integer}], + :columns ["venue_id" "user_id" "checkins_id"]} (select-keys (:data (qp/process-query {:native {:query "SELECT [test_data.checkins.venue_id] AS [venue_id], [test_data.checkins.user_id] AS [user_id], [test_data.checkins.id] AS [checkins_id] FROM [test_data.checkins] LIMIT 2"} diff --git a/test/metabase/driver/druid_test.clj b/test/metabase/driver/druid_test.clj index 224150164f2a4c02a627f67553c382dc6ff73844..436ba38d1f8217607468d1eb6e4febb2bb518a8c 100644 --- a/test/metabase/driver/druid_test.clj +++ b/test/metabase/driver/druid_test.clj @@ -1,6 +1,7 @@ (ns metabase.driver.druid-test (:require [cheshire.core :as json] [expectations :refer [expect]] + [medley.core :as m] [metabase [driver :as driver] [query-processor :as qp] @@ -29,9 +30,10 @@ (defn- process-native-query [query] (datasets/with-engine :druid (timeseries-qp-test/with-flattened-dbdef - (qp/process-query {:native {:query query} - :type :native - :database (data/id)})))) + (-> (qp/process-query {:native {:query query} + :type :native + :database (data/id)}) + (m/dissoc-in [:data :results_metadata]))))) ;; test druid native queries (expect-with-engine :druid diff --git a/test/metabase/driver/generic_sql/native_test.clj b/test/metabase/driver/generic_sql/native_test.clj index 1f9445e9cfba364c0d47c3c554ddf74cfb26b833..f466b820b2fd3cdc8544ecd2f306c47b607a9d7c 100644 --- a/test/metabase/driver/generic_sql/native_test.clj +++ b/test/metabase/driver/generic_sql/native_test.clj @@ -15,9 +15,10 @@ :columns ["ID"] :cols [{:name "ID", :display_name "ID", :base_type :type/Integer}] :native_form {:query "SELECT ID FROM VENUES ORDER BY ID DESC LIMIT 2;"}}} - (qp/process-query {:native {:query "SELECT ID FROM VENUES ORDER BY ID DESC LIMIT 2;"} - :type :native - :database (id)})) + (-> (qp/process-query {:native {:query "SELECT ID FROM VENUES ORDER BY ID DESC LIMIT 2;"} + :type :native + :database (id)}) + (m/dissoc-in [:data :results_metadata]))) ;; Check that column ordering is maintained (expect @@ -30,9 +31,10 @@ {:name "NAME", :display_name "Name", :base_type :type/Text} {:name "CATEGORY_ID", :display_name "Category ID", :base_type :type/Integer}] :native_form {:query "SELECT ID, NAME, CATEGORY_ID FROM VENUES ORDER BY ID DESC LIMIT 2;"}}} - (qp/process-query {:native {:query "SELECT ID, NAME, CATEGORY_ID FROM VENUES ORDER BY ID DESC LIMIT 2;"} - :type :native - :database (id)})) + (-> (qp/process-query {:native {:query "SELECT ID, NAME, CATEGORY_ID FROM VENUES ORDER BY ID DESC LIMIT 2;"} + :type :native + :database (id)}) + (m/dissoc-in [:data :results_metadata]))) ;; Check that we get proper error responses for malformed SQL (expect {:status :failed diff --git a/test/metabase/driver/mongo_test.clj b/test/metabase/driver/mongo_test.clj index 102e57808d429e4c6a6adda72cefb6b923af9610..edbce9b4d472c202a26e1a9f123b477a35f8e6b1 100644 --- a/test/metabase/driver/mongo_test.clj +++ b/test/metabase/driver/mongo_test.clj @@ -1,6 +1,7 @@ (ns metabase.driver.mongo-test "Tests for Mongo driver." (:require [expectations :refer :all] + [medley.core :as m] [metabase [driver :as driver] [query-processor :as qp] @@ -80,10 +81,11 @@ :cols [{:name "count", :display_name "Count", :base_type :type/Integer}] :native_form {:collection "venues" :query native-query}}} - (qp/process-query {:native {:query native-query - :collection "venues"} - :type :native - :database (data/id)})) + (-> (qp/process-query {:native {:query native-query + :collection "venues"} + :type :native + :database (data/id)}) + (m/dissoc-in [:data :results_metadata]))) ;; ## Tests for individual syncing functions diff --git a/test/metabase/driver/postgres_test.clj b/test/metabase/driver/postgres_test.clj index 554e9dd363465246b5e6517bc980e6d8e86946ff..ffd9889adbe6b663b560aa3f8de1ca30ed6a65eb 100644 --- a/test/metabase/driver/postgres_test.clj +++ b/test/metabase/driver/postgres_test.clj @@ -120,7 +120,7 @@ [3 "ouija_board"]]} (-> (data/dataset metabase.driver.postgres-test/dots-in-names (data/run-query objects.stuff)) - :data (dissoc :cols :native_form))) + :data (dissoc :cols :native_form :results_metadata))) ;; Make sure that duplicate column names (e.g. caused by using a FK) still return both columns @@ -140,7 +140,7 @@ (-> (data/dataset metabase.driver.postgres-test/duplicate-names (data/run-query people (ql/fields $name $bird_id->birds.name))) - :data (dissoc :cols :native_form))) + :data (dissoc :cols :native_form :results_metadata))) ;;; Check support for `inet` columns diff --git a/test/metabase/events/activity_feed_test.clj b/test/metabase/events/activity_feed_test.clj index 9f243b06e06031e809a01949631846f3f3cb669f..2ae4b9e8ebf15b16553b64045d410a7831c8f531 100644 --- a/test/metabase/events/activity_feed_test.clj +++ b/test/metabase/events/activity_feed_test.clj @@ -9,8 +9,9 @@ [metric :refer [Metric]] [pulse :refer [Pulse]] [segment :refer [Segment]]] - [metabase.test.data :refer :all] + [metabase.test.data :as data :refer :all] [metabase.test.data.users :refer [user->id]] + [metabase.util :as u] [toucan.db :as db] [toucan.util.test :as tt])) @@ -40,6 +41,27 @@ :topic "card-create" :model_id (:id card))))) +;; when I save a Card that uses a NESTED query, is the activity recorded? :D +(expect + {:topic :card-create + :user_id (user->id :rasta) + :model "card" + :database_id (data/id) + :table_id (data/id :venues) + :details {:name "My Cool NESTED Card", :description nil}} + (tt/with-temp* [Card [card-1 {:name "My Cool Card" + :dataset_query {:database (data/id) + :type :query + :query {:source-table (data/id :venues)}}}] + Card [card-2 {:name "My Cool NESTED Card" + :dataset_query {:database metabase.models.database/virtual-id + :type :query + :query {:source-table (str "card__" (u/get-id card-1))}}}]] + (with-temp-activities + (process-activity-event! {:topic :card-create, :item card-2}) + (db/select-one [Activity :topic :user_id :model :database_id :table_id :details] + :topic "card-create" + :model_id (:id card-2))))) ;; `:card-update` event diff --git a/test/metabase/events/revision_test.clj b/test/metabase/events/revision_test.clj index 7fbb6e7633d589194f9b39d5282a291b1fb304a2..85ce227362a4d8e699d39a4e354630c32a251dfa 100644 --- a/test/metabase/events/revision_test.clj +++ b/test/metabase/events/revision_test.clj @@ -44,7 +44,8 @@ :cache_ttl nil :query_type "query" :table_id (id :categories) - :visualization_settings {}}) + :visualization_settings {} + :result_metadata nil}) (defn- dashboard->revision-object [dashboard] {:description nil diff --git a/test/metabase/http_client.clj b/test/metabase/http_client.clj index b16790230d69800d76db42b769cca561eeb139bc..c252648a03c932a2aceeaa5b68bcf61c04cdb91e 100644 --- a/test/metabase/http_client.clj +++ b/test/metabase/http_client.clj @@ -63,14 +63,14 @@ (declare client) (defn authenticate - "Authenticate a test user with EMAIL and PASSWORD, returning their Metabase Session token; + "Authenticate a test user with USERNAME and PASSWORD, returning their Metabase Session token; or throw an Exception if that fails." - [{:keys [email password], :as credentials}] - {:pre [(string? email) (string? password)]} + [{:keys [username password], :as credentials}] + {:pre [(string? username) (string? password)]} (try (:id (client :post 200 "session" credentials)) (catch Throwable e - (log/error "Failed to authenticate with email:" email "and password:" password ":" (.getMessage e))))) + (log/error "Failed to authenticate with username:" username "and password:" password ":" (.getMessage e))))) ;;; client @@ -141,7 +141,7 @@ Args: - * CREDENTIALS Optional map of `:email` and `:password` or `X-METABASE-SESSION` token of a User who we should perform the request as + * CREDENTIALS Optional map of `:username` and `:password` or `X-METABASE-SESSION` token of a User who we should perform the request as * METHOD `:get`, `:post`, `:delete`, or `:put` * EXPECTED-STATUS-CODE When passed, throw an exception if the response has a different status code. * URL Base URL of the request, which will be appended to `*url-prefix*`. e.g. `card/1/favorite` diff --git a/test/metabase/integrations/ldap_test.clj b/test/metabase/integrations/ldap_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..e02e7753728574ce59441bc332199831cbf9fa2a --- /dev/null +++ b/test/metabase/integrations/ldap_test.clj @@ -0,0 +1,96 @@ +(ns metabase.integrations.ldap-test + (:require [expectations :refer :all] + [metabase.integrations.ldap :as ldap] + (metabase.test [util :refer [resolve-private-vars]]) + (metabase.test.integrations [ldap :refer [expect-with-ldap-server get-ldap-port]]))) + +(resolve-private-vars metabase.integrations.ldap escape-value settings->ldap-options get-connection get-user-groups) + + +(defn- get-ldap-details [] + {:host "localhost" + :port (get-ldap-port) + :bind-dn "cn=Directory Manager" + :password "password" + :security "none" + :user-base "dc=metabase,dc=com" + :group-base "dc=metabase,dc=com"}) + +;; See test_resources/ldap.ldif for fixtures + +(expect + "\\2AJohn \\28Dude\\29 Doe\\5C" + (escape-value "*John (Dude) Doe\\")) + +;; The connection test should pass with valid settings +(expect-with-ldap-server + {:status :SUCCESS} + (ldap/test-ldap-connection (get-ldap-details))) + +;; The connection test should fail with an invalid user search base +(expect-with-ldap-server + :ERROR + (:status (ldap/test-ldap-connection (assoc (get-ldap-details) :user-base "dc=example,dc=com")))) + +;; The connection test should fail with an invalid group search base +(expect-with-ldap-server + :ERROR + (:status (ldap/test-ldap-connection (assoc (get-ldap-details) :group-base "dc=example,dc=com")))) + +;; The connection test should fail with an invalid bind DN +(expect-with-ldap-server + :ERROR + (:status (ldap/test-ldap-connection (assoc (get-ldap-details) :bind-dn "cn=Not Directory Manager")))) + +;; The connection test should fail with an invalid bind password +(expect-with-ldap-server + :ERROR + (:status (ldap/test-ldap-connection (assoc (get-ldap-details) :password "wrong")))) + +;; Make sure the basic connection stuff works, this will throw otherwise +(expect-with-ldap-server + nil + (.close (get-connection))) + +;; Login with everything right should succeed +(expect-with-ldap-server + true + (ldap/verify-password "cn=Directory Manager" "password")) + +;; Login with wrong password should fail +(expect-with-ldap-server + false + (ldap/verify-password "cn=Directory Manager" "wrongpassword")) + +;; Login with invalid DN should fail +(expect-with-ldap-server + false + (ldap/verify-password "cn=Nobody,ou=nowhere,dc=metabase,dc=com" "password")) + +;; Login for regular users should also work +(expect-with-ldap-server + true + (ldap/verify-password "cn=Sally Brown,ou=People,dc=metabase,dc=com" "1234")) + +;; Login for regular users should also fail for the wrong password +(expect-with-ldap-server + false + (ldap/verify-password "cn=Sally Brown,ou=People,dc=metabase,dc=com" "password")) + +;; Find by username should work (given the default LDAP filter and test fixtures) +(expect-with-ldap-server + {:dn "cn=John Smith,ou=People,dc=metabase,dc=com" + :first-name "John" + :last-name "Smith" + :email "John.Smith@metabase.com" + :groups ["cn=Accounting,ou=Groups,dc=metabase,dc=com"]} + (ldap/find-user "jsmith1")) + +;; Find by email should also work (also given our default settings and fixtures) +(expect-with-ldap-server + {:dn "cn=John Smith,ou=People,dc=metabase,dc=com" + :first-name "John" + :last-name "Smith" + :email "John.Smith@metabase.com" + :groups ["cn=Accounting,ou=Groups,dc=metabase,dc=com"]} + (ldap/find-user "John.Smith@metabase.com")) diff --git a/test/metabase/middleware_test.clj b/test/metabase/middleware_test.clj index 90175af670f6a37eb34c3cf14472ce4a174e0676..94cf4d396ace2e23988ffac8943f6adae2d92ae7 100644 --- a/test/metabase/middleware_test.clj +++ b/test/metabase/middleware_test.clj @@ -15,7 +15,8 @@ [metabase.test.data.users :refer :all] [ring.mock.request :as mock] [ring.util.response :as resp] - [toucan.db :as db])) + [toucan.db :as db] + [clojure.string :as string])) ;; =========================== TEST wrap-session-id middleware =========================== @@ -217,12 +218,12 @@ _ (Thread/sleep 1000) second-second (async/poll! connection) eventually (apply str (async/<!! (async/into [] connection)))] - [first-second second-second eventually]))))) + [first-second second-second (string/trim eventually)]))))) ;;slow success (expect - [\newline \newline "\n\n\n{\"success\":true}"] + [\newline \newline "{\"success\":true}"] (test-streaming-endpoint streaming-slow-success)) ;; immediate success should have no padding @@ -232,7 +233,7 @@ ;; we know delayed failures (exception thrown) will just drop the connection (expect - [\newline \newline "\n\n\n"] + [\newline \newline ""] (test-streaming-endpoint streaming-slow-failure)) ;; immediate failures (where an exception is thown will return a 500 diff --git a/test/metabase/models/card_test.clj b/test/metabase/models/card_test.clj index 0d7fc226e149a9d3122a0b6293c5e14053d1a114..9dde412f80c1a58365eac44e3a000ba5b47441b3 100644 --- a/test/metabase/models/card_test.clj +++ b/test/metabase/models/card_test.clj @@ -5,6 +5,7 @@ [card :refer :all] [dashboard :refer [Dashboard]] [dashboard-card :refer [DashboardCard]] + [database :as database] [interface :as mi] [permissions :as perms]] [metabase.query-processor.expand :as ql] @@ -120,6 +121,42 @@ (ql/order-by (ql/asc (ql/fk-> (data/id :checkins :venue_id) (data/id :venues :name)))))) :read)) +;; MBQL w/ nested MBQL query +(defn- query-with-source-card [card] + {:database database/virtual-id, :type "query", :query {:source_table (str "card__" (u/get-id card))}}) + +(expect + #{(perms/object-path (data/id) "PUBLIC" (data/id :venues))} + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :query + :query {:source-table (data/id :venues)}}}] + (query-perms-set (query-with-source-card card) :read))) + +;; MBQL w/ nested MBQL query including a JOIN +(expect + #{(perms/object-path (data/id) "PUBLIC" (data/id :checkins)) + (perms/object-path (data/id) "PUBLIC" (data/id :users))} + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :query + :query {:source-table (data/id :checkins) + :order-by [[:asc [:fk-> (data/id :checkins :user_id) (data/id :users :id)]]]}}}] + (query-perms-set (query-with-source-card card) :read))) + +;; MBQL w/ nested NATIVE query +(expect + #{(perms/native-read-path (data/id))} + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :native + :native {:query "SELECT * FROM CHECKINS"}}}] + (query-perms-set (query-with-source-card card) :read))) + +(expect + #{(perms/native-readwrite-path (data/id))} + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :native + :native {:query "SELECT * FROM CHECKINS"}}}] + (query-perms-set (query-with-source-card card) :write))) + ;; invalid/legacy card should return perms for something that doesn't exist so no one gets to see it (expect #{"/db/0/"} diff --git a/test/metabase/query_processor/middleware/annotate_and_sort_test.clj b/test/metabase/query_processor/middleware/annotate_and_sort_test.clj index fabdac0715d2da0e09448d95bb13918431673173..422287b29f4915ee12048befc21da4202ef61c5c 100644 --- a/test/metabase/query_processor/middleware/annotate_and_sort_test.clj +++ b/test/metabase/query_processor/middleware/annotate_and_sort_test.clj @@ -1,5 +1,6 @@ (ns metabase.query-processor.middleware.annotate-and-sort-test (:require [expectations :refer :all] + metabase.query-processor.middleware.annotate-and-sort [metabase.test.util :as tu])) (tu/resolve-private-vars metabase.query-processor.middleware.annotate-and-sort diff --git a/test/metabase/query_processor/middleware/annotate_test.clj b/test/metabase/query_processor/middleware/annotate_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..e847074df8ec0607eeda180161bad6222941380a --- /dev/null +++ b/test/metabase/query_processor/middleware/annotate_test.clj @@ -0,0 +1,105 @@ +(ns metabase.query-processor.middleware.annotate-test + (:require [expectations :refer [expect]] + [metabase.query-processor + [annotate :as annotate] + [interface :as qpi]] + [metabase.util :as u] + [toucan.util.test :as tt])) + +;; make sure when using a source query the right metadata comes back so we are able to do drill-through properly +(expect + [{:field-id [:field-literal "id" :type/Integer] + :field-name "id" + :source :fields} + {:field-id [:field-literal "reciever_id" :type/Integer] + :field-name "reciever_id" + :source :fields} + {:field-id [:field-literal "sender_id" :type/Integer] + :field-name "sender_id" + :source :fields} + {:field-id [:field-literal "text" :type/Text] + :field-name "text" + :source :fields}] + (map + (u/rpartial select-keys [:field-id :field-name :source]) + (annotate/collect-fields + {:source-query {:source-table {:schema "public", :name "messages", :id 1} + :fields-is-implicit true + :fields [(qpi/map->Field + {:field-id 1 + :field-name "id" + :field-display-name "ID" + :base-type :type/Integer + :special-type :type/PK + :table-id 1}) + (qpi/map->Field + {:field-id 2 + :field-name "reciever_id" + :field-display-name "Rec I Ever ID" + :base-type :type/Integer + :special-type :type/FK + :table-id 1}) + (qpi/map->Field + {:field-id 3 + :field-name "sender_id" + :field-display-name "Sender ID" + :base-type :type/Integer + :special-type :type/FK + :table-id 1}) + (qpi/map->Field + {:field-id 3 + :field-name "text" + :field-display-name "Text" + :base-type :type/Text + :special-type :type/Category + :table-id 1})]}}))) + +;; make sure when doing a breakout of a nested query the right metadata comes back (fields are "collected" properly) so things like bar charts work as expected +(expect + [{:field-id [:field-literal "text" :type/Text], :field-name "text", :source :breakout} + {:field-id [:field-literal "id" :type/Integer], :field-name "id", :source :fields} + {:field-id [:field-literal "reciever_id" :type/Integer], :field-name "reciever_id", :source :fields} + {:field-id [:field-literal "sender_id" :type/Integer], :field-name "sender_id", :source :fields} + {:field-id [:field-literal "text" :type/Text], :field-name "text", :source :fields} + {:field-id [:field-literal "text" :type/Text], :field-name "text", :source :order-by} + {:field-id [:field-literal "text" :type/Text], :field-name "text", :source :order-by}] + (map + (u/rpartial select-keys [:field-id :field-name :source]) + (annotate/collect-fields + {:aggregation [{:aggregation-type :count, :custom-name nil}] + :breakout [(qpi/map->FieldLiteral {:field-name "text", :base-type :type/Text, :datetime-unit nil})] + :source-query {:source-table {:schema "public", :name "messages", :id 1} + :fields-is-implicit true + :fields [(qpi/map->Field + {:field-id 1 + :field-name "id" + :base-type :type/Integer + :special-type :type/PK + :table-id 1}) + (qpi/map->Field + {:field-id 2 + :field-name "reciever_id" + :base-type :type/Integer + :special-type :type/FK + :table-id 1}) + (qpi/map->Field + {:field-id 3 + :field-name "sender_id" + :base-type :type/Integer + :special-type :type/FK + :table-id 1}) + (qpi/map->Field + {:field-id 4 + :field-name "text" + :base-type :type/Text + :special-type :type/Category + :table-id 1})] + :order-by [{:field (qpi/map->Field + {:field-id 4 + :field-name "text" + :base-type :type/Text + :special-type :type/Category + :table-id 1}) + :direction :ascending}]} + :order-by [{:field (qpi/map->FieldLiteral {:field-name "text", :base-type :type/Text, :datetime-unit nil}) + :direction :ascending}]}))) diff --git a/test/metabase/query_processor/middleware/fetch_source_query_test.clj b/test/metabase/query_processor/middleware/fetch_source_query_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..c0fc6e9afd8ca39b23a1e0149ef6f1621edf630c --- /dev/null +++ b/test/metabase/query_processor/middleware/fetch_source_query_test.clj @@ -0,0 +1,103 @@ +(ns metabase.query-processor.middleware.fetch-source-query-test + (:require [expectations :refer [expect]] + [medley.core :as m] + [metabase + [query-processor :as qp] + [util :as u]] + [metabase.models + [card :refer [Card]] + [database :as database]] + [metabase.query-processor.middleware.fetch-source-query :as fetch-source-query] + [metabase.test.data :as data] + [toucan.util.test :as tt])) + +(def ^:private ^{:arglists '([query])} fetch-source-query (fetch-source-query/fetch-source-query identity)) + +;; make sure that the `fetch-source-query` middleware correctly resolves MBQL queries +(expect + {:database (data/id) + :type :query + :query {:aggregation [:count] + :breakout [[:field-literal :price :type/Integer]] + :source-query {:source-table (data/id :venues)}}} + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :query + :query {:source-table (data/id :venues)}}}] + (fetch-source-query {:database database/virtual-id + :type :query + :query {:source-table (str "card__" (u/get-id card)) + :aggregation [:count] + :breakout [[:field-literal :price :type/Integer]]}}))) + +;; make sure that the `fetch-source-query` middleware correctly resolves native queries +(expect + {:database (data/id) + :type :query + :query {:aggregation [:count] + :breakout [[:field-literal :price :type/Integer]] + :source-query {:native (format "SELECT * FROM %s" (data/format-name "venues")) + :template_tags nil}}} + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :native + :native {:query (format "SELECT * FROM %s" (data/format-name "venues"))}}}] + (fetch-source-query {:database database/virtual-id + :type :query + :query {:source-table (str "card__" (u/get-id card)) + :aggregation [:count] + :breakout [[:field-literal :price :type/Integer]]}}))) + + +;; test that the `metabase.query-processor/expand` function properly handles nested queries (this function should call `fetch-source-query`) +(expect + {:database {:name "test-data", :id (data/id), :engine :h2} + :type :query + :query {:source-query {:source-table {:schema "PUBLIC", :name "VENUES", :id (data/id :venues)} + :join-tables nil}} + :fk-field-ids #{}} + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :query + :query {:source-table (data/id :venues)}}}] + (-> (qp/expand {:database database/virtual-id + :type :query + :query {:source-table (str "card__" (u/get-id card))}}) + (m/dissoc-in [:database :features]) + (m/dissoc-in [:database :details])))) + +;; make sure that nested nested queries work as expected +(expect + {:database (data/id) + :type :query + :query {:limit 25 + :source-query {:limit 50 + :source-query {:source-table (data/id :venues) + :limit 100}}}} + (tt/with-temp* [Card [card-1 {:dataset_query {:database (data/id) + :type :query + :query {:source-table (data/id :venues), :limit 100}}}] + Card [card-2 {:dataset_query {:database database/virtual-id + :type :query + :query {:source-table (str "card__" (u/get-id card-1)), :limit 50}}}]] + ((fetch-source-query/fetch-source-query identity) {:database database/virtual-id + :type :query + :query {:source-table (str "card__" (u/get-id card-2)), :limit 25}}))) + +(expect + {:database {:name "test-data", :id (data/id), :engine :h2} + :type :query + :query {:limit 25 + :source-query {:limit 50 + :source-query {:source-table {:schema "PUBLIC", :name "VENUES", :id (data/id :venues)} + :limit 100 + :join-tables nil}}} + :fk-field-ids #{}} + (tt/with-temp* [Card [card-1 {:dataset_query {:database (data/id) + :type :query + :query {:source-table (data/id :venues), :limit 100}}}] + Card [card-2 {:dataset_query {:database database/virtual-id + :type :query + :query {:source-table (str "card__" (u/get-id card-1)), :limit 50}}}]] + (-> (qp/expand {:database database/virtual-id + :type :query + :query {:source-table (str "card__" (u/get-id card-2)), :limit 25}}) + (m/dissoc-in [:database :features]) + (m/dissoc-in [:database :details])))) diff --git a/test/metabase/query_processor/middleware/permissions_test.clj b/test/metabase/query_processor/middleware/permissions_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..69eb16e37783b6f79217028df02158a04f320715 --- /dev/null +++ b/test/metabase/query_processor/middleware/permissions_test.clj @@ -0,0 +1,118 @@ +(ns metabase.query-processor.middleware.permissions-test + "Tests for the middleware that checks whether the current user has permissions to run a given query." + (:require [expectations :refer :all] + [metabase.api.common :as api] + [metabase.models + [database :refer [Database]] + [permissions :as perms] + [permissions-group :as perms-group] + [table :refer [Table]] + [user :as user]] + [metabase.query-processor.middleware.permissions :refer [check-query-permissions]] + [metabase.test.data.users :as users] + [metabase.util :as u] + [toucan.util.test :as tt])) + +(def ^:private ^{:arglists '([query]), :style/indent 0} check-perms (check-query-permissions identity)) + +(defn- do-with-rasta + "Call F with rasta as the current user." + [f] + (binding [api/*current-user-id* (users/user->id :rasta) + api/*current-user-permissions-set* (atom (user/permissions-set (users/user->id :rasta)))] + (f))) + +(defn- check-perms-for-rasta + "Check permissions for QUERY with rasta as the current user." + {:style/indent 0} + [query] + (do-with-rasta (fn [] (check-perms query)))) + +;;; ------------------------------------------------------------ Native Queries ------------------------------------------------------------ + +;; Make sure the NATIVE query fails to run if current user doesn't have perms +(expect + Exception + (check-perms-for-rasta + {:database 1000 + :type :native + :native {:query "SELECT * FROM VENUES"}})) + +;; ...but it should work if user has perms +(tt/expect-with-temp [Database [db]] + ;; query should be returned by middleware unchanged + {:database (u/get-id db) + :type :native + :native {:query "SELECT * FROM VENUES"}} + (check-perms-for-rasta + {:database (u/get-id db) + :type :native + :native {:query "SELECT * FROM VENUES"}})) + + +;;; ------------------------------------------------------------ MBQL Queries ------------------------------------------------------------ + +(expect + Exception + (tt/with-temp* [Database [db] + Table [table {:db_id (u/get-id db)}]] + (perms/revoke-permissions! (perms-group/all-users) (u/get-id db)) ; All users get perms for all new DBs by default + (check-perms-for-rasta + {:database (u/get-id db) + :type :query + :query {:source-table {:name "Toucans", :id (u/get-id table)}}}))) + +(tt/expect-with-temp [Database [db] + Table [table {:db_id (u/get-id db)}]] + ;; query should be returned by middleware unchanged + {:database (u/get-id db) + :type :query + :query {:source-table {:name "Toucans", :id (u/get-id table)}}} + (check-perms-for-rasta + {:database (u/get-id db) + :type :query + :query {:source-table {:name "Toucans", :id (u/get-id table)}}})) + + +;;; ------------------------------------------------------------ Nested Native Queries ------------------------------------------------------------ + +(expect + Exception + (check-perms-for-rasta + {:database 1000 + :type :query + :query {:source-query {:native "SELECT * FROM VENUES"}}})) + +;; ...but it should work if user has perms +(tt/expect-with-temp [Database [db]] + {:database (u/get-id db) + :type :query + :query {:source-query {:native "SELECT * FROM VENUES"}}} + (check-perms-for-rasta + {:database (u/get-id db) + :type :query + :query {:source-query {:native "SELECT * FROM VENUES"}}})) + + +;;; ------------------------------------------------------------ Nested MBQL Queries ------------------------------------------------------------ + +;; For nested queries MBQL make sure perms are checked +(expect + Exception + (tt/with-temp* [Database [db] + Table [table {:db_id (u/get-id db)}]] + (perms/revoke-permissions! (perms-group/all-users) (u/get-id db)) ; All users get perms for all new DBs by default + (check-perms-for-rasta + {:database (u/get-id db) + :type :query + :query {:source-query {:source-table {:name "Toucans", :id (u/get-id table)}}}}))) + +(tt/expect-with-temp [Database [db] + Table [table {:db_id (u/get-id db)}]] + {:database (u/get-id db) + :type :query + :query {:source-query {:source-table {:name "Toucans", :id (u/get-id table)}}}} + (check-perms-for-rasta + {:database (u/get-id db) + :type :query + :query {:source-query {:source-table {:name "Toucans", :id (u/get-id table)}}}})) diff --git a/test/metabase/query_processor/middleware/results_metadata_test.clj b/test/metabase/query_processor/middleware/results_metadata_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..de285c7de8a2bc8b29c3e7f360f0314f9c182fe9 --- /dev/null +++ b/test/metabase/query_processor/middleware/results_metadata_test.clj @@ -0,0 +1,104 @@ +(ns metabase.query-processor.middleware.results-metadata-test + (:require [expectations :refer [expect]] + [metabase + [query-processor :as qp] + [util :as u]] + [metabase.models + [card :refer [Card]] + [database :as database]] + [metabase.query-processor.middleware.results-metadata :as results-metadata] + [metabase.test + [data :as data] + [util :as tu]] + [metabase.test.data.users :as users] + [toucan.db :as db] + [toucan.util.test :as tt])) + +(defn- native-query [sql] + {:database (data/id) + :type :native + :native {:query sql}}) + +(defn- card-metadata [card] + (db/select-one-field :result_metadata Card :id (u/get-id card))) + +;; test that Card result metadata is saved after running a Card +(expect + [{:name "ID", :display_name "ID", :base_type "type/Integer"} + {:name "NAME", :display_name "Name", :base_type "type/Text"} + {:name "PRICE", :display_name "Price", :base_type "type/Integer"} + {:name "CATEGORY_ID", :display_name "Category ID", :base_type "type/Integer"} + {:name "LATITUDE", :display_name "Latitude", :base_type "type/Float"} + {:name "LONGITUDE", :display_name "Longitude", :base_type "type/Float"}] + (tt/with-temp Card [card] + (qp/process-query (assoc (native-query "SELECT ID, NAME, PRICE, CATEGORY_ID, LATITUDE, LONGITUDE FROM VENUES") + :info {:card-id (u/get-id card) + :query-hash (byte-array 0)})) + (card-metadata card))) + +;; check that using a Card as your source doesn't overwrite the results metadata... +(expect + {:name "NAME", :display_name "Name", :base_type "type/Text"} + (tt/with-temp Card [card {:dataset_query (native-query "SELECT * FROM VENUES") + :result_metadata {:name "NAME", :display_name "Name", :base_type "type/Text"}}] + (qp/process-query {:database database/virtual-id + :type :query + :query {:source-table (str "card__" (u/get-id card))}}) + (card-metadata card))) + +;; ...even when running via the API endpoint +(expect + {:name "NAME", :display_name "Name", :base_type "type/Text"} + (tt/with-temp Card [card {:dataset_query (native-query "SELECT * FROM VENUES") + :result_metadata {:name "NAME", :display_name "Name", :base_type "type/Text"}}] + ((users/user->client :rasta) :post 200 "dataset" {:database database/virtual-id + :type :query + :query {:source-table (str "card__" (u/get-id card))}}) + (card-metadata card))) + + +;; tests for valid-checksum? +(tu/resolve-private-vars metabase.query-processor.middleware.results-metadata metadata-checksum) + +(expect + (results-metadata/valid-checksum? "ABCDE" (metadata-checksum "ABCDE"))) + +(expect + false + (results-metadata/valid-checksum? "ABCD" (metadata-checksum "ABCDE"))) + + +;; make sure that queries come back with metadata +(expect + {:checksum java.lang.String + :columns [{:base_type :type/Integer, :display_name "ID", :name "ID"} + {:base_type :type/Text, :display_name "Name", :name "NAME"} + {:base_type :type/Integer, :display_name "Price", :name "PRICE"} + {:base_type :type/Integer, :display_name "Category ID", :name "CATEGORY_ID"} + {:base_type :type/Float, :display_name "Latitude", :name "LATITUDE"} + {:base_type :type/Float, :display_name "Longitude", :name "LONGITUDE"}]} + (-> (qp/process-query {:database (data/id) + :type :native + :native {:query (format "SELECT ID, NAME, PRICE, CATEGORY_ID, LATITUDE, LONGITUDE FROM VENUES")}}) + (get-in [:data :results_metadata]) + (update :checksum class))) + +;; make sure that a Card where a DateTime column is broken out by year advertises that column as Text, since you can't do datetime breakouts on years +(expect + [{:base_type "type/Text" + :display_name "Date" + :name "DATE" + :unit nil} + {:base_type "type/Integer" + :display_name "count" + :name "count" + :special_type "type/Number"}] + (tt/with-temp Card [card] + (qp/process-query {:database (data/id) + :type :query + :query {:source-table (data/id :checkins) + :aggregation [[:count]] + :breakout [[:datetime-field [:field-id (data/id :checkins :date)] :year]]} + :info {:card-id (u/get-id card) + :query-hash (byte-array 0)}}) + (card-metadata card))) diff --git a/test/metabase/query_processor_test.clj b/test/metabase/query_processor_test.clj index 253833859bea56d8861a10f544471d7a92d653ab..f2e0f3f162ff749d26a91b8527f6f1b8768fd32e 100644 --- a/test/metabase/query_processor_test.clj +++ b/test/metabase/query_processor_test.clj @@ -8,7 +8,8 @@ [driver :as driver] [util :as u]] [metabase.test.data :as data] - [metabase.test.data.datasets :as datasets])) + [metabase.test.data.datasets :as datasets] + [medley.core :as m])) ;; make sure all the driver test extension namespaces are loaded <3 ;; if this isn't done some things will get loaded at the wrong time which can end up causing test databases to be created more than once, which fails @@ -256,10 +257,14 @@ (defn breakout-col [column] (assoc column :source :breakout)) +;; TODO - maybe this needs a new name now that it also removes the results_metadata (defn booleanize-native-form - "Convert `:native_form` attribute to a boolean to make test results comparisons easier." + "Convert `:native_form` attribute to a boolean to make test results comparisons easier. + Remove `data.results_metadata` as well since it just takes a lot of space and the checksum can vary based on whether encryption is enabled." [m] - (update-in m [:data :native_form] boolean)) + (-> m + (update-in [:data :native_form] boolean) + (m/dissoc-in [:data :results_metadata]))) (defn format-rows-by "Format the values in result ROWS with the fns at the corresponding indecies in FORMAT-FNS. diff --git a/test/metabase/query_processor_test/date_bucketing_test.clj b/test/metabase/query_processor_test/date_bucketing_test.clj index 540aafc99ca38ae5c29f333b827e26207585cced..02f65fa184a3aaadab637b41d689f18f0e3885eb 100644 --- a/test/metabase/query_processor_test/date_bucketing_test.clj +++ b/test/metabase/query_processor_test/date_bucketing_test.clj @@ -304,6 +304,7 @@ ;; Don't run the minute tests against Oracle because the Oracle tests are kind of slow and case CI to fail randomly when it takes so long to load the data that the times are ;; no longer current (these tests pass locally if your machine isn't as slow as the CircleCI ones) (expect-with-non-timeseries-dbs-except #{:bigquery :oracle} 4 (count-of-grouping (checkins:4-per-minute) :minute "current")) + (expect-with-non-timeseries-dbs-except #{:bigquery :oracle} 4 (count-of-grouping (checkins:4-per-minute) :minute -1 "minute")) (expect-with-non-timeseries-dbs-except #{:bigquery :oracle} 4 (count-of-grouping (checkins:4-per-minute) :minute 1 "minute")) diff --git a/test/metabase/query_processor_test/nested_queries_test.clj b/test/metabase/query_processor_test/nested_queries_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..caec8a62d8526eeeae0195a0cb41c56765f04e46 --- /dev/null +++ b/test/metabase/query_processor_test/nested_queries_test.clj @@ -0,0 +1,418 @@ +(ns metabase.query-processor-test.nested-queries-test + "Tests for handling queries with nested expressions." + (:require [clojure.string :as str] + [expectations :refer [expect]] + [honeysql.core :as hsql] + [metabase + [query-processor :as qp] + [query-processor-test :refer :all] + [util :as u]] + [metabase.models + [card :refer [Card]] + [database :as database] + [field :refer [Field]] + [table :refer [Table]]] + [metabase.test.data :as data] + [metabase.test.data.datasets :as datasets] + [toucan.db :as db] + [toucan.util.test :as tt])) + +(defn- rows+cols + "Return the `:rows` and relevant parts of `:cols` from the RESULTS. + (This is used to keep the output of various tests below focused and manageable.)" + {:style/indent 0} + [results] + {:rows (rows results) + :cols (for [col (get-in results [:data :cols])] + {:name (str/lower-case (:name col)) + :base_type (:base_type col)})}) + + +;; make sure we can do a basic query with MBQL source-query +(datasets/expect-with-engines (engines-that-support :nested-queries) + {:rows [[1 "Red Medicine" 4 10.0646 -165.374 3] + [2 "Stout Burgers & Beers" 11 34.0996 -118.329 2] + [3 "The Apple Pan" 11 34.0406 -118.428 2] + [4 "Wurstküche" 29 33.9997 -118.465 2] + [5 "Brite Spot Family Restaurant" 20 34.0778 -118.261 2]] + :cols [{:name "id", :base_type (data/id-field-type)} + {:name "name", :base_type :type/Text} + {:name "category_id", :base_type :type/Integer} + {:name "latitude", :base_type :type/Float} + {:name "longitude", :base_type :type/Float} + {:name "price", :base_type :type/Integer}]} + (rows+cols + (qp/process-query + {:database (data/id) + :type :query + :query {:source-query {:source-table (data/id :venues) + :order-by [:asc (data/id :venues :id)] + :limit 10} + :limit 5}}))) + +(defn- identifier + "Return a properly formatted identifier for a Table or Field. + (This handles DBs like H2 who require uppercase identifiers, or databases like Redshift do clever hacks + like prefixing table names with a unique schema for each test run because we're not + allowed to create new databases.)" + ([table-kw] + (let [{schema :schema, table-name :name} (db/select-one [Table :name :schema] :id (data/id table-kw))] + (name (hsql/qualify schema table-name)))) + ([table-kw field-kw] + (db/select-one-field :name Field :id (data/id table-kw field-kw)))) + +;; make sure we can do a basic query with a SQL source-query +(datasets/expect-with-engines (engines-that-support :nested-queries) + {:rows [[1 -165.374 4 3 "Red Medicine" 10.0646] + [2 -118.329 11 2 "Stout Burgers & Beers" 34.0996] + [3 -118.428 11 2 "The Apple Pan" 34.0406] + [4 -118.465 29 2 "Wurstküche" 33.9997] + [5 -118.261 20 2 "Brite Spot Family Restaurant" 34.0778]] + :cols [{:name "id", :base_type :type/Integer} + {:name "longitude", :base_type :type/Float} + {:name "category_id", :base_type :type/Integer} + {:name "price", :base_type :type/Integer} + {:name "name", :base_type :type/Text} + {:name "latitude", :base_type :type/Float}]} + (rows+cols + (qp/process-query + {:database (data/id) + :type :query + :query {:source-query {:native (format "SELECT %s, %s, %s, %s, %s, %s FROM %s" + (identifier :venues :id) + (identifier :venues :longitude) + (identifier :venues :category_id) + (identifier :venues :price) + (identifier :venues :name) + (identifier :venues :latitude) + (identifier :venues))} + :order-by [:asc [:field-literal (keyword (data/format-name :id)) :type/Integer]] + :limit 5}}))) + +(def ^:private ^:const breakout-results + {:rows [[1 22] + [2 59] + [3 13] + [4 6]] + :cols [{:name "price", :base_type :type/Integer} + {:name "count", :base_type :type/Integer}]}) + +;; make sure we can do a query with breakout and aggregation using an MBQL source query +(datasets/expect-with-engines (engines-that-support :nested-queries) + breakout-results + (rows+cols + (format-rows-by [int int] + (qp/process-query + {:database (data/id) + :type :query + :query {:source-query {:source-table (data/id :venues)} + :aggregation [:count] + :breakout [[:field-literal (keyword (data/format-name :price)) :type/Integer]]}})))) + +;; make sure we can do a query with breakout and aggregation using a SQL source query +(datasets/expect-with-engines (engines-that-support :nested-queries) + breakout-results + (rows+cols + (format-rows-by [int int] + (qp/process-query + {:database (data/id) + :type :query + :query {:source-query {:native (format "SELECT * FROM %s" (identifier :venues))} + :aggregation [:count] + :breakout [[:field-literal (keyword (data/format-name :price)) :type/Integer]]}})))) + + +(defn- mbql-card-def + "Basic MBQL Card definition. Pass kv-pair clauses for the inner query." + {:style/indent 0} + [& {:as clauses}] + {:dataset_query {:database (data/id) + :type :query + :query clauses}}) + +(defn- venues-mbql-card-def + "A basic Card definition that returns raw data for the venues test table. + Pass additional kv-pair clauses for the inner query as needed." + {:style/indent 0} + [& additional-clauses] + (apply mbql-card-def :source-table (data/id :venues) additional-clauses)) + + +(defn- query-with-source-card {:style/indent 1} [card & {:as additional-clauses}] + {:database database/virtual-id + :type :query + :query (merge {:source-table (str "card__" (u/get-id card))} + additional-clauses)}) + +;; Make sure we can run queries using source table `card__id` format. This is the format that is actually used by the frontend; +;; it gets translated to the normal `source-query` format by middleware. It's provided as a convenience so only minimal changes +;; need to be made to the frontend. +(expect + breakout-results + (tt/with-temp Card [card (venues-mbql-card-def)] + (rows+cols + (format-rows-by [int int] + (qp/process-query + (query-with-source-card card + :aggregation [:count] + :breakout [[:field-literal (keyword (data/format-name :price)) :type/Integer]])))))) + +;; make sure `card__id`-style queries work with native source queries as well +(expect + breakout-results + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :native + :native {:query "SELECT * FROM VENUES"}}}] + (rows+cols + (format-rows-by [int int] + (qp/process-query + (query-with-source-card card + :aggregation [:count] + :breakout [[:field-literal (keyword (data/format-name :price)) :type/Integer]])))))) + + +;; make sure we can filter by a field literal +(expect + {:rows [[1 "Red Medicine" 4 10.0646 -165.374 3]] + :cols [{:name "id", :base_type :type/BigInteger} + {:name "name", :base_type :type/Text} + {:name "category_id", :base_type :type/Integer} + {:name "latitude", :base_type :type/Float} + {:name "longitude", :base_type :type/Float} + {:name "price", :base_type :type/Integer}]} + (rows+cols + (qp/process-query + {:database (data/id) + :type :query + :query {:source-query {:source-table (data/id :venues)} + :filter [:= [:field-literal (data/format-name :id) :type/Integer] 1]}}))) + +(def ^:private ^:const ^String venues-source-sql + (str "(SELECT \"PUBLIC\".\"VENUES\".\"ID\" AS \"ID\", \"PUBLIC\".\"VENUES\".\"NAME\" AS \"NAME\", " + "\"PUBLIC\".\"VENUES\".\"CATEGORY_ID\" AS \"CATEGORY_ID\", \"PUBLIC\".\"VENUES\".\"LATITUDE\" AS \"LATITUDE\", " + "\"PUBLIC\".\"VENUES\".\"LONGITUDE\" AS \"LONGITUDE\", \"PUBLIC\".\"VENUES\".\"PRICE\" AS \"PRICE\" FROM \"PUBLIC\".\"VENUES\") \"source\"")) + +;; make sure that dots in field literal identifiers get escaped so you can't reference fields from other tables using them +(expect + {:query (format "SELECT * FROM %s WHERE \"BIRD.ID\" = 1 LIMIT 10" venues-source-sql) + :params nil} + (qp/query->native + {:database (data/id) + :type :query + :query {:source-query {:source-table (data/id :venues)} + :filter [:= [:field-literal :BIRD.ID :type/Integer] 1] + :limit 10}})) + +;; make sure that field-literals work as DateTimeFields +(expect + {:query (str "SELECT * " + (format "FROM %s " venues-source-sql) + "WHERE parsedatetime(formatdatetime(\"BIRD.ID\", 'YYYYww'), 'YYYYww') = parsedatetime(formatdatetime(?, 'YYYYww'), 'YYYYww') " + "LIMIT 10") + :params [#inst "2017-01-01T00:00:00.000000000-00:00"]} + (qp/query->native + {:database (data/id) + :type :query + :query {:source-query {:source-table (data/id :venues)} + :filter [:= [:datetime-field [:field-literal :BIRD.ID :type/DateTime] :week] "2017-01-01"] + :limit 10}})) + +;; make sure that aggregation references match up to aggregations from the same level they're from +;; e.g. the ORDER BY in the source-query should refer the 'stddev' aggregation, NOT the 'avg' aggregation +(expect + {:query (str "SELECT avg(\"stddev\") AS \"avg\" FROM (" + "SELECT STDDEV(\"PUBLIC\".\"VENUES\".\"ID\") AS \"stddev\", \"PUBLIC\".\"VENUES\".\"PRICE\" AS \"PRICE\" " + "FROM \"PUBLIC\".\"VENUES\" " + "GROUP BY \"PUBLIC\".\"VENUES\".\"PRICE\" " + "ORDER BY \"stddev\" DESC, \"PUBLIC\".\"VENUES\".\"PRICE\" ASC" + ") \"source\"") + :params nil} + (qp/query->native + {:database (data/id) + :type :query + :query {:source-query {:source-table (data/id :venues) + :aggregation [[:stddev [:field-id (data/id :venues :id)]]] + :breakout [[:field-id (data/id :venues :price)]] + :order-by [[[:aggregate-field 0] :descending]]} + :aggregation [[:avg [:field-literal "stddev" :type/Integer]]]}})) + +;; make sure that we handle [field-id [field-literal ...]] forms gracefully, despite that not making any sense +(expect + {:query (format "SELECT \"category_id\" AS \"category_id\" FROM %s GROUP BY \"category_id\" ORDER BY \"category_id\" ASC LIMIT 10" venues-source-sql) + :params nil} + (qp/query->native + {:database (data/id) + :type :query + :query {:source-query {:source-table (data/id :venues)} + :breakout [:field-id [:field-literal "category_id" :type/Integer]] + :limit 10}})) + +;; Make sure we can filter by string fields +(expect + {:query (format "SELECT * FROM %s WHERE \"text\" <> ? LIMIT 10" venues-source-sql) + :params ["Coo"]} + (qp/query->native {:database (data/id) + :type :query + :query {:source-query {:source-table (data/id :venues)} + :limit 10 + :filter [:!= [:field-literal "text" :type/Text] "Coo"]}})) + +;; Make sure we can filter by number fields +(expect + {:query (format "SELECT * FROM %s WHERE \"sender_id\" > 3 LIMIT 10" venues-source-sql) + :params nil} + (qp/query->native {:database (data/id) + :type :query + :query {:source-query {:source-table (data/id :venues)} + :limit 10 + :filter [:> [:field-literal "sender_id" :type/Integer] 3]}})) + +;; make sure using a native query with default params as a source works +(expect + {:query "SELECT * FROM (SELECT * FROM PRODUCTS WHERE CATEGORY = 'Widget' LIMIT 10) \"source\" LIMIT 1048576", + :params nil} + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :native + :native {:query "SELECT * FROM PRODUCTS WHERE CATEGORY = {{category}} LIMIT 10" + :template_tags {:category {:name "category" + :display_name "Category" + :type "text" + :required true + :default "Widget"}}}}}] + (qp/query->native + {:database (data/id) + :type :query + :query {:source-table (str "card__" (u/get-id card))}}))) + +(defn results-metadata {:style/indent 0} [results] + (for [col (get-in results [:data :cols])] + (u/select-non-nil-keys col [:base_type :display_name :id :name :source :special_type :table_id :unit :datetime-unit]))) + +;; make sure a query using a source query comes back with the correct columns metadata +(expect + [{:base_type :type/BigInteger + :display_name "ID" + :id [:field-literal "ID" :type/BigInteger] + :name "ID" + :source :fields + :special_type :type/PK + :table_id (data/id :venues)} + {:base_type :type/Text + :display_name "Name" + :id [:field-literal "NAME" :type/Text] + :name "NAME" + :source :fields + :special_type :type/Name + :table_id (data/id :venues)} + {:base_type :type/Integer + :display_name "Category ID" + :id [:field-literal "CATEGORY_ID" :type/Integer] + :name "CATEGORY_ID" + :source :fields + :special_type :type/FK + :table_id (data/id :venues)} + {:base_type :type/Float + :display_name "Latitude" + :id [:field-literal "LATITUDE" :type/Float] + :name "LATITUDE" + :source :fields + :special_type :type/Latitude + :table_id (data/id :venues)} + {:base_type :type/Float + :display_name "Longitude" + :id [:field-literal "LONGITUDE" :type/Float] + :name "LONGITUDE" + :source :fields + :special_type :type/Longitude + :table_id (data/id :venues)} + {:base_type :type/Integer + :display_name "Price" + :id [:field-literal "PRICE" :type/Integer] + :name "PRICE" + :source :fields + :special_type :type/Category + :table_id (data/id :venues)}] + (-> (tt/with-temp Card [card (venues-mbql-card-def)] + (qp/process-query (query-with-source-card card))) + results-metadata)) + +;; make sure a breakout/aggregate query using a source query comes back with the correct columns metadata +(expect + [{:base_type :type/Text + :id [:field-literal "PRICE" :type/Text] + :name "PRICE" + :display_name "Price" + :source :breakout} + {:base_type :type/Integer + :display_name "count" + :name "count" + :source :aggregation + :special_type :type/Number}] + (-> (tt/with-temp Card [card (venues-mbql-card-def)] + (qp/process-query (query-with-source-card card + :aggregation [[:count]] + :breakout [[:field-literal "PRICE" :type/Text]]))) + results-metadata)) + +;; make sure nested queries return the right columns metadata for SQL source queries and datetime breakouts +(expect + [{:base_type :type/DateTime + :display_name "Date" + :id [:field-literal "DATE" :type/DateTime] + :name "DATE" + :source :breakout + :unit :day} + {:base_type :type/Integer + :display_name "count" + :name "count" + :source :aggregation + :special_type :type/Number}] + (-> (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :native + :native {:query "SELECT * FROM CHECKINS"}}}] + (qp/process-query (query-with-source-card card + :aggregation [[:count]] + :breakout [[:datetime-field [:field-literal "DATE" :type/DateTime] :day]]))) + results-metadata)) + +;; make sure when doing a nested query we give you metadata that would suggest you should be able to break out a *YEAR* +(expect + [{:base_type :type/Text + :display_name "Date" + :id [:field-literal "DATE" :type/Text] + :name "DATE" + :source :breakout + :table_id (data/id :checkins)} + {:base_type :type/Integer + :display_name "Count" + :id [:field-literal :count :type/Integer] + :name "count" + :source :fields}] + (-> (tt/with-temp Card [card (mbql-card-def + :source-table (data/id :checkins) + :aggregation [[:count]] + :breakout [[:datetime-field [:field-id (data/id :checkins :date)] :year]])] + (qp/process-query (query-with-source-card card))) + results-metadata)) + +;; make sure using a time interval filter works +(datasets/expect-with-engines (engines-that-support :nested-queries) + :completed + (tt/with-temp Card [card (mbql-card-def + :source-table (data/id :checkins))] + (-> (query-with-source-card card + :filter [:time-interval [:field-literal (identifier :checkins :date) :type/DateTime] -30 :day]) + qp/process-query + :status))) + +;; make sure that wrapping a field literal in a datetime-field clause works correctly in filters & breakouts +(datasets/expect-with-engines (engines-that-support :nested-queries) + :completed + (tt/with-temp Card [card (mbql-card-def + :source-table (data/id :checkins))] + (-> (query-with-source-card card + :aggregation [[:count]] + :filter [:= [:datetime-field [:field-literal (identifier :checkins :date) :type/Date] :quarter] "2014-01-01T08:00:00.000Z"] + :breakout [[:datetime-field [:field-literal (identifier :checkins :date) :type/Date] :month]]) + qp/process-query + :status))) diff --git a/test/metabase/test/data/users.clj b/test/metabase/test/data/users.clj index a1415e76d1298cdd4ff7b1e0eafd169f4de90d9d..b3a9f0a659954c2cf113f0f50dc74fa27f753838 100644 --- a/test/metabase/test/data/users.clj +++ b/test/metabase/test/data/users.clj @@ -107,12 +107,14 @@ (:id (fetch-user username))))) (defn user->credentials - "Return a map with `:email` and `:password` for User with USERNAME. + "Return a map with `:username` and `:password` for User with USERNAME. - (user->credentials :rasta) -> {:email \"rasta@metabase.com\", :password \"blueberries\"}" + (user->credentials :rasta) -> {:username \"rasta@metabase.com\", :password \"blueberries\"}" [username] {:pre [(contains? usernames username)]} - (select-keys (user->info username) [:email :password])) + (let [{:keys [email password]} (user->info username)] + {:username email + :password password})) (def ^{:arglists '([id])} id->user "Reverse of `user->id`. diff --git a/test/metabase/test/integrations/ldap.clj b/test/metabase/test/integrations/ldap.clj new file mode 100644 index 0000000000000000000000000000000000000000..2cb0e54270fa181ac4f643bc1b124341fe028580 --- /dev/null +++ b/test/metabase/test/integrations/ldap.clj @@ -0,0 +1,55 @@ +(ns metabase.test.integrations.ldap + (:require [clojure.java.io :as io] + [expectations :refer [expect]] + [metabase.test.util :as tu]) + (:import (com.unboundid.ldap.listener InMemoryDirectoryServer InMemoryDirectoryServerConfig InMemoryListenerConfig) + com.unboundid.ldap.sdk.schema.Schema + com.unboundid.ldif.LDIFReader)) + + +(def ^:dynamic *ldap-server* + "An in-memory LDAP testing server." + nil) + +(defn- get-server-config [] + (doto (InMemoryDirectoryServerConfig. (into-array String ["dc=metabase,dc=com"])) + (.addAdditionalBindCredentials "cn=Directory Manager" "password") + (.setSchema (Schema/getDefaultStandardSchema)) + (.setListenerConfigs (into-array InMemoryListenerConfig [(InMemoryListenerConfig/createLDAPConfig "LDAP" 0)])))) + +(defn- start-ldap-server! [] + (with-open [ldif (LDIFReader. (io/file (io/resource "ldap.ldif")))] + (doto (InMemoryDirectoryServer. (get-server-config)) + (.importFromLDIF true ldif) + (.startListening)))) + +(defn get-ldap-port + "Get the port for the bound in-memory LDAP testing server." + [] + (.getListenPort *ldap-server*)) + +(defn do-with-ldap-server + "Bind `*ldap-server*` and the relevant settings to an in-memory LDAP testing server and executes `f`." + [f] + (binding [*ldap-server* (start-ldap-server!)] + (try + (tu/with-temporary-setting-values [ldap-enabled true + ldap-host "localhost" + ldap-port (str (get-ldap-port)) + ldap-bind-dn "cn=Directory Manager" + ldap-password "password" + ldap-user-base "dc=metabase,dc=com" + ldap-group-sync true + ldap-group-base "dc=metabase,dc=com"] + (f)) + (finally (.shutDown *ldap-server* true))))) + +(defmacro with-ldap-server + "Bind `*ldap-server*` and the relevant settings to an in-memory LDAP testing server and executes BODY." + [& body] + `(do-with-ldap-server (fn [] ~@body))) + +(defmacro expect-with-ldap-server + "Generate a unit test that runs ACTUAL with a bound `*ldap-server*` and relevant settings." + [expected actual] + `(expect ~expected (with-ldap-server ~actual))) diff --git a/test_resources/ldap.ldif b/test_resources/ldap.ldif new file mode 100644 index 0000000000000000000000000000000000000000..9d86c6f417da87d19368da755fdc2b3f303445a0 --- /dev/null +++ b/test_resources/ldap.ldif @@ -0,0 +1,76 @@ +dn: dc=metabase,dc=com +objectClass: top +objectClass: organization +objectClass: dcObject +dc: metabase +o: Metabase Corporation + +dn: ou=People,dc=metabase,dc=com +objectClass: top +objectClass: organizationalUnit +ou: People + +dn: cn=John Smith,ou=People,dc=metabase,dc=com +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +cn: John Smith +sn: Smith +givenName: John +uid: jsmith1 +mail: John.Smith@metabase.com +userPassword: strongpassword + +dn: cn=Sally Brown,ou=People,dc=metabase,dc=com +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +cn: Sally Brown +sn: Brown +givenName: Sally +uid: sbrown20 +mail: sally.brown@metabase.com +userPassword: 1234 + +dn: ou=Birds,dc=metabase,dc=com +objectClass: top +objectClass: organizationalUnit +ou: Birds + +dn: cn=Rasta Toucan,ou=Birds,dc=metabase,dc=com +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +cn: Rasta Toucan +givenName: Rasta +sn: Toucan +uid: rasta +mail: rasta@metabase.com +userPassword: blueberries + +dn: cn=Lucky Pigeon,ou=Birds,dc=metabase,dc=com +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +cn: Lucky Pigeon +givenName: Lucky +sn: Pigeon +uid: lucky +mail: lucky@metabase.com +userPassword: notalmonds + +dn: ou=Groups,dc=metabase,dc=com +objectClass: top +objectClass: organizationalUnit +ou: Groups + +dn: cn=Accounting,ou=Groups,dc=metabase,dc=com +objectClass: top +objectClass: groupOfNames +cn: Accounting +member: cn=John Smith,ou=People,dc=metabase,dc=com +member: cn=Rasta Toucan,ou=Birds,dc=metabase,dc=com diff --git a/yarn.lock b/yarn.lock index 9cd8e1fdcf74a1fdd24d1605c851208e7361b22f..1127442bfe07f31a67e3b8e948620b45da7dd592 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6834,13 +6834,6 @@ react-addons-shallow-compare@^15.2.1: fbjs "^0.8.4" object-assign "^4.1.0" -react-addons-test-utils@^15.4.2: - version "15.5.1" - resolved "https://registry.yarnpkg.com/react-addons-test-utils/-/react-addons-test-utils-15.5.1.tgz#e0d258cda2a122ad0dff69f838260d0c3958f5f7" - dependencies: - fbjs "^0.8.4" - object-assign "^4.1.0" - react-ansi-style@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/react-ansi-style/-/react-ansi-style-1.0.0.tgz#9b1d4c8eedfc56f4dcf359a3962184658c3e8c09"