diff --git a/.circleci/config.yml b/.circleci/config.yml index 59b9c85b047709719c4381f832caecb4d185de08..6f4181a5efa6743ef6fae03aefd8d7f9244c95c7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -170,6 +170,11 @@ executors: ACCEPT_EULA: Y SA_PASSWORD: 'P@ssw0rd' + fe-mongo: + working_directory: /home/circleci/metabase/metabase/ + docker: + - image: circleci/clojure:lein-2.8.1-node-browsers + - image: circleci/mongo:4.0 ######################################################################################################################## @@ -279,6 +284,13 @@ jobs: done; echo `md5sum yarn.lock` >> frontend-checksums.txt echo `md5sum webpack.config.js` >> frontend-checksums.txt + # As well as driver modules (database drivers) + - run: + name: Generate checksums of all driver module source files to use as Uberjar cache key + command: > + for file in `find ./modules -type f | sort`; + do echo `md5sum $file` >> modules-checksums.txt; + done; - run: name: Save last git commit message command: git log -1 --oneline > commit.txt @@ -530,18 +542,18 @@ jobs: - restore-be-deps-cache - restore_cache: keys: - - uberjar-{{ checksum "./backend-checksums.txt" }}-{{ checksum "./frontend-checksums.txt" }} + - uberjar-{{ checksum "./backend-checksums.txt" }}-{{ checksum "./frontend-checksums.txt" }}-{{ checksum "./modules-checksums.txt" }} - run: name: Build uberjar if needed command: > if [ ! -f './target/uberjar/metabase.jar' ]; - then ./bin/build version frontend uberjar; + then ./bin/build version frontend drivers uberjar; fi no_output_timeout: 5m - store_artifacts: path: /home/circleci/metabase/metabase/target/uberjar/metabase.jar - save_cache: - key: uberjar-{{ checksum "./backend-checksums.txt" }}-{{ checksum "./frontend-checksums.txt" }} + key: uberjar-{{ checksum "./backend-checksums.txt" }}-{{ checksum "./frontend-checksums.txt" }}-{{ checksum "./modules-checksums.txt" }} paths: - /home/circleci/metabase/metabase/target/uberjar/metabase.jar @@ -560,11 +572,24 @@ jobs: command: ./bin/build version fe-tests-cypress: - executor: clojure-and-node + parameters: + e: + type: executor + default: clojure-and-node + only-single-database: + type: boolean + default: false + test-files-location: + type: string + default: "" + driver: + type: string + default: "" + executor: << parameters.e >> steps: - run-yarn-command: command-name: Run Cypress tests - command: run test-cypress-no-build + command: run test-cypress-no-build <<# parameters.only-single-database >> --testFiles << parameters.test-files-location >> <</ parameters.only-single-database >> before-steps: - restore_cache: keys: @@ -852,6 +877,16 @@ workflows: - build-uberjar - fe-deps + - fe-tests-cypress: + name: fe-tests-cypress-mongo + requires: + - build-uberjar + - fe-deps + e: fe-mongo + driver: mongo + only-single-database: true + test-files-location: frontend/test/metabase-db/mongo + - deploy-master: requires: - yaml-linter diff --git a/backend/mbql/src/metabase/mbql/normalize.clj b/backend/mbql/src/metabase/mbql/normalize.clj index 841b32da7a432aaeab86a89dc32cf1e5922c534c..19630c088e9438722e646ee8b5aa2a967789f5ac 100644 --- a/backend/mbql/src/metabase/mbql/normalize.clj +++ b/backend/mbql/src/metabase/mbql/normalize.clj @@ -231,6 +231,13 @@ (update :special_type keyword) (update :fingerprint walk/keywordize-keys))) +(defn- normalize-native-query + "For native queries, normalize the top-level keys, and template tags, but nothing else." + [native-query] + (let [native-query (m/map-keys mbql.u/normalize-token native-query)] + (cond-> native-query + (seq (:template-tags native-query)) (update :template-tags normalize-template-tags)))) + ;; TODO - why not make this a multimethod of some sort? (def ^:private path->special-token-normalization-fn "Map of special functions that should be used to perform token normalization for a given path. For example, the @@ -238,9 +245,7 @@ defined below." {:type mbql.u/normalize-token ;; don't normalize native queries - :native {:query identity - :template-tags normalize-template-tags - :params identity} + :native normalize-native-query :query {:aggregation normalize-ag-clause-tokens :expressions normalize-expressions-tokens :order-by normalize-order-by-tokens diff --git a/backend/mbql/test/metabase/mbql/normalize_test.clj b/backend/mbql/test/metabase/mbql/normalize_test.clj index ac2f8697701270d67c34b4e8858e14f1f90f5080..1da4454005cb866d828317dc56e571f6ac5b12f0 100644 --- a/backend/mbql/test/metabase/mbql/normalize_test.clj +++ b/backend/mbql/test/metabase/mbql/normalize_test.clj @@ -428,6 +428,14 @@ :params ["Red Medicine"]}})) ":native :params shouldn't get normalized.")) +(deftest normalize-projections-test + (testing "Native :projections shouldn't get normalized." + (is (= {:type :native + :native {:projections ["_id" "name" "category_id" "latitude" "longitude" "price"]}} + (#'normalize/normalize-tokens + {:type :native + :native {:projections ["_id" "name" "category_id" "latitude" "longitude" "price"]}}))))) + ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | CANONICALIZE | diff --git a/bin/version b/bin/version index ec26e64ca4640c1208962d96a1bc0d11525c5f12..5a79a4f74bed66e715dca014a10a5520f4f4b047 100755 --- a/bin/version +++ b/bin/version @@ -1,6 +1,6 @@ #!/usr/bin/env bash -VERSION="v0.35.0" +VERSION="v0.35.2" # dynamically pull more interesting stuff from latest git commit HASH=$(git show-ref --head --hash=7 head) # first 7 letters of hash should be enough; that's what GitHub uses diff --git a/docs/users-guide/expressions.md b/docs/users-guide/expressions.md index 60e05948b582af8cec8845302895fd42968b8ea0..5db02368f94f4c09e39d203f9ae7c986b94f910c 100644 --- a/docs/users-guide/expressions.md +++ b/docs/users-guide/expressions.md @@ -76,7 +76,7 @@ This would return rows where `Created At` is between January 1, 2020 and March 3 | Percentile | `percentile(column, percentile-value)` | Returns the value of the column at the percentile value. | `percentile([Score], 0.9)` | | Power | `power(number, exponent)` | Raises a number to the power of the exponent value. | `power([Length], 2)` | | Regex extract | `regexextract(text, regular_expression)` | Extracts matching substrings according to a regular expression. | `regexextract( [Address], "[0-9]+" )` | -| Replace | `replace(text, position, length, new_text)` | Replaces a part of the input text with new text. | `replace( [Order ID], 8, 3, [Updated Part of ID] )` | +| Replace | `replace(text, find, replace)` | Replaces a part of the input text with new text. | `replace( [Title], "Enormous", "Gigantic" )` | | Round | `round(number)` | Rounds a decimal number either up or down to the nearest integer value. | `round([Temperature])` | | Right trim | `rtrim(text)` | Removes trailing whitespace from a string of text. | `rtrim( [Comment] )` | | Share | `share(condition)` | Returns the percent of rows in the data that match the condition, as a decimal. | `share( [Source] = "Goolge" )` | @@ -94,7 +94,7 @@ This would return rows where `Created At` is between January 1, 2020 and March 3 Certain database types don't support some of the above functions: -MySQL +MySQL and SQL Server - median - percentile diff --git a/frontend/src/metabase/admin/permissions/routes.jsx b/frontend/src/metabase/admin/permissions/routes.jsx index 1f6a667d0cba6062d7c8784e7aeb40c6d5695f9f..b9ba8ae07726075aed877b27fc8c37240e656b6d 100644 --- a/frontend/src/metabase/admin/permissions/routes.jsx +++ b/frontend/src/metabase/admin/permissions/routes.jsx @@ -31,7 +31,7 @@ const getRoutes = store => ( </Route> {/* TABLES NO SCHEMA */} - {/* NOTE: this route is to support null schemas, inject the empty string as the schemaName */} + {/* NOTE: this route is to support null schemas */} <Route path=":databaseId/tables" component={( @@ -39,7 +39,7 @@ const getRoutes = store => ( ) => ( <TablesPermissionsApp {...props} - params={{ ...props.params, schemaName: "" }} + params={{ ...props.params, schemaName: null }} /> )} > diff --git a/frontend/src/metabase/admin/permissions/selectors.js b/frontend/src/metabase/admin/permissions/selectors.js index 965b36cfa0a7e7b5fbcdcf5487091a3900993c90..9ff1b4be987ea8288259a47b83b7666ac3a4c1d7 100644 --- a/frontend/src/metabase/admin/permissions/selectors.js +++ b/frontend/src/metabase/admin/permissions/selectors.js @@ -662,7 +662,7 @@ export const getDatabasesPermissionsGrid = createSelector( }, name: database.name, link: - schemas.length === 0 || (schemas.length === 1 && schemas[0] === "") + schemas.length === 0 || (schemas.length === 1 && schemas[0] == null) ? { name: t`View tables`, url: `/admin/permissions/databases/${database.id}/tables`, diff --git a/frontend/src/metabase/admin/settings/components/SettingsBatchForm.jsx b/frontend/src/metabase/admin/settings/components/SettingsBatchForm.jsx index b716604837c3e423a4c04791f5eac6cbe202e631..4a2c220dd3bb0a0c7b05aeeb798736a0c5dadb27 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsBatchForm.jsx +++ b/frontend/src/metabase/admin/settings/components/SettingsBatchForm.jsx @@ -33,6 +33,8 @@ const SAVE_SETTINGS_BUTTONS_STATES = { @connect( null, { updateSettings }, + null, + { withRef: true }, // HACK: needed so consuming components can call methods on the component :-/ ) export default class SettingsBatchForm extends Component { constructor(props, context) { diff --git a/frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx b/frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx index 9c77536225dedaad03cfbca60c2d463a59912779..0afdb3a0e7f6dfc4f378520d2e20e6893a1f0592 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx +++ b/frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx @@ -69,7 +69,7 @@ export default class SettingsEmailForm extends Component { const { sendingEmail } = this.state; return ( <SettingsBatchForm - ref={form => (this._form = form)} + ref={form => (this._form = form && form.getWrappedInstance())} {...this.props} updateSettings={this.props.updateEmailSettings} disable={sendingEmail !== "default"} diff --git a/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx b/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx index 320322288c7f937e92303484f19e51c4b3c7bbd0..413ab6cba3df738659334bdee9460f15f4c0a80a 100644 --- a/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx +++ b/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx @@ -109,6 +109,7 @@ export default class SettingsEditorApp extends Component { const { settings, updateSetting } = this.props; const setting = _.findWhere(settings, { key }); if (!setting) { + console.error(`Attempted to change unknown setting ${key}`); throw new Error(t`Unknown setting ${key}`); } return updateSetting({ ...setting, value }); diff --git a/frontend/src/metabase/containers/Form.jsx b/frontend/src/metabase/containers/Form.jsx index 5e6adcc0415a0f3a288255df21b907634dbfdc5d..2784d1299eca09970839baa2e8efb63fb37b4f5f 100644 --- a/frontend/src/metabase/containers/Form.jsx +++ b/frontend/src/metabase/containers/Form.jsx @@ -8,6 +8,7 @@ import { createSelector } from "reselect"; import { reduxForm, getValues, initialize, change } from "redux-form"; import { getIn, assocIn } from "icepick"; import _ from "underscore"; +import { t } from "ttag"; import CustomForm from "metabase/components/form/CustomForm"; import StandardForm from "metabase/components/form/StandardForm"; @@ -82,6 +83,12 @@ type State = { inlineFields: { [name: FormFieldName]: FormFieldDefinition }, }; +type SubmitState = { + submitting: boolean, + failed: boolean, + result: any, +}; + let FORM_ID = 0; // use makeMapStateToProps so each component gets it's own unique formId const makeMapStateToProps = () => { @@ -95,19 +102,37 @@ const makeMapStateToProps = () => { }; }; -const ReduxFormComponent = reduxForm()(props => { - const FormComponent = - props.formComponent || (props.children ? CustomForm : StandardForm); - return <FormComponent {...props} />; -}); +const ReduxFormComponent = reduxForm()( + ({ handleSubmit, submitState, ...props }) => { + const FormComponent = + props.formComponent || (props.children ? CustomForm : StandardForm); + return ( + <FormComponent + {...props} + handleSubmit={async (...args) => { + await handleSubmit(...args); + // normally handleSubmit swallows the result/error, but we want to make it available to things like ActionButton + if (submitState.failed) { + throw submitState.result; + } else { + return submitState.result; + } + }} + /> + ); + }, +); @connect(makeMapStateToProps) export default class Form extends React.Component { props: Props; state: State; - _submitting: boolean = false; - _submitFailed: boolean = false; + _state: SubmitState = { + submitting: false, + failed: false, + result: undefined, + }; _getFormDefinition: () => FormDefinition; _getFormObject: () => FormObject; @@ -232,8 +257,8 @@ export default class Form extends React.Component { _validate = (values: FormValues, props: any) => { // HACK: clears failed state for global error - if (!this._submitting && this._submitFailed) { - this._submitFailed = false; + if (!this._state.submitting && this._state.failed) { + this._state.failed = false; props.stopSubmit(); } const formObject = this._getFormObject(); @@ -243,27 +268,35 @@ export default class Form extends React.Component { _onSubmit = async (values: FormValues) => { const formObject = this._getFormObject(); // HACK: clears failed state for global error - this._submitting = true; + this._state.submitting = true; try { const normalized = formObject.normalize(values); - return await this.props.onSubmit(normalized); + return (this._state.result = await this.props.onSubmit(normalized)); } catch (error) { console.error("Form submission error", error); - this._submitFailed = true; + this._state.failed = true; + this._state.result = error; // redux-form expects { "FIELD NAME": "FIELD ERROR STRING" } or {"_error": "GLOBAL ERROR STRING" } if (error && error.data && error.data.errors) { try { // HACK: blur the current element to ensure we show the error document.activeElement.blur(); } catch (e) {} - throw error.data.errors; + // if there are errors for fields we don't know about then inject a generic top-level _error key + const fieldNames = new Set(this._getFieldNames()); + const errorNames = Object.keys(error.data.errors); + const hasUnknownFields = errorNames.some(name => !fieldNames.has(name)); + throw { + _error: hasUnknownFields ? t`An error occurred` : null, + ...error.data.errors, + }; } else if (error) { throw { _error: error.data.message || error.data, }; } } finally { - setTimeout(() => (this._submitting = false)); + setTimeout(() => (this._state.submitting = false)); } }; @@ -289,6 +322,8 @@ export default class Form extends React.Component { validate={this._validate} onSubmit={this._onSubmit} onChangeField={this._handleChangeField} + // HACK: _state is a mutable object so we can pass by reference into the ReduxFormComponent + submitState={this._state} /> ); } diff --git a/frontend/src/metabase/lib/expressions/config.js b/frontend/src/metabase/lib/expressions/config.js index 5eb511e5032d7e308d52e696f4fc2e821bf69aff..5b8a8d30ceb79ecba715a199d369c9f9b95b6df5 100644 --- a/frontend/src/metabase/lib/expressions/config.js +++ b/frontend/src/metabase/lib/expressions/config.js @@ -177,7 +177,7 @@ export const MBQL_CLAUSES = { multiple: true, }, replace: { - displayName: `substitute`, + displayName: `replace`, type: "string", args: ["string", "string", "string"], }, diff --git a/frontend/src/metabase/lib/expressions/helper_text_strings.js b/frontend/src/metabase/lib/expressions/helper_text_strings.js index 34cd35972bd72a693ca52a527a294113e2c5d114..fb00110302856e01705d5776f13e97d80ff8024c 100644 --- a/frontend/src/metabase/lib/expressions/helper_text_strings.js +++ b/frontend/src/metabase/lib/expressions/helper_text_strings.js @@ -235,35 +235,21 @@ const helperTextStrings = [ }, { name: "replace", - structure: - "replace(" + - t`text` + - ", " + - t`position` + - ", " + - t`length` + - ", " + - t`new_text` + - ")", + structure: "replace(" + t`text` + ", " + t`find` + ", " + t`replace` + ")", description: t`Replaces a part of the input text with new text.`, - example: - "replace([" + t`Order ID` + "] , 8, 3, [" + t`Updated Part of ID` + "] )", + example: "replace([" + t`Title` + '] , "Enormous", "Gigantic")', args: [ { name: t`text`, description: t`The text that will be modified.`, }, { - name: t`position`, - description: t`The position where the replacing will start.`, - }, - { - name: t`length`, - description: t`The number of characters to replace.`, + name: t`find`, + description: t`The text to find.`, }, { - name: t`new_text`, - description: t`The text to use in the replacement.`, + name: t`replace`, + description: t`The text to use as the replacement.`, }, ], }, diff --git a/frontend/src/metabase/lib/query/field_ref.js b/frontend/src/metabase/lib/query/field_ref.js index 44332da6e42f75b05d649f2f5163a743cab98427..b3523d3a210e36f90437bd1fe41d0fb6a385528c 100644 --- a/frontend/src/metabase/lib/query/field_ref.js +++ b/frontend/src/metabase/lib/query/field_ref.js @@ -62,7 +62,7 @@ export function isValidField(field) { (isAggregateField(field) && typeof field[1] === "number") || (isJoinedField(field) && typeof field[1] === "string" && - isValidField(field[0])) || + isValidField(field[2])) || isFieldLiteral(field) ); } diff --git a/frontend/src/metabase/lib/query/filter.js b/frontend/src/metabase/lib/query/filter.js index d85ee86c7ad0464a9cc585865ab212cd503fd160..552c558797055e4c51068063f2157ddcc953898e 100644 --- a/frontend/src/metabase/lib/query/filter.js +++ b/frontend/src/metabase/lib/query/filter.js @@ -70,7 +70,7 @@ export function canAddFilter(filter: ?FilterClause): boolean { export function isStandard(filter: FilterClause): boolean { return ( Array.isArray(filter) && - STANDARD_FILTERS.has(filter[0]) && + (STANDARD_FILTERS.has(filter[0]) || filter[0] === null) && (filter[1] === undefined || isValidField(filter[1])) ); } diff --git a/frontend/src/metabase/parameters/components/Parameters.jsx b/frontend/src/metabase/parameters/components/Parameters.jsx index 9048bc4335f5ccbaca595faa7ed5dad41df6ea93..4c908e3577eb45d6475c5d8362551029c8d19407 100644 --- a/frontend/src/metabase/parameters/components/Parameters.jsx +++ b/frontend/src/metabase/parameters/components/Parameters.jsx @@ -71,10 +71,14 @@ export default class Parameters extends Component { const queryParam = query && query[parameter.slug]; if (queryParam != null || parameter.default != null) { let value = queryParam != null ? queryParam : parameter.default; - if (parameter.hasOnlyFieldTargets && value && !Array.isArray(value)) { - // FieldValuesWidget always produces an array. If this param has - // only field targets we'll use that widget, so we should start with - // an array to match. + + // ParameterValueWidget uses FieldValuesWidget if there's no available + // date widget and all targets are fields. This matches that logic. + const willUseFieldValuesWidget = + parameter.hasOnlyFieldTargets && !/^date\//.test(parameter.type); + if (willUseFieldValuesWidget && value && !Array.isArray(value)) { + // FieldValuesWidget always produces an array. If we'll use that + // widget, we should start with an array to match. value = [value]; } const fieldIds = parameter.field_ids || []; diff --git a/frontend/src/metabase/visualizations/components/CardRenderer.jsx b/frontend/src/metabase/visualizations/components/CardRenderer.jsx index 04313664f865c2416431cd96068f8025024ba38c..d6bcec81c39d2afa6967f746e3b119bbd601b331 100644 --- a/frontend/src/metabase/visualizations/components/CardRenderer.jsx +++ b/frontend/src/metabase/visualizations/components/CardRenderer.jsx @@ -108,7 +108,6 @@ export default class CardRenderer extends Component { }); } catch (err) { console.error(err); - this.props.onRenderError(err.message || err); } } diff --git a/frontend/src/metabase/visualizations/components/ChoroplethMap.jsx b/frontend/src/metabase/visualizations/components/ChoroplethMap.jsx index af0e7cb4dff6305bd028864d7b73347c70d99fcd..13ec640388e79ebb75b8f8b7cd1fb27df76f9095 100644 --- a/frontend/src/metabase/visualizations/components/ChoroplethMap.jsx +++ b/frontend/src/metabase/visualizations/components/ChoroplethMap.jsx @@ -330,6 +330,7 @@ export default class ChoroplethMap extends Component { onClickFeature={onClickFeature} projection={projection} projectionFrame={projectionFrame} + onRenderError={this.props.onRenderError} /> ) : ( <LeafletChoropleth @@ -339,6 +340,7 @@ export default class ChoroplethMap extends Component { onHoverFeature={onHoverFeature} onClickFeature={onClickFeature} minimalBounds={minimalBounds} + onRenderError={this.props.onRenderError} /> )} </ChartWithLegend> diff --git a/frontend/src/metabase/visualizations/components/LeafletChoropleth.jsx b/frontend/src/metabase/visualizations/components/LeafletChoropleth.jsx index d33a47f158089b25e2df9ef66254ad069211ddb8..667a5fc71307936d014c6cbe691b05755d0fce9f 100644 --- a/frontend/src/metabase/visualizations/components/LeafletChoropleth.jsx +++ b/frontend/src/metabase/visualizations/components/LeafletChoropleth.jsx @@ -17,6 +17,7 @@ const LeafletChoropleth = ({ getColor = () => color("brand"), onHoverFeature = () => {}, onClickFeature = () => {}, + onRenderError, }) => ( <CardRenderer series={series} @@ -104,6 +105,7 @@ const LeafletChoropleth = ({ map.remove(); }; }} + onRenderError={onRenderError} /> ); diff --git a/frontend/test/__runner__/run_cypress_tests.js b/frontend/test/__runner__/run_cypress_tests.js index d9415c0b9be3a3cd8dd62a7079d814486ee1cd21..d0c5d35ffef8a33e7a121235216a10feed228b76 100644 --- a/frontend/test/__runner__/run_cypress_tests.js +++ b/frontend/test/__runner__/run_cypress_tests.js @@ -7,8 +7,13 @@ const BackendResource = require("./backend.js").BackendResource; const server = BackendResource.get({ dbKey: "" }); +// We currently accept two (optional) command line arguments +// --open - Opens the Cypress test browser +// --testFiles <path> - Specifies a different path for the integration folder const userArgs = process.argv.slice(2); -const isOpenMode = userArgs[0] === "--open"; +const isOpenMode = userArgs.includes("--open"); +const testFiles = userArgs.includes("--testFiles"); +const testFilesLocation = userArgs[userArgs.indexOf("--testFiles") + 1]; function readFile(fileName) { return new Promise(function(resolve, reject) { @@ -30,6 +35,10 @@ const init = async () => { ); } + if (testFiles) { + console.log(chalk.bold(`Running tests in '${testFilesLocation}'`)); + } + try { const version = await readFile( __dirname + "/../../../resources/version.properties", @@ -57,6 +66,11 @@ const init = async () => { await generateSnapshots(); console.log(chalk.bold("Starting Cypress")); + let commandLineConfig = `baseUrl=${server.host}`; + if (testFiles) { + commandLineConfig = `${commandLineConfig},integrationFolder=${testFilesLocation}`; + } + const cypressProcess = spawn( "yarn", [ @@ -65,7 +79,7 @@ const init = async () => { "--config-file", process.env["CONFIG_FILE"], "--config", - `baseUrl=${server.host}`, + commandLineConfig, ...(process.env["CI"] ? [ "--reporter", diff --git a/frontend/test/__support__/cypress.js b/frontend/test/__support__/cypress.js index 94c8ae103e49d9c790fa8abdca62302070d8a533..075eecfc9986c4f4bac294e7b2ef1453f4f7bc38 100644 --- a/frontend/test/__support__/cypress.js +++ b/frontend/test/__support__/cypress.js @@ -68,6 +68,8 @@ export function main() { return cy.get("nav").next(); } +// Metabase utility functions for commonly-used patterns + export function openOrdersTable() { cy.visit("/question/new?database=1&table=2"); } @@ -76,4 +78,13 @@ export function openProductsTable() { cy.visit("/question/new?database=1&table=1"); } +// Find a text field by label text, type it in, then blur the field. +// Commonly used in our Admin section as we auto-save settings. +export function typeAndBlurUsingLabel(label, value) { + cy.findByLabelText(label) + .clear() + .type(value) + .blur(); +} + Cypress.on("uncaught:exception", (err, runnable) => false); diff --git a/frontend/test/cypress.json b/frontend/test/cypress.json index e026f5171fd83b3223fb90ecc240632a80c71d1b..2d6709c27b82a6c323fee05e2a0e4c48b6149268 100644 --- a/frontend/test/cypress.json +++ b/frontend/test/cypress.json @@ -1,7 +1,7 @@ { "testFiles": "**/*.cy.spec.js", "pluginsFile": "frontend/test/cypress-plugins.js", - "integrationFolder": "frontend/test", + "integrationFolder": "frontend/test/metabase", "supportFile": "frontend/test/__support__/cypress.js", "viewportHeight": 800, "viewportWidth": 1280 diff --git a/frontend/test/metabase-db/mongo/add.cy.spec.js b/frontend/test/metabase-db/mongo/add.cy.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..dde062aa147700199c5f6587f46b2c7ea4521e5f --- /dev/null +++ b/frontend/test/metabase-db/mongo/add.cy.spec.js @@ -0,0 +1,58 @@ +import { + signInAsAdmin, + restore, + modal, + typeAndBlurUsingLabel, +} from "__support__/cypress"; + +function addMongoDatabase() { + cy.visit("/admin/databases/create"); + cy.contains("Database type") + .closest(".Form-field") + .find("a") + .click(); + cy.contains("MongoDB").click({ force: true }); + cy.contains("Additional Mongo connection"); + + typeAndBlurUsingLabel("Name", "MongoDB"); + typeAndBlurUsingLabel("Database name", "admin"); + + cy.findByText("Save") + .should("not.be.disabled") + .click(); +} + +describe("mongodb > admin > add", () => { + beforeEach(() => { + restore(); + signInAsAdmin(); + cy.server(); + }); + + it("should add a database and redirect to listing", () => { + cy.route({ + method: "POST", + url: "/api/database", + }).as("createDatabase"); + + addMongoDatabase(); + + cy.wait("@createDatabase"); + + cy.url().should("match", /\/admin\/databases\?created=\d+$/); + cy.contains("Your database has been added!"); + modal() + .contains("I'm good thanks") + .click(); + }); + + it("can query a Mongo database", () => { + addMongoDatabase(); + cy.url().should("match", /\/admin\/databases\?created=\d+$/); + cy.visit("/question/new"); + cy.contains("Simple question").click(); + cy.contains("MongoDB").click(); + cy.contains("Version").click(); + cy.contains("featureCompatibilityVersion"); + }); +}); diff --git a/frontend/test/metabase-db/mongo/query.cy.spec.js b/frontend/test/metabase-db/mongo/query.cy.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e156f3dfafa57654920c2809de94a0f1f7d792a7 --- /dev/null +++ b/frontend/test/metabase-db/mongo/query.cy.spec.js @@ -0,0 +1,110 @@ +import { + signInAsAdmin, + restore, + modal, + signInAsNormalUser, +} from "__support__/cypress"; + +function addMongoDatabase() { + cy.request("POST", "/api/database", { + engine: "mongo", + name: "MongoDB", + details: { + host: "localhost", + dbname: "admin", + port: 27017, + user: null, + pass: null, + authdb: null, + "additional-options": null, + "use-srv": false, + "tunnel-enabled": false, + }, + auto_run_queries: true, + is_full_sync: true, + schedules: { + cache_field_values: { + schedule_day: null, + schedule_frame: null, + schedule_hour: 0, + schedule_type: "daily", + }, + metadata_sync: { + schedule_day: null, + schedule_frame: null, + schedule_hour: null, + schedule_type: "hourly", + }, + }, + }); +} + +describe("mongodb > user > query", () => { + before(() => { + restore(); + signInAsAdmin(); + addMongoDatabase(); + }); + + beforeEach(() => { + signInAsNormalUser(); + }); + + it("can query a Mongo database as a user", () => { + cy.visit("/question/new"); + cy.contains("Simple question").click(); + cy.contains("MongoDB").click(); + cy.contains("Version").click(); + cy.contains("featureCompatibilityVersion"); + }); + + it.only("can write a native MongoDB query", () => { + cy.visit("/question/new"); + cy.contains("Native query").click(); + cy.contains("MongoDB").click(); + + cy.get(".ace_content").type(`[ { $count: "Total" } ]`, { + parseSpecialCharSequences: false, + }); + cy.get(".NativeQueryEditor .Icon-play").click(); + cy.contains("1"); + }); + + it("can save a native MongoDB query", () => { + cy.server(); + cy.route("POST", "/api/card").as("createQuestion"); + + cy.visit("/question/new"); + cy.contains("Native query").click(); + cy.contains("MongoDB").click(); + + cy.get(".ace_content").type(`[ { $count: "Total" } ]`, { + parseSpecialCharSequences: false, + }); + cy.get(".NativeQueryEditor .Icon-play").click(); + cy.contains("1"); + + // Close the Ace editor because it interferes with the modal for some reason + cy.get(".Icon-contract").click(); + + cy.contains("Save").click(); + modal() + .findByLabelText("Name") + .focus() + .type("mongo count"); + modal() + .contains("button", "Save") + .should("not.be.disabled") + .click(); + + cy.wait("@createQuestion").then(({ status }) => { + expect(status).to.equal(202); + }); + + modal() + .contains("Not now") + .click(); + + cy.url().should("match", /\/question\/\d+$/); + }); +}); diff --git a/frontend/test/metabase-lib/lib/queries/structured/Filter.unit.spec.js b/frontend/test/metabase-lib/lib/queries/structured/Filter.unit.spec.js index 734d0525fc07abee9a47545c1543bb16ab2c41d5..379fa29508211d402c52db223dfcb1799326ab79 100644 --- a/frontend/test/metabase-lib/lib/queries/structured/Filter.unit.spec.js +++ b/frontend/test/metabase-lib/lib/queries/structured/Filter.unit.spec.js @@ -1,6 +1,6 @@ import Filter from "metabase-lib/lib/queries/structured/Filter"; -import { ORDERS } from "__support__/sample_dataset_fixture"; +import { ORDERS, PEOPLE } from "__support__/sample_dataset_fixture"; const query = ORDERS.query(); @@ -72,5 +72,38 @@ describe("Filter", () => { filter(["segment", 1]).setDimension(["field-id", ORDERS.TOTAL.id]), ).toEqual([null, ["field-id", ORDERS.TOTAL.id]]); }); + it("should set joined-field for new filter clause", () => { + const q = ORDERS.query().join({ + alias: "foo", + "source-table": PEOPLE.id, + }); + const f = new Filter([], 0, q); + expect( + f.setDimension(["joined-field", "foo", ["field-id", PEOPLE.EMAIL.id]], { + useDefaultOperator: true, + }), + ).toEqual([ + "=", + ["joined-field", "foo", ["field-id", PEOPLE.EMAIL.id]], + undefined, + ]); + }); }); + + const CASES = [ + ["isStandard", ["=", ["field-id", 1]]], + ["isStandard", [null, ["field-id", 1]]], // assume null operator is standard + ["isSegment", ["segment", 1]], + ["isCustom", ["or", ["=", ["field-id", 1], 42]]], + ]; + for (const method of ["isStandard", "isSegment", "isCustom"]) { + describe(method, () => { + for (const [method_, mbql] of CASES) { + const expected = method_ === method; + it(`should return ${expected} for ${JSON.stringify(mbql)}`, () => { + expect(filter(mbql)[method]()).toEqual(expected); + }); + } + }); + } }); diff --git a/frontend/test/metabase/lib/expressions/suggest.unit.spec.js b/frontend/test/metabase/lib/expressions/suggest.unit.spec.js index 69366fd5c8bceebfd11cea26da351d6a59109f83..05fe56de44191687a8a173f832365c87229aac0c 100644 --- a/frontend/test/metabase/lib/expressions/suggest.unit.spec.js +++ b/frontend/test/metabase/lib/expressions/suggest.unit.spec.js @@ -35,7 +35,7 @@ const STRING_FUNCTIONS = [ { text: "ltrim(", type: "functions" }, { text: "regexextract(", type: "functions" }, { text: "rtrim(", type: "functions" }, - { text: "substitute(", type: "functions" }, + { text: "replace(", type: "functions" }, { text: "substring(", type: "functions" }, { text: "trim(", type: "functions" }, { text: "upper(", type: "functions" }, diff --git a/frontend/test/metabase/lib/query/field_ref.unit.spec.js b/frontend/test/metabase/lib/query/field_ref.unit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..d58fd540f469d925e2aec4a53cdc545c64c5ac98 --- /dev/null +++ b/frontend/test/metabase/lib/query/field_ref.unit.spec.js @@ -0,0 +1,18 @@ +import { isValidField } from "metabase/lib/query/field_ref"; + +describe("field_ref", () => { + describe("isValidField", () => { + it("should be valid for field id", () => { + expect(isValidField(["field-id", 1])).toBe(true); + }); + it("should be valid for fk", () => { + expect(isValidField(["fk->", ["field-id", 1], ["field-id", 2]])).toBe( + true, + ); + }); + it("should be valid for joined-field", () => { + expect(isValidField(["joined-field", "foo", ["field-id", 1]])).toBe(true); + }); + // TODO: remaininng field types + }); +}); diff --git a/frontend/test/metabase/scenarios/admin/settings/settings.cy.spec.js b/frontend/test/metabase/scenarios/admin/settings/settings.cy.spec.js index 872628e12d896ad01ee18b84abcff4fc376414fb..8110fae1f78f0c78152323444b1dcc2b584bf7d5 100644 --- a/frontend/test/metabase/scenarios/admin/settings/settings.cy.spec.js +++ b/frontend/test/metabase/scenarios/admin/settings/settings.cy.spec.js @@ -59,4 +59,126 @@ describe("scenarios > admin > settings", () => { openOrdersTable(); cy.contains(/^February 11, 2019, 9:40 PM$/); }); + + describe(" > embedding settings", () => { + it("should validate a premium embedding token has a valid format", () => { + cy.server(); + cy.route("PUT", "/api/setting/premium-embedding-token").as( + "saveEmbeddingToken", + ); + + cy.visit("/admin/settings/embedding_in_other_applications"); + cy.contains("Premium embedding"); + cy.contains("Enter a token").click(); + + // Try an invalid token format + cy.contains("Enter the token") + .next() + .type("Hi") + .blur(); + cy.wait("@saveEmbeddingToken").then(({ response }) => { + expect(response.body).to.equal( + "Token format is invalid. Token should be 64 hexadecimal characters.", + ); + }); + cy.contains("Token format is invalid."); + }); + + it("should validate a premium embedding token exists", () => { + cy.server(); + cy.route("PUT", "/api/setting/premium-embedding-token").as( + "saveEmbeddingToken", + ); + + cy.visit("/admin/settings/embedding_in_other_applications"); + cy.contains("Premium embedding"); + cy.contains("Enter a token").click(); + + // Try a valid format, but an invalid token + cy.contains("Enter the token") + .next() + .type( + "11397b1e60cfb1372f2f33ac8af234a15faee492bbf5c04d0edbad76da3e614a", + ) + .blur(); + cy.wait("@saveEmbeddingToken").then(({ response }) => { + expect(response.body).to.equal( + "Unable to validate token: 404 not found.", + ); + }); + cy.contains("Unable to validate token: 404 not found."); + }); + + it("should be able to set a premium embedding token", () => { + // A random embedding token with valid format + const embeddingToken = + "11397b1e60cfb1372f2f33ac8af234a15faee492bbf5c04d0edbad76da3e614a"; + + cy.server(); + cy.route({ + method: "PUT", + url: "/api/setting/premium-embedding-token", + response: embeddingToken, + }).as("saveEmbeddingToken"); + + cy.visit("/admin/settings/embedding_in_other_applications"); + cy.contains("Premium embedding"); + cy.contains("Enter a token").click(); + + cy.route("GET", "/api/session/properties").as("getSessionProperties"); + cy.route({ + method: "GET", + url: "/api/setting", + response: [ + { key: "enable-embedding", value: true }, + { key: "embedding-secret-key", value: embeddingToken }, + { key: "premium-embedding-token", value: embeddingToken }, + ], + }).as("getSettings"); + + cy.contains("Enter the token") + .next() + .type(embeddingToken) + .blur(); + cy.wait("@saveEmbeddingToken").then(({ response }) => { + expect(response.body).to.equal(embeddingToken); + }); + cy.wait("@getSessionProperties"); + cy.wait("@getSettings"); + cy.contains("Premium embedding enabled"); + }); + }); + + describe(" > email settings", () => { + it("should be able to save email settings", () => { + cy.visit("/admin/settings/email"); + cy.findByPlaceholderText("smtp.yourservice.com") + .type("localhost") + .blur(); + cy.findByPlaceholderText("587") + .type("1234") + .blur(); + cy.findByPlaceholderText("metabase@yourcompany.com") + .type("admin@metabase.com") + .blur(); + cy.findByText("Save changes").click(); + + cy.findByText("Changes saved!"); + }); + it("should show an error if test email fails", () => { + cy.visit("/admin/settings/email"); + cy.findByText("Send test email").click(); + cy.findByText("Sorry, something went wrong. Please try again."); + }); + it("should be able to clear email settings", () => { + cy.visit("/admin/settings/email"); + cy.findByText("Clear").click(); + cy.findByPlaceholderText("smtp.yourservice.com").should("have.value", ""); + cy.findByPlaceholderText("587").should("have.value", ""); + cy.findByPlaceholderText("metabase@yourcompany.com").should( + "have.value", + "", + ); + }); + }); }); diff --git a/frontend/test/metabase/scenarios/question/native.cy.spec.js b/frontend/test/metabase/scenarios/question/native.cy.spec.js index db852aee0cf46468cc60f3d5d5aeb85ee9442214..3a7654de2d43a966003d600b15b18b71bc2703b6 100644 --- a/frontend/test/metabase/scenarios/question/native.cy.spec.js +++ b/frontend/test/metabase/scenarios/question/native.cy.spec.js @@ -1,4 +1,9 @@ -import { signInAsNormalUser, restore, popover } from "__support__/cypress"; +import { + signInAsNormalUser, + restore, + popover, + modal, +} from "__support__/cypress"; describe("scenarios > question > native", () => { before(restore); @@ -150,4 +155,39 @@ describe("scenarios > question > native", () => { cy.get(".NativeQueryEditor .Icon-play").click(); cy.contains("18,760"); }); + + it("can load a question with a date filter (from issue metabase#12228)", () => { + cy.request("POST", "/api/card", { + name: "Test Question", + dataset_query: { + type: "native", + native: { + query: "select count(*) from orders where {{created_at}}", + "template-tags": { + created_at: { + id: "6b8b10ef-0104-1047-1e1b-2492d5954322", + name: "created_at", + "display-name": "Created at", + type: "dimension", + dimension: ["field-id", 15], + "widget-type": "date/month-year", + }, + }, + }, + database: 1, + }, + display: "scalar", + description: null, + visualization_settings: {}, + collection_id: null, + result_metadata: null, + metadata_checksum: null, + }).then(response => { + cy.visit(`/question/${response.body.id}?created_at=2020-01`); + modal() + .contains("Okay") + .click(); + cy.contains("580"); + }); + }); }); diff --git a/frontend/test/metabase/support/join_filter.cy.spec.js b/frontend/test/metabase/support/join_filter.cy.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..bb0ba3eb1967f29d873b15ce2a905b7cac9eb061 --- /dev/null +++ b/frontend/test/metabase/support/join_filter.cy.spec.js @@ -0,0 +1,26 @@ +import { restore, signInAsNormalUser } from "__support__/cypress"; + +describe("support > join filter (metabase#12221)", () => { + before(restore); + before(signInAsNormalUser); + + it.skip("can filter by a joined table", () => { + cy.visit("/question/new"); + cy.contains("Custom question").click(); + cy.contains("Sample Dataset").click(); + cy.contains("Orders").click(); + cy.get(".Icon-join_left_outer ").click(); + cy.contains("People").click(); + cy.contains("Orders + People"); + cy.contains("Visualize").click(); + cy.contains("Showing first 2,000"); + + cy.contains("Filter").click(); + cy.contains("Email").click(); + cy.contains("People – Email"); + cy.get('[placeholder="Search by Email"]').type("wolf."); + cy.contains("wolf.dina@yahoo.com").click(); + cy.contains("Add filter").click(); + cy.contains("Showing 1 row"); + }); +}); diff --git a/modules/drivers/mongo/src/metabase/driver/mongo.clj b/modules/drivers/mongo/src/metabase/driver/mongo.clj index f02a9a8b2326e7beaee6b48b2a2de3526e52b2d1..e1abacd7973446f0a96774e89821baea14451f80 100644 --- a/modules/drivers/mongo/src/metabase/driver/mongo.clj +++ b/modules/drivers/mongo/src/metabase/driver/mongo.clj @@ -207,12 +207,8 @@ (defmethod driver/execute-reducible-query :mongo [_ query context respond] - ;; TODO - there's a bug in normalization where the columns in `:projection` are getting converted to keywords - ;; (#11897). Until we fix that, work around it here - (let [query (cond-> query - (-> query :native :projections seq) (update-in [:native :projections] (partial mapv u/qualified-name)))] - (with-mongo-connection [_ (qp.store/database)] - (execute/execute-reducible-query query context respond)))) + (with-mongo-connection [_ (qp.store/database)] + (execute/execute-reducible-query query context respond))) (defmethod driver/substitute-native-parameters :mongo [driver inner-query] diff --git a/modules/drivers/mongo/src/metabase/driver/mongo/execute.clj b/modules/drivers/mongo/src/metabase/driver/mongo/execute.clj index 8772fcb0d092e88ec69c6a9c9b68ebfc313a282d..7e4cbdea40b20a4e6bf83390be3da27956081fca 100644 --- a/modules/drivers/mongo/src/metabase/driver/mongo/execute.clj +++ b/modules/drivers/mongo/src/metabase/driver/mongo/execute.clj @@ -49,7 +49,9 @@ not-in-expected (set/difference actual-cols expected-cols)] (when (seq not-in-expected) (throw (ex-info (tru "Unexpected columns in results: {0}" (sort not-in-expected)) - {:type error-type/driver})))))) + {:type error-type/driver + :actual actual-cols + :expected expected-cols})))))) (s/defn ^:private result-col-names :- {:row [s/Str], :unescaped [s/Str]} "Return column names we can expect in each `:row` of the results, and the `:unescaped` versions we should return in @@ -68,7 +70,7 @@ (vec (for [k projections] (get unescape-map k k))))}))) -(defn- result-metadata [{:keys [mbql?]} unescaped-col-names] +(defn- result-metadata [unescaped-col-names] {:cols (vec (for [col-name unescaped-col-names] {:name col-name}))}) @@ -148,7 +150,7 @@ {row-col-names :row unescaped-col-names :unescaped} (result-col-names native-query (row-keys first-row))] (log/tracef "Renaming columns in results %s -> %s" (pr-str row-col-names) (pr-str unescaped-col-names)) - (respond (result-metadata native-query unescaped-col-names) + (respond (result-metadata unescaped-col-names) (if-not first-row [] (reducible-rows context cursor first-row (post-process-row native-query row-col-names))))) diff --git a/src/metabase/public_settings.clj b/src/metabase/public_settings.clj index 592cf16fe01639bcacc8964918d6bc4191bc3b3c..16884bd808beb5e4ae5d2918a1e7ba7f4396f93d 100644 --- a/src/metabase/public_settings.clj +++ b/src/metabase/public_settings.clj @@ -10,12 +10,16 @@ [metabase.models [common :as common] [setting :as setting :refer [defsetting]]] + metabase.public-settings.metastore [metabase.util [i18n :refer [available-locales-with-names deferred-tru set-locale trs tru]] [password :as password]] [toucan.db :as db]) (:import java.util.UUID)) +;; These modules register settings but are otherwise unused. They still must be imported. +(comment metabase.public-settings.metastore/keep-me) + (defsetting check-for-updates (deferred-tru "Identify when new versions of Metabase are available.") :type :boolean diff --git a/src/metabase/query_processor/middleware/catch_exceptions.clj b/src/metabase/query_processor/middleware/catch_exceptions.clj index da10ec67e82df17ee3bc7939406a66dc0d2b1721..5c6522ec5c7e880f7c0c3b8b333f865572904a22 100644 --- a/src/metabase/query_processor/middleware/catch_exceptions.clj +++ b/src/metabase/query_processor/middleware/catch_exceptions.clj @@ -7,6 +7,7 @@ [reducible :as qp.reducible]] [metabase.query-processor.middleware.permissions :as perms] [metabase.util :as u] + [metabase.util.i18n :refer [trs]] schema.utils) (:import clojure.lang.ExceptionInfo java.sql.SQLException @@ -83,6 +84,8 @@ (assoc ((get-method format-exception Throwable) e) :state (.getSQLState e))) +;; TODO -- some of this logic duplicates the functionality of `clojure.core/Throwable->map`, we should consider +;; whether we can use that more extensively and remove some of this logic (defn- cause [^Throwable e] (let [cause (.getCause e)] (when-not (= cause e) @@ -131,7 +134,9 @@ (defn- query-execution-info [query-execution] (dissoc query-execution :result_rows :hash :executor_id :card_id :dashboard_id :pulse_id :native :start_time_millis)) -(defn- format-exception* [query ^Throwable e extra-info] +(defn- format-exception* + "Format a `Throwable` into the usual userland error-response format." + [query ^Throwable e extra-info] (try (if-let [query-execution (:query-execution (ex-data e))] (merge (query-execution-info query-execution) @@ -147,7 +152,6 @@ "Middleware for catching exceptions thrown by the query processor and returning them in a 'normal' format. Forwards exceptions to the `result-chan`." [qp] - (fn [query rff {:keys [preprocessedf nativef raisef], :as context}] (let [extra-info (atom nil)] (letfn [(preprocessedf* [query context] @@ -157,11 +161,15 @@ (swap! extra-info assoc :native query) (nativef query context)) (raisef* [e context] + ;; if the exception is the special quit-early exception, forward this to our parent `raisef` exception + ;; handler, which has logic for handling that case (if (qp.reducible/quit-result e) (raisef e context) - (do - (log/tracef "raisef* got %s, returning formatted exception" (class e)) - (context/resultf (format-exception* query e @extra-info) context))))] + ;; otherwise format the Exception and return it + (let [formatted-exception (format-exception* query e @extra-info)] + (log/error (str (trs "Error processing query: {0}" (:error format-exception)) + "\n" (u/pprint-to-str formatted-exception))) + (context/resultf formatted-exception context))))] (try (qp query rff (assoc context :preprocessedf preprocessedf* diff --git a/src/metabase/query_processor/reducible.clj b/src/metabase/query_processor/reducible.clj index ccf4a0b96b43bb9bcf6a4b55b24b97d17d660fef..20fc217177326efb0e65847e371dbb333c4b5da1 100644 --- a/src/metabase/query_processor/reducible.clj +++ b/src/metabase/query_processor/reducible.clj @@ -47,6 +47,7 @@ (defn quit "Create a special Exception that, when thrown or raised in the QP, will cause `result` to be returned directly. + Similar in concept to using `reduced` to stip reduction early. (context/raisef (qp.reducible/quit :my-result) context)" [result] diff --git a/src/metabase/sync/analyze/query_results.clj b/src/metabase/sync/analyze/query_results.clj index 8de0cf0f26bc65ca41bb5ef4b890502adb1e4f3a..750598dbb42b5b278f2002b5d11f275c95c8777f 100644 --- a/src/metabase/sync/analyze/query_results.clj +++ b/src/metabase/sync/analyze/query_results.clj @@ -42,9 +42,9 @@ (s/defn ^:private maybe-infer-special-type :- ResultColumnMetadata "Infer the special type and add it to the result metadata. If the inferred special type is nil, don't override the special type with a nil special type" - [result-metadata col] + [col] (update - result-metadata + col :special_type (fn [original-value] ;; If we already know the special type, becouse it is stored, don't classify again, but try to refine special @@ -53,14 +53,14 @@ (nil :type/Number) (classify-name/infer-special-type col) original-value)))) -(s/defn ^:private stored-column-metadata->result-column-metadata :- ResultColumnMetadata - "The metadata in the column of our resultsets come from the metadata we store in the `Field` associated with the - column. It is cheapest and easiest to just use that. This function takes what it can from the column metadata to - populate the ResultColumnMetadata" +(s/defn ^:private col->ResultColumnMetadata :- ResultColumnMetadata + "Make sure a `column` as it comes back from a driver's initial results metadata matches the schema for valid results + column metadata, adding placeholder values and removing nil keys." [column] ;; HACK - not sure why we don't have display_name yet in some cases (merge - {:display_name (:name column)} + {:base_type :type/* + :display_name (:name column)} (u/select-non-nil-keys column [:name :display_name :description :base_type :special_type :unit :fingerprint]))) (defn insights-rf @@ -70,9 +70,7 @@ [{:keys [cols]}] (let [cols (for [col cols] (try - (-> col - stored-column-metadata->result-column-metadata - (maybe-infer-special-type col)) + (maybe-infer-special-type (col->ResultColumnMetadata col)) (catch Throwable e (log/error e (trs "Error generating insights for column:") col) col)))] diff --git a/test/metabase/query_processor/middleware/catch_exceptions_test.clj b/test/metabase/query_processor/middleware/catch_exceptions_test.clj index f74bf2412baca8263ffef1bf9126a25a4062441d..4c3e13b56f3896a98bcef5a1bb93c3969bd8a1a3 100644 --- a/test/metabase/query_processor/middleware/catch_exceptions_test.clj +++ b/test/metabase/query_processor/middleware/catch_exceptions_test.clj @@ -29,7 +29,7 @@ (let [e1 (ex-info "1" {:level 1}) e2 (ex-info "2" {:level 2, :type error-type/qp} e1) e3 (ex-info "3" {:level 3} e2)] - (is (= {:status :failed, + (is (= {:status :failed :class clojure.lang.ExceptionInfo :error "1" :stacktrace true diff --git a/test/metabase/query_processor/middleware/results_metadata_test.clj b/test/metabase/query_processor/middleware/results_metadata_test.clj index 559e98bc97fe33212018adfcb28bfe65dc9d0858..a99cd1fa30b81b49b2f821be16f684840ea4fe8e 100644 --- a/test/metabase/query_processor/middleware/results_metadata_test.clj +++ b/test/metabase/query_processor/middleware/results_metadata_test.clj @@ -2,6 +2,7 @@ (:require [clojure.test :refer :all] [metabase [query-processor :as qp] + [test :as mt] [util :as u]] [metabase.mbql.schema :as mbql.s] [metabase.models @@ -11,17 +12,17 @@ [permissions-group :as group]] [metabase.query-processor.middleware.results-metadata :as results-metadata] [metabase.query-processor.util :as qputil] - [metabase.test - [data :as data] - [util :as tu]] + [metabase.sync.analyze.query-results :as qr] [metabase.test.data.users :as users] [metabase.test.mock.util :as mutil] - [metabase.util.encryption :as encrypt] - [toucan.db :as db] - [toucan.util.test :as tt])) + [metabase.test.util :as tu] + [metabase.util + [encryption :as encrypt] + [schema :as su]] + [toucan.db :as db])) (defn- native-query [sql] - {:database (data/id) + {:database (mt/id) :type :native :native {:query sql}}) @@ -29,9 +30,9 @@ (db/select-one-field :result_metadata Card :id (u/get-id card))) (defn- round-to-2-decimals - "Defaults `tu/round-all-decimals` to 2 digits" + "Defaults `mt/round-all-decimals` to 2 digits" [data] - (tu/round-all-decimals 2 data)) + (mt/round-all-decimals 2 data)) (def ^:private default-card-results [{:name "ID", :display_name "ID", :base_type "type/BigInteger", @@ -54,7 +55,7 @@ (deftest save-result-metadata-test (testing "test that Card result metadata is saved after running a Card" - (tt/with-temp Card [card] + (mt/with-temp Card [card] (let [result (qp/process-userland-query (assoc (native-query "SELECT ID, NAME, PRICE, CATEGORY_ID, LATITUDE, LONGITUDE FROM VENUES") :info {:card-id (u/get-id card) @@ -65,7 +66,7 @@ (-> card card-metadata round-to-2-decimals tu/round-fingerprint-cols))))) (testing "check that using a Card as your source doesn't overwrite the results metadata..." - (tt/with-temp Card [card {:dataset_query (native-query "SELECT * FROM VENUES") + (mt/with-temp Card [card {:dataset_query (native-query "SELECT * FROM VENUES") :result_metadata [{:name "NAME", :display_name "Name", :base_type "type/Text"}]}] (let [result (qp/process-userland-query {:database mbql.s/saved-questions-virtual-database-id :type :query @@ -76,7 +77,7 @@ (card-metadata card))))) (testing "...even when running via the API endpoint" - (tt/with-temp* [Collection [collection] + (mt/with-temp* [Collection [collection] Card [card {:collection_id (u/get-id collection) :dataset_query (native-query "SELECT * FROM VENUES") :result_metadata [{:name "NAME", :display_name "Name", :base_type "type/Text"}]}]] @@ -154,7 +155,7 @@ :columns (for [col default-card-results-native] (-> col (update :special_type keyword) (update :base_type keyword)))} (-> (qp/process-userland-query - {:database (data/id) + {:database (mt/id) :type :native :native {:query "SELECT ID, NAME, PRICE, CATEGORY_ID, LATITUDE, LONGITUDE FROM VENUES"}}) (get-in [:data :results_metadata]) @@ -164,13 +165,13 @@ (deftest card-with-datetime-breakout-by-year-test (testing "make sure that a Card where a DateTime column is broken out by year works the way we'd expect" - (tt/with-temp Card [card] + (mt/with-temp Card [card] (qp/process-userland-query - {:database (data/id) + {:database (mt/id) :type :query - :query {:source-table (data/id :checkins) + :query {:source-table (mt/id :checkins) :aggregation [[:count]] - :breakout [[:datetime-field [:field-id (data/id :checkins :date)] :year]]} + :breakout [[:datetime-field [:field-id (mt/id :checkins :date)] :year]]} :info {:card-id (u/get-id card) :query-hash (qputil/query-hash {})}}) (is (= [{:base_type "type/DateTime" @@ -191,3 +192,16 @@ card-metadata round-to-2-decimals tu/round-fingerprint-cols)))))) + +(deftest valid-results-metadata-test + (mt/test-drivers (mt/normal-drivers) + (letfn [(results-metadata [query] + (-> (qp/process-query query) :data :results_metadata :columns))] + (testing "MBQL queries should come back with valid results metadata" + + (is (schema= (su/non-empty qr/ResultsMetadata) + (results-metadata (mt/query venues))))) + + (testing "Native queries should come back with valid results metadata (#12265)" + (is (schema= (su/non-empty qr/ResultsMetadata) + (results-metadata (-> (mt/mbql-query venues) qp/query->native mt/native-query)))))))) diff --git a/test/metabase/query_processor_test/case_test.clj b/test/metabase/query_processor_test/case_test.clj index 87eb1fda537b3d4fe407eedba5f5e09ed96c89ab..87aa53739d14fade18c6dc145e3472b786160f5d 100644 --- a/test/metabase/query_processor_test/case_test.clj +++ b/test/metabase/query_processor_test/case_test.clj @@ -1,92 +1,85 @@ (ns metabase.query-processor-test.case-test (:require [clojure.test :refer :all] [metabase - [query-processor-test :refer :all] - [test :as mt]] - [metabase.models - [metric :refer [Metric]] - [segment :refer [Segment]]] - [metabase.test - [data :as data] - [util :as tu]] - [toucan.util.test :as tt])) + [models :refer [Metric Segment]] + [test :as mt]])) (defn- test-case [expr] (some->> (mt/run-mbql-query venues {:aggregation [expr]}) - rows + mt/rows ffirst double)) (deftest test-case-aggregations (mt/test-drivers (mt/normal-drivers-with-feature :basic-aggregations) - (is (= 116.0 (test-case [:sum [:case [[[:< [:field-id (data/id :venues :price)] 2] 2] - [[:< [:field-id (data/id :venues :price)] 4] 1]]]]))) + (is (= 116.0 (test-case [:sum [:case [[[:< [:field-id (mt/id :venues :price)] 2] 2] + [[:< [:field-id (mt/id :venues :price)] 4] 1]]]]))) (testing "Can we use fields as values" - (is (= 179.0 (test-case [:sum [:case [[[:< [:field-id (data/id :venues :price)] 2] [:field-id (data/id :venues :price)]] - [[:< [:field-id (data/id :venues :price)] 4] [:field-id (data/id :venues :price)]]]]])))) + (is (= 179.0 (test-case [:sum [:case [[[:< [:field-id (mt/id :venues :price)] 2] [:field-id (mt/id :venues :price)]] + [[:< [:field-id (mt/id :venues :price)] 4] [:field-id (mt/id :venues :price)]]]]])))) (testing "Test else clause" - (is (= 122.0 (test-case [:sum [:case [[[:< [:field-id (data/id :venues :price)] 2] 2]] + (is (= 122.0 (test-case [:sum [:case [[[:< [:field-id (mt/id :venues :price)] 2] 2]] {:default 1}]])))) (testing "Test implicit else (= nil) clause" ;; Some DBs return 0 for sum of nulls. - (is ((some-fn nil? zero?) (test-case [:sum [:case [[[:> [:field-id (data/id :venues :price)] 200] 2]]]])))) + (is ((some-fn nil? zero?) (test-case [:sum [:case [[[:> [:field-id (mt/id :venues :price)] 200] 2]]]])))) (testing "Test complex filters" (is (= 34.0 (test-case [:sum - [:case [[[:and [:< [:field-id (data/id :venues :price)] 4] - [:or [:starts-with [:field-id (data/id :venues :name)] "M"] - [:ends-with [:field-id (data/id :venues :name)] "t"]]] - [:field-id (data/id :venues :price)]]]]])))) + [:case [[[:and [:< [:field-id (mt/id :venues :price)] 4] + [:or [:starts-with [:field-id (mt/id :venues :name)] "M"] + [:ends-with [:field-id (mt/id :venues :name)] "t"]]] + [:field-id (mt/id :venues :price)]]]]])))) (testing "Can we use segments in case" - (tt/with-temp* [Segment [{segment-id :id} {:table_id (data/id :venues) - :definition {:source-table (data/id :venues) - :filter [:< [:field-id (data/id :venues :price)] 4]}}]] - (is (= 179.0 (test-case [:sum [:case [[[:segment segment-id] [:field-id (data/id :venues :price)]]]]]))))) + (mt/with-temp Segment [{segment-id :id} {:table_id (mt/id :venues) + :definition {:source-table (mt/id :venues) + :filter [:< [:field-id (mt/id :venues :price)] 4]}}] + (is (= 179.0 (test-case [:sum [:case [[[:segment segment-id] [:field-id (mt/id :venues :price)]]]]]))))) (testing "Can we use case in metric" - (tt/with-temp* [Metric [{metric-id :id} {:table_id (data/id :venues) - :definition {:source-table (data/id :venues) - :aggregation [:sum - [:case [[[:< [:field-id (data/id :venues :price)] 4] - [:field-id (data/id :venues :price)]]]]]}}]] + (mt/with-temp Metric [{metric-id :id} {:table_id (mt/id :venues) + :definition {:source-table (mt/id :venues) + :aggregation [:sum + [:case [[[:< [:field-id (mt/id :venues :price)] 4] + [:field-id (mt/id :venues :price)]]]]]}}] (is (= 179.0 (test-case [:metric metric-id]))))) (testing "Can we use case with breakout" (is (= [[2 0.0] [3 0.0] [4 1.0] [5 1.0]] - (->> {:aggregation [[:sum [:case [[[:< [:field-id (data/id :venues :price)] 2] [:field-id (data/id :venues :price)]]] + (->> {:aggregation [[:sum [:case [[[:< [:field-id (mt/id :venues :price)] 2] [:field-id (mt/id :venues :price)]]] {:default 0}]]] - :breakout [[:field-id (data/id :venues :category_id)]] + :breakout [[:field-id (mt/id :venues :category_id)]] :limit 4} (mt/run-mbql-query venues) - (tu/round-all-decimals 2) - rows + (mt/round-all-decimals 2) + mt/rows (map (fn [[k v]] [(long k) (double v)])))))))) (deftest test-case-aggregations+expressions (mt/test-drivers (mt/normal-drivers-with-feature :basic-aggregations :expressions) (testing "Can we use case in metric expressions" - (is (= 90.5 (test-case [:+ [:/ [:sum [:case [[[:< [:field-id (data/id :venues :price)] 4] [:field-id (data/id :venues :price)]]] + (is (= 90.5 (test-case [:+ [:/ [:sum [:case [[[:< [:field-id (mt/id :venues :price)] 4] [:field-id (mt/id :venues :price)]]] {:default 0}]] 2] 1])))) (testing "Can use expressions as values" - (is (= 194.5 (test-case [:sum [:case [[[:< [:field-id (data/id :venues :price)] 2] [:+ [:field-id (data/id :venues :price)] 1]] - [[:< [:field-id (data/id :venues :price)] 4] [:+ [:/ [:field-id (data/id :venues :price)] 2] 1]]]]])))))) + (is (= 194.5 (test-case [:sum [:case [[[:< [:field-id (mt/id :venues :price)] 2] [:+ [:field-id (mt/id :venues :price)] 1]] + [[:< [:field-id (mt/id :venues :price)] 4] [:+ [:/ [:field-id (mt/id :venues :price)] 2] 1]]]]])))))) (deftest test-case-normalization (mt/test-drivers (mt/normal-drivers-with-feature :basic-aggregations) - (is (= 116.0 (test-case ["sum" ["case" [[["<" ["field-id" (data/id :venues :price)] 2] 2] - [["<" ["field-id" (data/id :venues :price)] 4] 1]] ]]))))) + (is (= 116.0 (test-case ["sum" ["case" [[["<" ["field-id" (mt/id :venues :price)] 2] 2] + [["<" ["field-id" (mt/id :venues :price)] 4] 1]] ]]))))) (deftest test-case-expressions (mt/test-drivers (mt/normal-drivers-with-feature :expressions) (is (= [nil -2.0 -1.0] - (->> {:expressions {"case_test" [:case [[[:< [:field-id (data/id :venues :price)] 2] -1.0] - [[:< [:field-id (data/id :venues :price)] 3] -2.0]] ]} + (->> {:expressions {"case_test" [:case [[[:< [:field-id (mt/id :venues :price)] 2] -1.0] + [[:< [:field-id (mt/id :venues :price)] 3] -2.0]] ]} :fields [[:expression "case_test"]]} (mt/run-mbql-query venues) - rows + mt/rows (map (comp #(some-> % double) first)) distinct sort)))))