diff --git a/bin/osx-release b/bin/osx-release index eac9c57b94a732213c2e0f7e89802aa2c560f5f2..3660b92c98950bbe0c5fde5f3e71750b2347339b 100755 --- a/bin/osx-release +++ b/bin/osx-release @@ -93,36 +93,68 @@ sub build { remove_tree($xcarchive); } -# Codesign Metabase.app -sub codesign { +sub codesign_file { + my ($filename) = @_; + Readonly my $codesigning_cert_name => config_or_die('codesigningIdentity'); + Readonly my $entitlements_file => get_file_or_die('OSX/Metabase/Metabase.entitlements'); - announce "Codesigning $app..."; + announce "Codesigning $filename..."; system('codesign', '--force', '--verify', '--sign', $codesigning_cert_name, '-r=designated => anchor trusted', '--timestamp', '--options', 'runtime', - '--deep', get_file_or_die($app)) == 0 or die "Code signing failed: $!\n"; + '--entitlements', $entitlements_file, + '--deep', get_file_or_die($filename)) == 0 or die "Code signing failed: $!\n"; } -# Verify that Metabase.app was signed correctly -sub verify_codesign { +# Codesign Metabase.app +sub codesign { + codesign_file($app) or die $1; +} + +sub verify_file_codesign { + my ($filename) = @_; + get_file_or_die($filename); + config_or_die('codesigningIdentity'); - announce "Verifying codesigning for $app..."; + announce "Verifying codesigning for $filename..."; + + system('codesign', '--verify', '--deep', + '--display', + '--strict', + '--verbose=4', + get_file_or_die($filename)) == 0 or die "Code signing verification failed: $!\n"; - system('codesign', '--verify', '--deep', '--display', - '--verbose=4', get_file_or_die($app)) == 0 or die "Code signing verification failed: $!\n"; + announce "codesign --verify $filename successful"; # Double-check with System Policy Security tool - system('spctl', '--assess', '--verbose=4', get_file_or_die($app)) == 0 + system('spctl', '--assess', '--verbose=4', get_file_or_die($filename)) == 0 or die "Codesigning verification (spctl) failed: $!\n"; + + announce "spctl --assess $filename successful"; + +} + +# Verify that Metabase.app was signed correctly +sub verify_codesign { + verify_file_codesign($app) or die $!; } # ------------------------------------------------------------ PACKAGING FOR SPARKLE ------------------------------------------------------------ +sub verify_zip_codesign { + remove_tree('/tmp/Metabase.zip'); + + system('unzip', get_file_or_die($zipfile), + '-d', '/tmp/Metabase.zip'); + + verify_file_codesign('/tmp/Metabase.zip/Metabase.app') or die $!; +} + # Create ZIP containing Metabase.app sub archive { announce "Creating $zipfile..."; @@ -131,8 +163,11 @@ sub archive { get_file_or_die($app); - system('cd ' . OSX_ARTIFACTS_DIR . ' && zip -r Metabase.zip Metabase.app') == 0 or die $!; + # Use ditto instead of zip to preserve the codesigning -- see https://forums.developer.apple.com/thread/116831 + system('cd ' . OSX_ARTIFACTS_DIR . ' && ditto -c -k --sequesterRsrc --keepParent Metabase.app Metabase.zip') == 0 or die $!; get_file_or_die($zipfile); + + verify_zip_codesign; } sub generate_signature { @@ -323,6 +358,9 @@ sub notarize_file { '--asc-provider', $ascProvider, '--file', $filename ) == 0 or die $!; + + print 'You can keep an eye on the notarization status (and get the LogFileURL) with the command:' . "\n\n"; + print ' xcrun altool --notarization-info <RequestUUID> -u "$METABASE_MAC_APP_BUILD_APPLE_ID" -p "@keychain:METABASE_MAC_APP_BUILD_PASSWORD"' . "\n\n"; } sub wait_for_notarization { @@ -351,7 +389,7 @@ sub staple_notorization { announce "Stapling notarization to $filename..."; system('xcrun', 'stapler', 'staple', - '-v', $filename) == 0 or die $1; + '-v', $filename) == 0 or die $!; announce "Notarization stapled successfully."; } diff --git a/frontend/src/metabase-lib/lib/Dimension.js b/frontend/src/metabase-lib/lib/Dimension.js index 96ba79779f9e28a5f7fb957441d7af38cb51e672..24efb7ac781e7b1709561589e81676042c6fac92 100644 --- a/frontend/src/metabase-lib/lib/Dimension.js +++ b/frontend/src/metabase-lib/lib/Dimension.js @@ -5,7 +5,7 @@ import { stripId, FK_SYMBOL } from "metabase/lib/formatting"; import { TYPE } from "metabase/lib/types"; import Field from "./metadata/Field"; -import Metadata from "./metadata/Metadata"; +import type Metadata from "./metadata/Metadata"; import type { ConcreteField, @@ -37,6 +37,7 @@ type DimensionOption = { * - DatetimeFieldDimension * - ExpressionDimension * - AggregationDimension + * - TemplateTagDimension */ /** @@ -51,6 +52,7 @@ export default class Dimension { _parent: ?Dimension; _args: any; _metadata: ?Metadata; + _query: ?Query; // Display names provided by the backend _subDisplayName: ?String; @@ -977,6 +979,31 @@ export class JoinedDimension extends FieldDimension { } } +export class TemplateTagDimension extends FieldDimension { + dimension() { + if (this._query) { + const tag = this._query.templateTagsMap()[this.tagName()]; + if (tag && tag.type === "dimension") { + return this.parseMBQL(tag.dimension); + } + } + return null; + } + + field() { + const dimension = this.dimension(); + return dimension ? dimension.field() : super.field(); + } + + tagName() { + return this._args[0]; + } + + mbql() { + return ["template-tag", this.tagName()]; + } +} + const DIMENSION_TYPES: typeof Dimension[] = [ FieldIDDimension, FieldLiteralDimension, diff --git a/frontend/src/metabase-lib/lib/DimensionOptions.js b/frontend/src/metabase-lib/lib/DimensionOptions.js index c95f676a1c196b0d9e0d16a0ec88151dbfb66668..4f0578c5df76d969e4e4da17d31d4643194ae2bf 100644 --- a/frontend/src/metabase-lib/lib/DimensionOptions.js +++ b/frontend/src/metabase-lib/lib/DimensionOptions.js @@ -1,21 +1,30 @@ -import { t } from "ttag"; - import Dimension from "metabase-lib/lib/Dimension"; -import { stripId, singularize } from "metabase/lib/formatting"; + +import type Field from "metabase-lib/lib/metadata/Field"; + +type Option = { + dimension: Dimension, +}; + +type Section = { + name: string, + icon: string, + items: Option[], +}; export default class DimensionOptions { - count: number; - dimensions: Dimension[]; + count: number = 0; + dimensions: Dimension[] = []; fks: Array<{ - field: FieldMetadata, + field: Field, dimensions: Dimension[], - }>; + }> = []; constructor(o) { Object.assign(this, o); } - all() { + all(): Dimension { return [].concat(this.dimensions, ...this.fks.map(fk => fk.dimensions)); } @@ -28,10 +37,10 @@ export default class DimensionOptions { return false; } - sections({ extraItems = [] } = {}) { + sections({ extraItems = [] } = {}): Section[] { const table = this.dimensions[0] && this.dimensions[0].field().table; const mainSection = { - name: this.name || (table && singularize(table.display_name)), + name: this.name || (table && table.objectName()), icon: this.icon || "table2", items: [ ...extraItems, @@ -40,7 +49,7 @@ export default class DimensionOptions { }; const fkSections = this.fks.map(fk => ({ - name: fk.name || stripId(fk.field.display_name), + name: fk.name || (fk.field && fk.field.targetObjectName()), icon: fk.icon || "connections", items: fk.dimensions.map(dimension => ({ dimension })), })); @@ -53,100 +62,4 @@ export default class DimensionOptions { return sections; } - - sectionsByType() { - const itemsBySection = {}; - const addItem = item => { - const field = item.dimension.field(); - for (const [key, { is }] of Object.entries(SECTIONS_BY_TYPE)) { - if (is(field)) { - itemsBySection[key] = itemsBySection[key] || []; - itemsBySection[key].push(item); - return; - } - } - }; - - for (const dimension of this.dimensions) { - addItem({ - name: dimension.displayName(), - icon: null, - dimension: dimension, - }); - } - for (const fk of this.fks) { - const fkName = stripId(fk.field.display_name); - for (const dimension of fk.dimensions) { - addItem({ - name: fkName + " — " + dimension.displayName(), - icon: null, - dimension: dimension, - }); - } - } - - return SECTIONS_BY_TYPE_DISPLAY_ORDER.filter( - key => itemsBySection[key], - ).map(key => ({ - name: SECTIONS_BY_TYPE[key].name, - icon: SECTIONS_BY_TYPE[key].icon, - items: itemsBySection[key], - })); - } } - -// this is the actual order we should display the sections in -const SECTIONS_BY_TYPE_DISPLAY_ORDER = [ - "date", - "location", - "boolean", - "category", - "number", - "text", - "connection", - "other", -]; - -// these are ordered by priority of categorizing fields -// e.x. if a field is a fk it should be considered a "connection" even if it is also a "number" or "text" because it comes first -const SECTIONS_BY_TYPE = { - connection: { - name: t`Connection`, - icon: "connections", - is: f => f.isFK(), - }, - date: { - name: t`Date`, - icon: "calendar", - is: f => f.isDate() || f.isTime(), - }, - location: { - name: t`Location`, - icon: "location", - is: f => f.isLocation() || f.isCoordinate(), - }, - boolean: { - name: t`Boolean`, - icon: "io", - is: f => f.isBoolean(), - }, - category: { - name: t`Category`, - is: f => f.isCategory(), - }, - number: { - name: t`Number`, - icon: "int", - is: f => f.isNumber(), - }, - text: { - name: t`Text`, - icon: "string", - is: f => f => f.isString(), - }, - other: { - name: t`Other`, - icon: "unknown", - is: f => true, - }, -}; diff --git a/frontend/src/metabase-lib/lib/Variable.js b/frontend/src/metabase-lib/lib/Variable.js new file mode 100644 index 0000000000000000000000000000000000000000..61133682162631e23fbd3b5c88b40f70a25903bd --- /dev/null +++ b/frontend/src/metabase-lib/lib/Variable.js @@ -0,0 +1,45 @@ +/* @flow */ + +import type Metadata from "./metadata/Metadata"; +import type Query from "./queries/Query"; +import type { TemplateTag } from "metabase/meta/types/Query"; + +import NativeQuery from "./queries/NativeQuery"; + +export default class Variable { + _args: any; + _metadata: ?Metadata; + _query: ?Query; + + constructor(args: any[], metadata?: Metadata, query?: Query) { + this._args = args; + this._metadata = metadata || (query && query.metadata()); + this._query = query; + } +} + +const VARIABLE_ICONS = { + text: "string", + number: "int", + date: "calendar", + dimension: null, +}; + +export class TemplateTagVariable extends Variable { + tag(): ?TemplateTag { + if (this._query instanceof NativeQuery) { + return this._query.templateTagsMap()[this._args[0]]; + } + } + displayName() { + const tag = this.tag(); + return tag && (tag["display-name"] || tag.name); + } + icon() { + const tag = this.tag(); + return tag && VARIABLE_ICONS[tag.type]; + } + mbql() { + return ["template-tag", this._args[0]]; + } +} diff --git a/frontend/src/metabase-lib/lib/metadata/Database.js b/frontend/src/metabase-lib/lib/metadata/Database.js index 7cea1df475ef32cb46642871c7be9e43b7359505..13162a62bc921a1163e01ff107b322b37400ed27 100644 --- a/frontend/src/metabase-lib/lib/metadata/Database.js +++ b/frontend/src/metabase-lib/lib/metadata/Database.js @@ -65,23 +65,34 @@ export default class Database extends Base { .setDefaultDisplay(); } - question(): Question { + question(query = { "source-table": null }): Question { return Question.create({ - databaseId: this.id, metadata: this.metadata, + dataset_query: { + database: this.id, + type: "query", + query: query, + }, }); } - nativeQuestion(): Question { + nativeQuestion(native = {}): Question { return Question.create({ - databaseId: this.id, metadata: this.metadata, - native: "native", + dataset_query: { + database: this.id, + type: "native", + native: { + query: "", + "template-tags": {}, + ...native, + }, + }, }); } - nativeQuery() { - return this.nativeQuestion().query(); + nativeQuery(native) { + return this.nativeQuestion(native).query(); } /** Returns a database containing only the saved questions from the same database, if any */ diff --git a/frontend/src/metabase-lib/lib/metadata/Field.js b/frontend/src/metabase-lib/lib/metadata/Field.js index 6ba91a615417072f6ce72bac9886cce9f07fc630..f981ddb88fbb8fe317b85022144a3b6d25b24253 100644 --- a/frontend/src/metabase-lib/lib/metadata/Field.js +++ b/frontend/src/metabase-lib/lib/metadata/Field.js @@ -19,7 +19,9 @@ import { isSummable, isCategory, isAddress, + isCity, isState, + isZipCode, isCountry, isCoordinate, isLocation, @@ -73,8 +75,13 @@ export default class Field extends Base { return displayName; } - targetDisplayName() { - return stripId(this.display_name); + /** + * The name of the object type this field points to. + * Currently we try to guess this by stripping trailing `ID` from `display_name`, but ideally it would be configurable in metadata + * See also `table.objectName()` + */ + targetObjectName() { + return stripId(this.displayName()); } isDate() { @@ -98,6 +105,12 @@ export default class Field extends Base { isAddress() { return isAddress(this); } + isCity() { + return isCity(this); + } + isZipCode() { + return isZipCode(this); + } isState() { return isState(this); } diff --git a/frontend/src/metabase-lib/lib/metadata/Table.js b/frontend/src/metabase-lib/lib/metadata/Table.js index e5b944a358c6f734e68401b0da76b964197ee513..4c286777ece0ecad276bd9907f4565eecc1cf4ac 100644 --- a/frontend/src/metabase-lib/lib/metadata/Table.js +++ b/frontend/src/metabase-lib/lib/metadata/Table.js @@ -10,7 +10,7 @@ import Field from "./Field"; import type { SchemaName } from "metabase/meta/types/Table"; import type { FieldMetadata } from "metabase/meta/types/Metadata"; -import { titleize, humanize } from "metabase/lib/formatting"; +import { titleize, singularize, humanize } from "metabase/lib/formatting"; import Dimension from "../Dimension"; @@ -75,6 +75,15 @@ export default class Table extends Base { ); } + /** + * The singular form of the object type this table represents + * Currently we try to guess this by singularizing `display_name`, but ideally it would be configurable in metadata + * See also `field.targetObjectName()` + */ + objectName() { + return singularize(this.displayName()); + } + dateFields(): Field[] { return this.fields.filter(field => field.isDate()); } diff --git a/frontend/src/metabase-lib/lib/queries/NativeQuery.js b/frontend/src/metabase-lib/lib/queries/NativeQuery.js index d724f29181c3891943403ca998efaca9197aac0a..2bc4bfff321a49d0a5ba124c4ef0933211af9a3f 100644 --- a/frontend/src/metabase-lib/lib/queries/NativeQuery.js +++ b/frontend/src/metabase-lib/lib/queries/NativeQuery.js @@ -28,6 +28,13 @@ import type { DatabaseEngine, DatabaseId } from "metabase/meta/types/Database"; import AtomicQuery from "metabase-lib/lib/queries/AtomicQuery"; +import Dimension, { TemplateTagDimension } from "../Dimension"; +import Variable, { TemplateTagVariable } from "../Variable"; +import DimensionOptions from "../DimensionOptions"; + +type DimensionFilter = (dimension: Dimension) => boolean; +type VariableFilter = (variable: Variable) => boolean; + export const NATIVE_QUERY_TEMPLATE: NativeDatasetQuery = { database: null, type: "native", @@ -247,10 +254,39 @@ export default class NativeQuery extends AtomicQuery { ); } + setTemplateTag(name, tag) { + return this.setDatasetQuery( + assocIn(this.datasetQuery(), ["native", "template-tags", name], tag), + ); + } + setDatasetQuery(datasetQuery: DatasetQuery): NativeQuery { return new NativeQuery(this._originalQuestion, datasetQuery); } + dimensionOptions( + dimensionFilter: DimensionFilter = () => true, + ): DimensionOptions { + const dimensions = this.templateTags() + .filter(tag => tag.type === "dimension") + .map( + tag => + new TemplateTagDimension(null, [tag.name], this.metadata(), this), + ) + .filter(dimensionFilter); + return new DimensionOptions({ + dimensions: dimensions, + count: dimensions.length, + }); + } + + variables(variableFilter: VariableFilter = () => true): Variable[] { + return this.templateTags() + .filter(tag => tag.type !== "dimension") + .map(tag => new TemplateTagVariable([tag.name], this.metadata(), this)) + .filter(variableFilter); + } + /** * special handling for NATIVE cards to automatically detect parameters ... {{varname}} */ diff --git a/frontend/src/metabase-lib/lib/queries/Query.js b/frontend/src/metabase-lib/lib/queries/Query.js index 5fe57ea16732b928dd403eb434904c7b40eaba11..bbd9ed585bae658e17b9acef9e6a840205ba0495 100644 --- a/frontend/src/metabase-lib/lib/queries/Query.js +++ b/frontend/src/metabase-lib/lib/queries/Query.js @@ -5,8 +5,13 @@ import Database from "../metadata/Database"; import type { DatasetQuery } from "metabase/meta/types/Card"; import type Metadata from "metabase-lib/lib/metadata/Metadata"; import type Question from "metabase-lib/lib/Question"; +import type Dimension from "metabase-lib/lib/Dimension"; +import type Variable from "metabase-lib/lib/Variable"; + import { memoize } from "metabase-lib/lib/utils"; +import DimensionOptions from "metabase-lib/lib/DimensionOptions"; + type QueryUpdateFn = (datasetQuery: DatasetQuery) => void; /** @@ -91,6 +96,23 @@ export default class Query { return this._metadata.databasesList(); } + /** + * Dimensions exposed by this query + * NOTE: Ideally we'd also have `dimensions()` that returns a flat list, but currently StructuredQuery has it's own `dimensions()` for another purpose. + */ + dimensionOptions( + filter: (dimension: Dimension) => boolean, + ): DimensionOptions { + return new DimensionOptions(); + } + + /** + * Variables exposed by this query + */ + variables(filter: (variable: Variable) => boolean): Variable[] { + return []; + } + /** * Metadata this query needs to display correctly */ diff --git a/frontend/src/metabase-lib/lib/queries/StructuredQuery.js b/frontend/src/metabase-lib/lib/queries/StructuredQuery.js index ccb355bc4e099b6552928c178284b6d30dbc380f..4d90332ea833807df8ec46b74793fbb2bb595643 100644 --- a/frontend/src/metabase-lib/lib/queries/StructuredQuery.js +++ b/frontend/src/metabase-lib/lib/queries/StructuredQuery.js @@ -808,9 +808,9 @@ export default class StructuredQuery extends AtomicQuery { } filterFieldOptionSections(filter?: ?(Filter | FilterWrapper)) { - const filterFieldOptions = this.filterFieldOptions(); + const filterDimensionOptions = this.filterDimensionOptions(); const filterSegmentOptions = this.filterSegmentOptions(filter); - return filterFieldOptions.sections({ + return filterDimensionOptions.sections({ extraItems: filterSegmentOptions.map(segment => ({ name: segment.name, icon: "star_outline", @@ -856,8 +856,8 @@ export default class StructuredQuery extends AtomicQuery { /** * @returns @type {DimensionOptions} that can be used in filters. */ - filterFieldOptions(): DimensionOptions { - return this.fieldOptions(); + filterDimensionOptions(): DimensionOptions { + return this.dimensionOptions(); } /** @@ -896,7 +896,7 @@ export default class StructuredQuery extends AtomicQuery { canAddFilter(): boolean { return ( Q.canAddFilter(this.query()) && - (this.filterFieldOptions().count > 0 || + (this.filterDimensionOptions().count > 0 || this.filterSegmentOptions().length > 0) ); } diff --git a/frontend/src/metabase-lib/lib/queries/structured/Filter.js b/frontend/src/metabase-lib/lib/queries/structured/Filter.js index 9601700c24fe5ba67eef0e12f28558bc965421d6..7be047de8865c6f5575cc0dca8e42f0125c8859f 100644 --- a/frontend/src/metabase-lib/lib/queries/structured/Filter.js +++ b/frontend/src/metabase-lib/lib/queries/structured/Filter.js @@ -79,7 +79,7 @@ export default class Filter extends MBQLClause { const query = this.query(); if ( !dimension || - !(query && query.filterFieldOptions().hasDimension(dimension)) + !(query && query.filterDimensionOptions().hasDimension(dimension)) ) { return false; } diff --git a/frontend/src/metabase-lib/lib/queries/structured/Join.js b/frontend/src/metabase-lib/lib/queries/structured/Join.js index 84ecb8b25fe277fe3ff6192c53dfd86218897b43..a4365ab9cee509f5923002cce17c8e221f51a727 100644 --- a/frontend/src/metabase-lib/lib/queries/structured/Join.js +++ b/frontend/src/metabase-lib/lib/queries/structured/Join.js @@ -158,7 +158,7 @@ export default class Join extends MBQLObjectClause { setDefaultAlias() { const parentDimension = this.parentDimension(); if (parentDimension && parentDimension.field().isFK()) { - return this.setAlias(parentDimension.field().targetDisplayName()); + return this.setAlias(parentDimension.field().targetObjectName()); } else { const table = this.joinedTable(); // $FlowFixMe diff --git a/frontend/src/metabase/lib/api.js b/frontend/src/metabase/lib/api.js index 27f36d1b28633beea036ea5390a689e91d2cddfa..e2e035c60211a0cd6e30bcb7cbfdf40284593bc7 100644 --- a/frontend/src/metabase/lib/api.js +++ b/frontend/src/metabase/lib/api.js @@ -201,20 +201,24 @@ export class Api extends EventEmitter { body = JSON.parse(body); } catch (e) {} } - if (xhr.status >= 200 && xhr.status <= 299) { + let status = xhr.status; + if (status === 202 && body && body._status > 0) { + status = body._status; + } + if (status >= 200 && status <= 299) { if (options.transformResponse) { body = options.transformResponse(body, { data }); } resolve(body); } else { reject({ - status: xhr.status, + status: status, data: body, isCancelled: isCancelled, }); } if (!options.noEvent) { - this.emit(xhr.status, url); + this.emit(status, url); } } }; diff --git a/frontend/src/metabase/lib/schema_metadata.js b/frontend/src/metabase/lib/schema_metadata.js index 20f93742f6aad52bcd56af56cef19da2928acd9b..28adb5d50293c8df35f879be2b2f77c6afcb543f 100644 --- a/frontend/src/metabase/lib/schema_metadata.js +++ b/frontend/src/metabase/lib/schema_metadata.js @@ -151,12 +151,12 @@ export const isMetric = col => export const isFK = field => field && isTypeFK(field.special_type); export const isPK = field => field && isTypePK(field.special_type); export const isEntityName = field => - isa(field && field.special_type, TYPE.Name); + field && isa(field.special_type, TYPE.Name); export const isAny = col => true; export const isNumericBaseType = field => - isa(field && field.base_type, TYPE.Number); + field && isa(field.base_type, TYPE.Number); // ZipCode, ID, etc derive from Number but should not be formatted as numbers export const isNumber = field => @@ -166,34 +166,37 @@ export const isNumber = field => export const isBinnedNumber = field => isNumber(field) && !!field.binning_info; -export const isTime = field => isa(field && field.base_type, TYPE.Time); +export const isTime = field => field && isa(field.base_type, TYPE.Time); export const isAddress = field => - isa(field && field.special_type, TYPE.Address); -export const isState = field => isa(field && field.special_type, TYPE.State); + field && isa(field.special_type, TYPE.Address); +export const isCity = field => field && isa(field.special_type, TYPE.City); +export const isState = field => field && isa(field.special_type, TYPE.State); +export const isZipCode = field => + field && isa(field.special_type, TYPE.ZipCode); export const isCountry = field => - isa(field && field.special_type, TYPE.Country); + field && isa(field.special_type, TYPE.Country); export const isCoordinate = field => - isa(field && field.special_type, TYPE.Coordinate); + field && isa(field.special_type, TYPE.Coordinate); export const isLatitude = field => - isa(field && field.special_type, TYPE.Latitude); + field && isa(field.special_type, TYPE.Latitude); export const isLongitude = field => - isa(field && field.special_type, TYPE.Longitude); + field && isa(field.special_type, TYPE.Longitude); export const isCurrency = field => - isa(field && field.special_type, TYPE.Currency); + field && isa(field.special_type, TYPE.Currency); export const isDescription = field => - isa(field && field.special_type, TYPE.Description); + field && isa(field.special_type, TYPE.Description); export const isID = field => isFK(field) || isPK(field); -export const isURL = field => isa(field && field.special_type, TYPE.URL); -export const isEmail = field => isa(field && field.special_type, TYPE.Email); +export const isURL = field => field && isa(field.special_type, TYPE.URL); +export const isEmail = field => field && isa(field.special_type, TYPE.Email); export const isAvatarURL = field => - isa(field && field.special_type, TYPE.AvatarURL); + field && isa(field.special_type, TYPE.AvatarURL); export const isImageURL = field => - isa(field && field.special_type, TYPE.ImageURL); + field && isa(field.special_type, TYPE.ImageURL); // filter operator argument constructors: diff --git a/frontend/src/metabase/meta/Dashboard.js b/frontend/src/metabase/meta/Dashboard.js index bdb9b09da4efb6f4210cce84d558bbf6be8cd1ca..e18e51183c2f6867f03ff211a0cdded7c2ae887f 100644 --- a/frontend/src/metabase/meta/Dashboard.js +++ b/frontend/src/metabase/meta/Dashboard.js @@ -1,10 +1,9 @@ /* @flow */ -import Metadata from "metabase-lib/lib/metadata/Metadata"; -import Table from "metabase-lib/lib/metadata/Table"; -import Field from "metabase-lib/lib/metadata/Field"; +import Question from "metabase-lib/lib/Question"; -import type { FieldId } from "./types/Field"; +import type Metadata from "metabase-lib/lib/metadata/Metadata"; +import type Field from "metabase-lib/lib/metadata/Field"; import type { TemplateTag } from "./types/Query"; import type { Card } from "./types/Card"; import type { @@ -12,19 +11,25 @@ import type { Parameter, ParameterType, ParameterMappingUIOption, - DimensionTarget, - VariableTarget, } from "./types/Parameter"; -import { t } from "ttag"; -import { getTemplateTags } from "./Card"; -import { slugify, stripId } from "metabase/lib/formatting"; -import * as Q_DEPRECATED from "metabase/lib/query"; -import { TYPE, isa } from "metabase/lib/types"; +import Dimension, { + FKDimension, + JoinedDimension, +} from "metabase-lib/lib/Dimension"; +import Variable, { TemplateTagVariable } from "metabase-lib/lib/Variable"; +import { t } from "ttag"; import _ from "underscore"; -export const PARAMETER_OPTIONS: Array<ParameterOption> = [ +import { slugify } from "metabase/lib/formatting"; + +type DimensionFilter = (dimension: Dimension) => boolean; +type TemplateTagFilter = (tag: TemplateTag) => boolean; +type FieldFilter = (field: Field) => boolean; +type VariableFilter = (variable: Variable) => boolean; + +export const PARAMETER_OPTIONS: ParameterOption[] = [ { type: "date/month-year", name: t`Month and Year`, @@ -86,10 +91,10 @@ export type ParameterSection = { id: string, name: string, description: string, - options: Array<ParameterOption>, + options: ParameterOption[], }; -export const PARAMETER_SECTIONS: Array<ParameterSection> = [ +export const PARAMETER_SECTIONS: ParameterSection[] = [ { id: "date", name: t`Time`, @@ -128,153 +133,11 @@ for (const option of PARAMETER_OPTIONS) { } } -type Dimension = { - name: string, - parentName: string, - target: DimensionTarget, - field_id: number, - depth: number, -}; - -type Variable = { - name: string, - target: VariableTarget, - type: string, -}; - -type FieldFilter = (field: Field) => boolean; -type TemplateTagFilter = (tag: TemplateTag) => boolean; - -export function getFieldDimension(field: Field): Dimension { - return { - name: field.display_name, - field_id: field.id, - parentName: field.table.display_name, - target: ["field-id", field.id], - depth: 0, - }; -} - -export function getTagDimension( - tag: TemplateTag, - dimension: Dimension, -): Dimension { - return { - name: dimension.name, - parentName: dimension.parentName, - target: ["template-tag", tag.name], - field_id: dimension.field_id, - depth: 0, - }; -} - -export function getCardDimensions( - metadata: Metadata, - card: Card, - filter: FieldFilter = () => true, -): Array<Dimension> { - if (card.dataset_query.type === "query") { - const table = - card.dataset_query.query["source-table"] != null - ? metadata.tables[card.dataset_query.query["source-table"]] - : null; - if (table) { - return getTableDimensions(table, 1, filter); - } - } else if (card.dataset_query.type === "native") { - const dimensions = []; - for (const tag of getTemplateTags(card)) { - if ( - tag.type === "dimension" && - Array.isArray(tag.dimension) && - tag.dimension[0] === "field-id" - ) { - const field = metadata.fields[tag.dimension[1]]; - if (field && filter(field)) { - const fieldDimension = getFieldDimension(field); - dimensions.push(getTagDimension(tag, fieldDimension)); - } - } - } - return dimensions; - } - return []; -} - -function getDimensionTargetFieldId(target: DimensionTarget): ?FieldId { - if (Array.isArray(target) && target[0] === "template-tag") { - return null; - } else { - return Q_DEPRECATED.getFieldTargetId(target); - } -} - -export function getTableDimensions( - table: Table, - depth: number, - filter: FieldFilter = () => true, -): Array<Dimension> { - return _.chain(table.fields) - .map(field => { - const targetField = field.target; - if (targetField && depth > 0 && targetField.table) { - const targetTable = targetField.table; - const dimensions = getTableDimensions( - targetTable, - depth - 1, - filter, - ).map((dimension: Dimension) => ({ - ...dimension, - parentName: stripId(field.display_name), - target: [ - "fk->", - field.id, - getDimensionTargetFieldId(dimension.target), - ], - depth: dimension.depth + 1, - })); - if (filter(field)) { - dimensions.push(getFieldDimension(field)); - } - return dimensions; - } else if (filter(field)) { - return [getFieldDimension(field)]; - } - }) - .flatten() - .filter(dimension => dimension != null) - .value(); -} - -export function getCardVariables( - metadata: Metadata, - card: Card, - filter: TemplateTagFilter = () => true, -): Array<Variable> { - if (card.dataset_query.type === "native") { - const variables = []; - for (const tag of getTemplateTags(card)) { - if (!filter || filter(tag)) { - variables.push({ - name: tag["display-name"] || tag.name, - type: tag.type, - target: ["template-tag", tag.name], - }); - } - } - return variables; - } - return []; -} - -function fieldFilterForParameter(parameter: ?Parameter = null) { - if (!parameter) { - return () => true; - } +function fieldFilterForParameter(parameter: Parameter) { return fieldFilterForParameterType(parameter.type); } -export function fieldFilterForParameterType( +function fieldFilterForParameterType( parameterType: ParameterType, ): FieldFilter { const [type] = parameterType.split("/"); @@ -289,13 +152,13 @@ export function fieldFilterForParameterType( switch (parameterType) { case "location/city": - return (field: Field) => isa(field.special_type, TYPE.City); + return (field: Field) => field.isCity(); case "location/state": - return (field: Field) => isa(field.special_type, TYPE.State); + return (field: Field) => field.isState(); case "location/zip_code": - return (field: Field) => isa(field.special_type, TYPE.ZipCode); + return (field: Field) => field.isZipCode(); case "location/country": - return (field: Field) => isa(field.special_type, TYPE.Country); + return (field: Field) => field.isCountry(); } return (field: Field) => false; } @@ -306,12 +169,23 @@ export function parameterOptionsForField(field: Field): ParameterOption[] { ); } -function tagFilterForParameter( - parameter: ?Parameter = null, -): TemplateTagFilter { - if (!parameter) { - return () => true; - } +function dimensionFilterForParameter(parameter: Parameter): DimensionFilter { + const fieldFilter = fieldFilterForParameter(parameter); + return dimension => fieldFilter(dimension.field()); +} + +function variableFilterForParameter(parameter: Parameter): VariableFilter { + const tagFilter = tagFilterForParameter(parameter); + return variable => { + if (variable instanceof TemplateTagVariable) { + const tag = variable.tag(); + return tag ? tagFilter(tag) : false; + } + return false; + }; +} + +function tagFilterForParameter(parameter: Parameter): TemplateTagFilter { const [type, subtype] = parameter.type.split("/"); switch (type) { case "date": @@ -326,48 +200,43 @@ function tagFilterForParameter( return (tag: TemplateTag) => false; } -const VARIABLE_ICONS = { - text: "string", - number: "int", - date: "calendar", -}; - export function getParameterMappingOptions( metadata: Metadata, parameter: ?Parameter = null, card: Card, -): Array<ParameterMappingUIOption> { +): ParameterMappingUIOption[] { const options = []; + const query = new Question(card, metadata).query(); + // dimensions options.push( - ...getCardDimensions( - metadata, - card, - fieldFilterForParameter(parameter), - ).map((dimension: Dimension) => { - const field = metadata.fields[dimension.field_id]; - return { - name: dimension.name, - target: ["dimension", dimension.target], - icon: field && field.icon(), - sectionName: dimension.parentName, - isFk: dimension.depth > 0, - }; - }), + ...query + .dimensionOptions(parameter && dimensionFilterForParameter(parameter)) + .sections() + .flatMap(section => + section.items.map(({ dimension }) => ({ + sectionName: section.name, + name: dimension.displayName(), + icon: dimension.icon(), + target: ["dimension", dimension.mbql()], + isForeign: + dimension instanceof FKDimension || + dimension instanceof JoinedDimension, + })), + ), ); // variables options.push( - ...getCardVariables(metadata, card, tagFilterForParameter(parameter)).map( - (variable: Variable) => ({ - name: variable.name, - target: ["variable", variable.target], - icon: VARIABLE_ICONS[variable.type], + ...query + .variables(parameter && variableFilterForParameter(parameter)) + .map(variable => ({ sectionName: "Variables", - isVariable: true, - }), - ), + name: variable.displayName(), + icon: variable.icon(), + target: ["variable", variable.mbql()], + })), ); return options; @@ -375,7 +244,7 @@ export function getParameterMappingOptions( export function createParameter( option: ParameterOption, - parameters: Array<ParameterOption> = [], + parameters: Parameter[] = [], ): Parameter { let name = option.name; let nameIndex = 0; diff --git a/frontend/src/metabase/meta/types/Parameter.js b/frontend/src/metabase/meta/types/Parameter.js index 6c38202311bd8e233898c3c0a4152f8240226795..c4f695d1ecfe749cd0214902bcf60240c8799c61 100644 --- a/frontend/src/metabase/meta/types/Parameter.js +++ b/frontend/src/metabase/meta/types/Parameter.js @@ -58,8 +58,7 @@ export type ParameterInstance = { export type ParameterMappingUIOption = ParameterMappingOption & { icon: ?string, sectionName: string, - isFk?: boolean, - isVariable?: boolean, + isForeign?: boolean, }; export type ParameterValues = { diff --git a/frontend/src/metabase/parameters/components/ParameterTargetList.jsx b/frontend/src/metabase/parameters/components/ParameterTargetList.jsx index 51ca14ed7482cbc2dc06e0065fca4ecf4dcca709..bac6b1ddfdfb6c750be8a0bbb40e31ac654028b2 100644 --- a/frontend/src/metabase/parameters/components/ParameterTargetList.jsx +++ b/frontend/src/metabase/parameters/components/ParameterTargetList.jsx @@ -26,7 +26,7 @@ export default class ParameterTargetList extends React.Component { const mappingOptionSections = _.groupBy(mappingOptions, "sectionName"); - const hasFkOption = _.any(mappingOptions, o => !!o.isFk); + const hasForeignOption = _.any(mappingOptions, o => !!o.isForeign); const sections = _.map(mappingOptionSections, options => ({ name: options[0].sectionName, @@ -44,7 +44,7 @@ export default class ParameterTargetList extends React.Component { <Icon name={item.icon || "unknown"} size={18} /> )} alwaysExpanded={true} - hideSingleSectionTitle={!hasFkOption} + hideSingleSectionTitle={!hasForeignOption} /> ); } diff --git a/frontend/src/metabase/reference/components/GuideDetail.jsx b/frontend/src/metabase/reference/components/GuideDetail.jsx deleted file mode 100644 index acd0a1c3d960219d1f260600edb8cbb43dedc6ab..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/reference/components/GuideDetail.jsx +++ /dev/null @@ -1,166 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import { Link } from "react-router"; -import pure from "recompose/pure"; -import cx from "classnames"; -import { t } from "ttag"; -import Icon from "metabase/components/Icon"; -import * as Urls from "metabase/lib/urls"; - -import { getQuestionUrl, has, typeToBgClass, typeToLinkClass } from "../utils"; - -const GuideDetail = ({ - entity = {}, - tables, - type, - exploreLinks, - detailLabelClasses, -}) => { - const title = entity.display_name || entity.name; - const { caveats, points_of_interest } = entity; - const typeToLink = { - dashboard: Urls.dashboard(entity.id), - metric: getQuestionUrl({ - dbId: tables[entity.table_id] && tables[entity.table_id].db_id, - tableId: entity.table_id, - metricId: entity.id, - }), - segment: getQuestionUrl({ - dbId: tables[entity.table_id] && tables[entity.table_id].db_id, - tableId: entity.table_id, - segmentId: entity.id, - }), - table: getQuestionUrl({ - dbId: entity.db_id, - tableId: entity.id, - }), - }; - const link = typeToLink[type]; - const typeToLearnMoreLink = { - metric: `/reference/metrics/${entity.id}`, - segment: `/reference/segments/${entity.id}`, - table: `/reference/databases/${entity.db_id}/tables/${entity.id}`, - }; - const learnMoreLink = typeToLearnMoreLink[type]; - - const linkClass = typeToLinkClass[type]; - const linkHoverClass = `${typeToLinkClass[type]}-hover`; - const bgClass = typeToBgClass[type]; - const hasLearnMore = - type === "metric" || type === "segment" || type === "table"; - - return ( - <div className="relative mt2 pb3"> - <div className="flex align-center"> - <div - style={{ - width: 40, - height: 40, - left: -60, - }} - className={cx( - "absolute text-white flex align-center justify-center", - bgClass, - )} - > - <Icon name={type === "metric" ? "ruler" : type} /> - </div> - {title && ( - <ItemTitle - link={link} - title={title} - linkColorClass={linkClass} - linkHoverClass={linkHoverClass} - /> - )} - </div> - <div className="mt2"> - <ContextHeading> - {type === "dashboard" - ? t`Why this ${type} is important` - : t`Why this ${type} is interesting`} - </ContextHeading> - - <ContextContent empty={!points_of_interest}> - {points_of_interest || - (type === "dashboard" - ? t`Nothing important yet` - : t`Nothing interesting yet`)} - </ContextContent> - - <div className="mt2"> - <ContextHeading> - {t`Things to be aware of about this ${type}`} - </ContextHeading> - - <ContextContent empty={!caveats}> - {caveats || t`Nothing to be aware of yet`} - </ContextContent> - </div> - - {has(exploreLinks) && [ - <div className="mt2"> - <ContextHeading key="detailLabel">{t`Explore this metric`}</ContextHeading> - <div key="detailLinks"> - <h4 className="inline-block mr2 link text-bold">{t`View this metric`}</h4> - {exploreLinks.map(link => ( - <Link - className="inline-block text-bold text-brand mr2 link" - key={link.url} - to={link.url} - > - {t`By ${link.name}`} - </Link> - ))} - </div> - </div>, - ]} - {hasLearnMore && ( - <Link - className={cx( - "block mt3 no-decoration text-underline-hover text-bold", - linkClass, - )} - to={learnMoreLink} - > - {t`Learn more`} - </Link> - )} - </div> - </div> - ); -}; - -GuideDetail.propTypes = { - entity: PropTypes.object, - type: PropTypes.string, - exploreLinks: PropTypes.array, -}; - -const ItemTitle = ({ title, link, linkColorClass, linkHoverClass }) => ( - <h2> - <Link - className={cx(linkColorClass, linkHoverClass)} - style={{ textDecoration: "none" }} - to={link} - > - {title} - </Link> - </h2> -); - -const ContextHeading = ({ children }) => ( - <h3 className="my2 text-medium">{children}</h3> -); - -const ContextContent = ({ empty, children }) => ( - <p - className={cx("m0 text-paragraph text-measure text-pre-wrap", { - "text-medium": empty, - })} - > - {children} - </p> -); - -export default pure(GuideDetail); diff --git a/frontend/src/metabase/reference/components/GuideDetailEditor.css b/frontend/src/metabase/reference/components/GuideDetailEditor.css deleted file mode 100644 index 643b609e513f78a83ab303e529169fffb793a4d3..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/reference/components/GuideDetailEditor.css +++ /dev/null @@ -1,16 +0,0 @@ -:local(.guideDetailEditor):last-child { - margin-bottom: 0; -} - -:local(.guideDetailEditorTextarea) { - composes: text-dark input p2 mb4 from "style"; - resize: none; - font-size: 16px; - width: 100%; - min-height: 100px; - background-color: unset; -} - -:local(.guideDetailEditorTextarea):last-child { - margin-bottom: 0; -} diff --git a/frontend/src/metabase/reference/components/GuideDetailEditor.jsx b/frontend/src/metabase/reference/components/GuideDetailEditor.jsx deleted file mode 100644 index 6ef9a106371c56f127c8fd71bd40b69b2b4fe390..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/reference/components/GuideDetailEditor.jsx +++ /dev/null @@ -1,224 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -// FIXME: using pure seems to mess with redux form updates -// import pure from "recompose/pure"; -import cx from "classnames"; -import { t } from "ttag"; -import S from "./GuideDetailEditor.css"; - -import Select from "metabase/components/Select"; -import Icon from "metabase/components/Icon"; -import Tooltip from "metabase/components/Tooltip"; - -import { typeToBgClass } from "../utils.js"; -import { SchemaTableAndSegmentDataSelector } from "metabase/query_builder/components/DataSelector"; - -const GuideDetailEditor = ({ - className, - type, - entities, - metadata = {}, - selectedIds = [], - selectedIdTypePairs = [], - formField, - removeField, - editLabelClasses, -}) => { - const { - databases, - tables, - segments, - metrics, - fields, - metricImportantFields, - } = metadata; - - const bgClass = typeToBgClass[type]; - const entityId = formField.id.value; - const disabled = - formField.id.value === null || formField.id.value === undefined; - const tableId = metrics && metrics[entityId] && metrics[entityId].table_id; - const tableFields = - (tables && tables[tableId] && tables[tableId].fields) || []; - const fieldsByMetric = - type === "metric" ? tableFields.map(fieldId => fields[fieldId]) : []; - - const selectClasses = "input h3 px2 py1"; - - const selectedIdsSet = new Set(selectedIds); - return ( - <div className={cx("mb2 border-bottom pb4 text-measure", className)}> - <div className="relative mt2 flex align-center"> - <div - style={{ - width: 40, - height: 40, - left: -60, - }} - className={cx( - "absolute text-white flex align-center justify-center", - bgClass, - )} - > - <Icon name={type === "metric" ? "ruler" : type} /> - </div> - <div className="py2"> - {entities ? ( - <Select - placeholder={t`Select...`} - value={formField.id.value} - onChange={({ target: { value } }) => { - const entity = entities[value]; - //TODO: refactor into function - formField.id.onChange(entity.id); - formField.points_of_interest.onChange( - entity.points_of_interest || "", - ); - formField.caveats.onChange(entity.caveats || ""); - if (type === "metric") { - formField.important_fields.onChange( - metricImportantFields[entity.id] && - metricImportantFields[entity.id].map( - fieldId => fields[fieldId], - ), - ); - } - }} - options={Object.values(entities)} - optionNameFn={option => option.display_name || option.name} - optionValueFn={option => option.id} - optionDisabledFn={o => selectedIdsSet.has(o.id)} - /> - ) : ( - <SchemaTableAndSegmentDataSelector - className={cx( - selectClasses, - "inline-block", - "rounded", - "text-bold", - )} - triggerIconSize={12} - selectedTableId={ - formField.type.value === "table" && - Number.parseInt(formField.id.value) - } - selectedDatabaseId={ - formField.type.value === "table" && - tables[formField.id.value] && - tables[formField.id.value].db_id - } - selectedSegmentId={ - formField.type.value === "segment" && - Number.parseInt(formField.id.value) - } - databases={Object.values(databases).map(database => ({ - ...database, - tables: database.tables.map(tableId => tables[tableId]), - }))} - setDatabaseFn={() => null} - tables={Object.values(tables)} - disabledTableIds={selectedIdTypePairs - .filter(idTypePair => idTypePair[1] === "table") - .map(idTypePair => idTypePair[0])} - setSourceTableFn={tableId => { - const table = tables[tableId]; - formField.id.onChange(table.id); - formField.type.onChange("table"); - formField.points_of_interest.onChange( - table.points_of_interest || null, - ); - formField.caveats.onChange(table.caveats || null); - }} - segments={Object.values(segments)} - disabledSegmentIds={selectedIdTypePairs - .filter(idTypePair => idTypePair[1] === "segment") - .map(idTypePair => idTypePair[0])} - setSourceSegmentFn={segmentId => { - const segment = segments[segmentId]; - formField.id.onChange(segment.id); - formField.type.onChange("segment"); - formField.points_of_interest.onChange( - segment.points_of_interest || "", - ); - formField.caveats.onChange(segment.caveats || ""); - }} - /> - )} - </div> - <div className="ml-auto cursor-pointer text-light"> - <Tooltip tooltip={t`Remove item`}> - <Icon name="close" width={16} height={16} onClick={removeField} /> - </Tooltip> - </div> - </div> - <div className="mt2 text-measure"> - <div className={cx("mb2", { disabled: disabled })}> - <EditLabel> - {type === "dashboard" - ? t`Why is this dashboard the most important?` - : t`What is useful or interesting about this ${type}?`} - </EditLabel> - <textarea - className={S.guideDetailEditorTextarea} - placeholder={t`Write something helpful here`} - {...formField.points_of_interest} - disabled={disabled} - /> - </div> - - <div className={cx("mb2", { disabled: disabled })}> - <EditLabel> - {type === "dashboard" - ? t`Is there anything users of this dashboard should be aware of?` - : t`Anything users should be aware of about this ${type}?`} - </EditLabel> - <textarea - className={S.guideDetailEditorTextarea} - placeholder={t`Write something helpful here`} - {...formField.caveats} - disabled={disabled} - /> - </div> - {type === "metric" && ( - <div className={cx("mb2", { disabled: disabled })}> - <EditLabel key="metricFieldsLabel"> - {t`Which 2-3 fields do you usually group this metric by?`} - </EditLabel> - <Select - placeholder={t`Select...`} - multiple - value={formField.important_fields.value || []} - onChange={({ target: { value } }) => - formField.important_fields.onChange(value) - } - disabled={formField.id.value == null} - options={fieldsByMetric} - optionNameFn={metric => metric.display_name || metric.name} - optionValueFn={metric => metric.id} - optionDisabledFn={metric => - formField.important_fields && - formField.important_fields.length >= 3 && - !formField.important_fields.includes(metric.id) - } - /> - </div> - )} - </div> - </div> - ); -}; - -const EditLabel = ({ children }) => <h3 className="mb1">{children}</h3>; - -GuideDetailEditor.propTypes = { - className: PropTypes.string, - type: PropTypes.string.isRequired, - entities: PropTypes.object, - metadata: PropTypes.object, - selectedIds: PropTypes.array, - selectedIdTypePairs: PropTypes.array, - formField: PropTypes.object.isRequired, - removeField: PropTypes.func.isRequired, -}; - -export default GuideDetailEditor; diff --git a/frontend/src/metabase/reference/components/GuideEditSection.css b/frontend/src/metabase/reference/components/GuideEditSection.css deleted file mode 100644 index 8043cdcbe747ca9188323c3e94f11e7454edebd8..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/reference/components/GuideEditSection.css +++ /dev/null @@ -1,21 +0,0 @@ -:local(.guideEditSectionCollapsed) { - composes: flex flex-full align-center mt4 p3 input text-brand text-bold from "style"; - font-size: 16px; -} - -:local(.guideEditSectionDisabled) { - composes: text-medium from "style"; -} - -:local(.guideEditSectionCollapsedIcon) { - composes: mr3 from "style"; -} - -:local(.guideEditSectionCollapsedTitle) { - composes: flex-full mr3 from "style"; -} - -:local(.guideEditSectionCollapsedLink) { - composes: text-brand no-decoration from "style"; - font-size: 14px; -} diff --git a/frontend/src/metabase/reference/components/GuideEditSection.jsx b/frontend/src/metabase/reference/components/GuideEditSection.jsx deleted file mode 100644 index b42b16ee688ea3eb91b3392e0678253d285b1a3d..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/reference/components/GuideEditSection.jsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import { Link } from "react-router"; -import pure from "recompose/pure"; -import cx from "classnames"; - -import S from "./GuideEditSection.css"; - -import Icon from "metabase/components/Icon"; - -const GuideEditSection = ({ - children, - isCollapsed, - isDisabled, - showLink, - collapsedIcon, - collapsedTitle, - linkMessage, - link, - action, - expand, -}) => - isCollapsed ? ( - <div - className={cx("text-measure", S.guideEditSectionCollapsed, { - "cursor-pointer border-brand-hover": !isDisabled, - [S.guideEditSectionDisabled]: isDisabled, - })} - onClick={!isDisabled && expand} - > - <Icon - className={S.guideEditSectionCollapsedIcon} - name={collapsedIcon} - size={24} - /> - <span className={S.guideEditSectionCollapsedTitle}>{collapsedTitle}</span> - {(showLink || isDisabled) && - (link ? ( - link.startsWith("http") ? ( - <a - className={S.guideEditSectionCollapsedLink} - href={link} - target="_blank" - > - {linkMessage} - </a> - ) : ( - <Link className={S.guideEditSectionCollapsedLink} to={link}> - {linkMessage} - </Link> - ) - ) : ( - action && ( - <a className={S.guideEditSectionCollapsedLink} onClick={action}> - {linkMessage} - </a> - ) - ))} - </div> - ) : ( - <div className={cx("my4", S.guideEditSection)}>{children}</div> - ); -GuideEditSection.propTypes = { - isCollapsed: PropTypes.bool.isRequired, -}; - -export default pure(GuideEditSection); diff --git a/frontend/src/metabase/reference/components/GuideHeader.jsx b/frontend/src/metabase/reference/components/GuideHeader.jsx deleted file mode 100644 index 7962f8963d380db2d9c45c08e7a39b5d205160f8..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/reference/components/GuideHeader.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import pure from "recompose/pure"; -import { t } from "ttag"; -import Button from "metabase/components/Button"; - -const GuideHeader = ({ startEditing, isSuperuser }) => ( - <div> - <div className="wrapper wrapper--trim sm-py4 sm-my3"> - <div className="flex align-center"> - <h1 className="text-dark" style={{ fontWeight: 700 }}> - {t`Start here`}. - </h1> - {isSuperuser && ( - <span className="ml-auto"> - <Button primary icon="pencil" onClick={startEditing}> - {t`Edit`} - </Button> - </span> - )} - </div> - <p - className="text-paragraph" - style={{ maxWidth: 620 }} - >{t`This is the perfect place to start if you’re new to your company’s data, or if you just want to check in on what’s going on.`}</p> - </div> - </div> -); - -GuideHeader.propTypes = { - startEditing: PropTypes.func.isRequired, - isSuperuser: PropTypes.bool, -}; - -export default pure(GuideHeader); diff --git a/frontend/src/metabase/reference/guide/GettingStartedGuide.jsx b/frontend/src/metabase/reference/guide/GettingStartedGuide.jsx deleted file mode 100644 index 471bb2b2cf6b1bc994f5aa07d0b01aaea1aa5f27..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/reference/guide/GettingStartedGuide.jsx +++ /dev/null @@ -1,368 +0,0 @@ -/* eslint "react/prop-types": "warn" */ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import { Link } from "react-router"; -import { connect } from "react-redux"; -import { t, jt } from "ttag"; -import cx from "classnames"; - -import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; - -import GuideHeader from "metabase/reference/components/GuideHeader"; -import GuideDetail from "metabase/reference/components/GuideDetail"; - -import * as metadataActions from "metabase/redux/metadata"; -import * as actions from "metabase/reference/reference"; -import { setRequestUnloaded } from "metabase/redux/requests"; -import Dashboards from "metabase/entities/dashboards"; - -import { updateSetting } from "metabase/admin/settings/settings"; - -import { - getGuide, - getUser, - getDashboards, - getLoading, - getError, - getIsEditing, - getTables, - getFields, - getMetrics, - getSegments, -} from "../selectors"; - -import { getQuestionUrl, has } from "../utils"; - -const isGuideEmpty = ({ - things_to_know, - contact, - most_important_dashboard, - important_metrics, - important_segments, - important_tables, -} = {}) => - things_to_know - ? false - : contact && contact.name - ? false - : contact && contact.email - ? false - : most_important_dashboard - ? false - : important_metrics && important_metrics.length !== 0 - ? false - : important_segments && important_segments.length !== 0 - ? false - : important_tables && important_tables.length !== 0 - ? false - : true; - -// This function generates a link for each important field of a Metric. -// The link goes to a question comprised of this Metric broken out by -// That important field. -const exploreLinksForMetric = (metricId, guide, metadataFields, tables) => { - if (guide.metric_important_fields[metricId]) { - return guide.metric_important_fields[metricId] - .map(fieldId => metadataFields[fieldId]) - .map(field => ({ - name: field.display_name || field.name, - url: getQuestionUrl({ - dbId: tables[field.table_id] && tables[field.table_id].db_id, - tableId: field.table_id, - fieldId: field.id, - metricId, - }), - })); - } -}; - -const mapStateToProps = (state, props) => ({ - guide: getGuide(state, props), - user: getUser(state, props), - dashboards: getDashboards(state, props), - metrics: getMetrics(state, props), - segments: getSegments(state, props), - tables: getTables(state, props), - // FIXME: avoids naming conflict, tried using the propNamespace option - // version but couldn't quite get it to work together with passing in - // dynamic initialValues - metadataFields: getFields(state, props), - loading: getLoading(state, props), - // naming this 'error' will conflict with redux form - loadingError: getError(state, props), - isEditing: getIsEditing(state, props), -}); - -const mapDispatchToProps = { - updateDashboard: Dashboards.actions.update, - createDashboard: Dashboards.actions.create, - updateSetting, - setRequestUnloaded, - ...metadataActions, - ...actions, -}; - -@connect( - mapStateToProps, - mapDispatchToProps, -) -export default class GettingStartedGuide extends Component { - static propTypes = { - fields: PropTypes.object, - style: PropTypes.object, - guide: PropTypes.object, - user: PropTypes.object, - dashboards: PropTypes.object, - metrics: PropTypes.object, - segments: PropTypes.object, - tables: PropTypes.object, - metadataFields: PropTypes.object, - loadingError: PropTypes.any, - loading: PropTypes.bool, - startEditing: PropTypes.func, - }; - - render() { - const { - style, - guide, - user, - dashboards, - metrics, - segments, - tables, - metadataFields, - loadingError, - loading, - startEditing, - } = this.props; - - return ( - <div className="full relative p3" style={style}> - <LoadingAndErrorWrapper - className="full" - style={style} - loading={!loadingError && loading} - error={loadingError} - > - {() => ( - <div> - <GuideHeader - startEditing={startEditing} - isSuperuser={user && user.is_superuser} - /> - - <div className="wrapper wrapper--trim"> - {(!guide || isGuideEmpty(guide)) && user && user.is_superuser && ( - <AdminInstructions> - <h2 className="py2">{t`Help your team get started with your data.`}</h2> - <GuideText> - {t`Show your team what’s most important by choosing your top dashboard, metrics, and segments.`} - </GuideText> - <button - className="Button Button--primary" - onClick={startEditing} - > - {t`Get started`} - </button> - </AdminInstructions> - )} - - {guide.most_important_dashboard !== null && [ - <div className="my2"> - <SectionHeader key={"dashboardTitle"}> - {t`Our most important dashboard`} - </SectionHeader> - <GuideDetail - key={"dashboardDetail"} - type="dashboard" - entity={dashboards[guide.most_important_dashboard]} - tables={tables} - /> - </div>, - ]} - {Object.keys(metrics).length > 0 && ( - <div className="my4 pt4"> - <SectionHeader trim={guide.important_metrics.length === 0}> - {guide.important_metrics && - guide.important_metrics.length > 0 - ? t`Numbers that we pay attention to` - : t`Metrics`} - </SectionHeader> - {guide.important_metrics && - guide.important_metrics.length > 0 ? ( - [ - <div className="my2"> - {guide.important_metrics.map(metricId => ( - <GuideDetail - key={metricId} - type="metric" - entity={metrics[metricId]} - tables={tables} - exploreLinks={exploreLinksForMetric( - metricId, - guide, - metadataFields, - tables, - )} - /> - ))} - </div>, - ] - ) : ( - <GuideText> - {t`Metrics are important numbers your company cares about. They often represent a core indicator of how the business is performing.`} - </GuideText> - )} - <div> - <Link - className="Button Button--primary" - to={"/reference/metrics"} - > - {t`See all metrics`} - </Link> - </div> - </div> - )} - - <div className="mt4 pt4"> - <SectionHeader - trim={ - !has(guide.important_segments) && - !has(guide.important_tables) - } - > - {Object.keys(segments).length > 0 - ? t`Segments and tables` - : t`Tables`} - </SectionHeader> - {has(guide.important_segments) || - has(guide.important_tables) ? ( - <div className="my2"> - {guide.important_segments.map(segmentId => ( - <GuideDetail - key={segmentId} - type="segment" - entity={segments[segmentId]} - tables={tables} - /> - ))} - {guide.important_tables.map(tableId => ( - <GuideDetail - key={tableId} - type="table" - entity={tables[tableId]} - tables={tables} - /> - ))} - </div> - ) : ( - <GuideText> - {Object.keys(segments).length > 0 ? ( - <span> - {jt`Segments and tables are the building blocks of your company's data. Tables are collections of the raw information while segments are specific slices with specific meanings, like ${( - <b>"Recent orders."</b> - )}`} - </span> - ) : ( - t`Tables are the building blocks of your company's data.` - )} - </GuideText> - )} - <div> - {Object.keys(segments).length > 0 && ( - <Link - className="Button Button--purple mr2" - to={"/reference/segments"} - > - {t`See all segments`} - </Link> - )} - <Link - className={cx( - { - "text-purple text-bold no-decoration text-underline-hover": - Object.keys(segments).length > 0, - }, - { - "Button Button--purple": - Object.keys(segments).length === 0, - }, - )} - to={"/reference/databases"} - > - {t`See all tables`} - </Link> - </div> - </div> - - <div className="mt4 pt4"> - <SectionHeader trim={!guide.things_to_know}> - {guide.things_to_know - ? t`Other things to know about our data` - : t`Find out more`} - </SectionHeader> - <GuideText> - {guide.things_to_know - ? guide.things_to_know - : t`A good way to get to know your data is by spending a bit of time exploring the different tables and other info available to you. It may take a while, but you'll start to recognize names and meanings over time.`} - </GuideText> - <Link - className="Button link text-bold" - to={"/reference/databases"} - > - {t`Explore our data`} - </Link> - </div> - - <div className="mt4"> - {guide.contact && - (guide.contact.name || guide.contact.email) && [ - <SectionHeader key={"contactTitle"}> - {t`Have questions?`} - </SectionHeader>, - <div className="mb4 pb4" key={"contactDetails"}> - {guide.contact.name && ( - <span className="text-dark mr3"> - {t`Contact ${guide.contact.name}`} - </span> - )} - {guide.contact.email && ( - <a - className="text-brand text-bold no-decoration" - href={`mailto:${guide.contact.email}`} - > - {guide.contact.email} - </a> - )} - </div>, - ]} - </div> - </div> - </div> - )} - </LoadingAndErrorWrapper> - </div> - ); - } -} - -const GuideText = ( - { children }, // eslint-disable-line react/prop-types -) => <p className="text-paragraph text-measure">{children}</p>; - -const AdminInstructions = ( - { children }, // eslint-disable-line react/prop-types -) => ( - <div className="bordered border-brand rounded p3 text-brand text-measure text-centered bg-light-blue"> - {children} - </div> -); - -const SectionHeader = ( - { trim, children }, // eslint-disable-line react/prop-types -) => ( - <h2 className={cx("text-dark text-measure", { mb0: trim }, { mb4: !trim })}> - {children} - </h2> -); diff --git a/frontend/src/metabase/reference/guide/GettingStartedGuideContainer.jsx b/frontend/src/metabase/reference/guide/GettingStartedGuideContainer.jsx deleted file mode 100644 index da2ed33d2f1044bf6a1aae17aa79ae5c6206e627..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/reference/guide/GettingStartedGuideContainer.jsx +++ /dev/null @@ -1,66 +0,0 @@ -/* eslint "react/prop-types": "warn" */ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import { connect } from "react-redux"; - -import GettingStartedGuide from "metabase/reference/guide/GettingStartedGuide"; -import GettingStartedGuideEditForm from "metabase/reference/guide/GettingStartedGuideEditForm"; - -import * as metadataActions from "metabase/redux/metadata"; -import * as actions from "metabase/reference/reference"; - -import { getDatabaseId, getIsEditing } from "../selectors"; - -import Dashboards from "metabase/entities/dashboards"; - -const mapStateToProps = (state, props) => ({ - databaseId: getDatabaseId(state, props), - isEditing: getIsEditing(state, props), -}); - -const mapDispatchToProps = { - fetchDashboards: Dashboards.actions.fetchList, - ...metadataActions, - ...actions, -}; - -@connect( - mapStateToProps, - mapDispatchToProps, -) -export default class GettingStartedGuideContainer extends Component { - static propTypes = { - params: PropTypes.object.isRequired, - location: PropTypes.object.isRequired, - databaseId: PropTypes.number.isRequired, - isEditing: PropTypes.bool, - }; - - async fetchContainerData() { - await actions.wrappedFetchGuide(this.props); - } - - componentWillMount() { - this.fetchContainerData(); - } - - componentWillReceiveProps(newProps) { - if (this.props.location.pathname === newProps.location.pathname) { - return; - } - - actions.clearState(newProps); - } - - render() { - return ( - <div> - {this.props.isEditing ? ( - <GettingStartedGuideEditForm {...this.props} /> - ) : ( - <GettingStartedGuide {...this.props} /> - )} - </div> - ); - } -} diff --git a/frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx b/frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx deleted file mode 100644 index d6689784f05d09b0359551b09f74f3eb1e45cffb..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx +++ /dev/null @@ -1,506 +0,0 @@ -/* eslint "react/prop-types": "warn" */ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import { connect } from "react-redux"; -import { reduxForm } from "redux-form"; -import { t } from "ttag"; -import cx from "classnames"; - -import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; -import CreateDashboardModal from "metabase/components/CreateDashboardModal"; -import Modal from "metabase/components/Modal"; - -import EditHeader from "metabase/reference/components/EditHeader"; -import GuideEditSection from "metabase/reference/components/GuideEditSection"; -import GuideDetailEditor from "metabase/reference/components/GuideDetailEditor"; - -import * as metadataActions from "metabase/redux/metadata"; -import * as actions from "metabase/reference/reference"; -import { setRequestUnloaded } from "metabase/redux/requests"; - -import MetabaseSettings from "metabase/lib/settings"; - -import Dashboards from "metabase/entities/dashboards"; - -import { updateSetting } from "metabase/admin/settings/settings"; - -import S from "../components/GuideDetailEditor.css"; - -import { - getGuide, - getDashboards, - getLoading, - getError, - getIsEditing, - getIsDashboardModalOpen, - getDatabases, - getTables, - getFields, - getMetrics, - getSegments, -} from "../selectors"; - -const mapStateToProps = (state, props) => { - const guide = getGuide(state, props); - const dashboards = getDashboards(state, props); - const metrics = getMetrics(state, props); - const segments = getSegments(state, props); - const tables = getTables(state, props); - const fields = getFields(state, props); - const databases = getDatabases(state, props); - - // redux-form populates fields with stale values after update - // if we dont specify nulls here - // could use a lot of refactoring - const initialValues = guide && { - things_to_know: guide.things_to_know || null, - contact: guide.contact || { name: null, email: null }, - most_important_dashboard: - dashboards !== null && guide.most_important_dashboard !== null - ? dashboards[guide.most_important_dashboard] - : {}, - important_metrics: - guide.important_metrics && guide.important_metrics.length > 0 - ? guide.important_metrics.map( - metricId => - metrics[metricId] && { - ...metrics[metricId], - important_fields: - guide.metric_important_fields[metricId] && - guide.metric_important_fields[metricId].map( - fieldId => fields[fieldId], - ), - }, - ) - : [], - important_segments_and_tables: - (guide.important_segments && guide.important_segments.length > 0) || - (guide.important_tables && guide.important_tables.length > 0) - ? guide.important_segments - .map( - segmentId => - segments[segmentId] && { - ...segments[segmentId], - type: "segment", - }, - ) - .concat( - guide.important_tables.map( - tableId => - tables[tableId] && { ...tables[tableId], type: "table" }, - ), - ) - : [], - }; - - return { - guide, - dashboards, - metrics, - segments, - tables, - databases, - // FIXME: avoids naming conflict, tried using the propNamespace option - // version but couldn't quite get it to work together with passing in - // dynamic initialValues - metadataFields: fields, - loading: getLoading(state, props), - // naming this 'error' will conflict with redux form - loadingError: getError(state, props), - isEditing: getIsEditing(state, props), - isDashboardModalOpen: getIsDashboardModalOpen(state, props), - // redux form doesn't pass this through to component - // need to use to reset form field arrays - initialValues: initialValues, - initialFormValues: initialValues, - }; -}; - -const mapDispatchToProps = { - updateDashboard: Dashboards.actions.update, - createDashboard: Dashboards.actions.create, - updateSetting, - setRequestUnloaded, - ...metadataActions, - ...actions, -}; - -@connect( - mapStateToProps, - mapDispatchToProps, -) -@reduxForm({ - form: "guide", - fields: [ - "things_to_know", - "contact.name", - "contact.email", - "most_important_dashboard.id", - "most_important_dashboard.caveats", - "most_important_dashboard.points_of_interest", - "important_metrics[].id", - "important_metrics[].caveats", - "important_metrics[].points_of_interest", - "important_metrics[].important_fields", - "important_segments_and_tables[].id", - "important_segments_and_tables[].type", - "important_segments_and_tables[].caveats", - "important_segments_and_tables[].points_of_interest", - ], -}) -export default class GettingStartedGuideEditForm extends Component { - static propTypes = { - fields: PropTypes.object, - style: PropTypes.object, - guide: PropTypes.object, - dashboards: PropTypes.object, - metrics: PropTypes.object, - segments: PropTypes.object, - tables: PropTypes.object, - databases: PropTypes.object, - metadataFields: PropTypes.object, - loadingError: PropTypes.any, - loading: PropTypes.bool, - isEditing: PropTypes.bool, - endEditing: PropTypes.func, - handleSubmit: PropTypes.func, - submitting: PropTypes.bool, - initialFormValues: PropTypes.object, - initializeForm: PropTypes.func, - createDashboard: PropTypes.func, - isDashboardModalOpen: PropTypes.bool, - showDashboardModal: PropTypes.func, - hideDashboardModal: PropTypes.func, - }; - - render() { - const { - fields: { - things_to_know, - contact, - most_important_dashboard, - important_metrics, - important_segments_and_tables, - }, - style, - guide, - dashboards, - metrics, - segments, - tables, - databases, - metadataFields, - loadingError, - loading, - isEditing, - endEditing, - handleSubmit, - submitting, - initialFormValues, - initializeForm, - createDashboard, - isDashboardModalOpen, - showDashboardModal, - hideDashboardModal, - } = this.props; - - const onSubmit = handleSubmit( - async fields => await actions.tryUpdateGuide(fields, this.props), - ); - - const getSelectedIds = fields => - fields.map(field => field.id.value).filter(id => id !== null); - - const getSelectedIdTypePairs = fields => - fields - .map(field => [field.id.value, field.type.value]) - .filter(idTypePair => idTypePair[0] !== null); - - return ( - <form className="full relative py4" style={style} onSubmit={onSubmit}> - {isDashboardModalOpen && ( - <Modal> - <CreateDashboardModal - createDashboard={async newDashboard => { - try { - await createDashboard(newDashboard, { redirect: true }); - } catch (error) { - console.error(error); - } - }} - onClose={hideDashboardModal} - /> - </Modal> - )} - {isEditing && ( - <EditHeader - endEditing={endEditing} - // resetForm doesn't reset field arrays - reinitializeForm={() => initializeForm(initialFormValues)} - submitting={submitting} - /> - )} - <LoadingAndErrorWrapper - className="full" - style={style} - loading={!loadingError && loading} - error={loadingError} - > - {() => ( - <div className="wrapper wrapper--trim"> - <div className="mt4 py2"> - <h1 className="my3 text-dark"> - {t`Help new Metabase users find their way around.`} - </h1> - <p className="text-paragraph text-measure"> - {t`The Getting Started guide highlights the dashboard, metrics, segments, and tables that matter most, and informs your users of important things they should know before digging into the data.`} - </p> - </div> - - <GuideEditSection - isCollapsed={most_important_dashboard.id.value === undefined} - isDisabled={!dashboards || Object.keys(dashboards).length === 0} - collapsedTitle={t`Is there an important dashboard for your team?`} - collapsedIcon="dashboard" - linkMessage={t`Create a dashboard now`} - action={showDashboardModal} - expand={() => most_important_dashboard.id.onChange(null)} - > - <div> - <SectionHeader> - {t`What is your most important dashboard?`} - </SectionHeader> - <GuideDetailEditor - type="dashboard" - entities={dashboards} - selectedIds={[most_important_dashboard.id.value]} - formField={most_important_dashboard} - removeField={() => { - most_important_dashboard.id.onChange(null); - most_important_dashboard.points_of_interest.onChange(""); - most_important_dashboard.caveats.onChange(""); - }} - /> - </div> - </GuideEditSection> - - <GuideEditSection - isCollapsed={important_metrics.length === 0} - isDisabled={!metrics || Object.keys(metrics).length === 0} - collapsedTitle={t`Do you have any commonly referenced metrics?`} - collapsedIcon="ruler" - linkMessage={t`Learn how to define a metric`} - link={MetabaseSettings.docsUrl( - "administration-guide/07-segments-and-metrics", - "creating-a-metric", - )} - expand={() => - important_metrics.addField({ - id: null, - caveats: null, - points_of_interest: null, - important_fields: null, - }) - } - > - <div className="my2"> - <SectionHeader> - {t`What are your 3-5 most commonly referenced metrics?`} - </SectionHeader> - <div> - {important_metrics.map( - (metricField, index, metricFields) => ( - <GuideDetailEditor - key={index} - type="metric" - metadata={{ - tables, - metrics, - fields: metadataFields, - metricImportantFields: - guide.metric_important_fields, - }} - entities={metrics} - formField={metricField} - selectedIds={getSelectedIds(metricFields)} - removeField={() => { - if (metricFields.length > 1) { - return metricFields.removeField(index); - } - metricField.id.onChange(null); - metricField.points_of_interest.onChange(""); - metricField.caveats.onChange(""); - metricField.important_fields.onChange(null); - }} - /> - ), - )} - </div> - {important_metrics.length < 5 && - important_metrics.length < Object.keys(metrics).length && ( - <button - className="Button Button--primary Button--large" - type="button" - onClick={() => - important_metrics.addField({ - id: null, - caveats: null, - points_of_interest: null, - }) - } - > - {t`Add another metric`} - </button> - )} - </div> - </GuideEditSection> - - <GuideEditSection - isCollapsed={important_segments_and_tables.length === 0} - isDisabled={ - (!segments || Object.keys(segments).length === 0) && - (!tables || Object.keys(tables).length === 0) - } - showLink={!segments || Object.keys(segments).length === 0} - collapsedTitle={t`Do you have any commonly referenced segments or tables?`} - collapsedIcon="table2" - linkMessage={t`Learn how to create a segment`} - link={MetabaseSettings.docsUrl( - "administration-guide/07-segments-and-metrics", - "creating-a-segment", - )} - expand={() => - important_segments_and_tables.addField({ - id: null, - type: null, - caveats: null, - points_of_interest: null, - }) - } - > - <div> - <h2 className="text-measure text-dark"> - {t`What are 3-5 commonly referenced segments or tables that would be useful for this audience?`} - </h2> - <div className="mb2"> - {important_segments_and_tables.map( - (segmentOrTableField, index, segmentOrTableFields) => ( - <GuideDetailEditor - key={index} - type="segment" - metadata={{ - databases, - tables, - segments, - }} - formField={segmentOrTableField} - selectedIdTypePairs={getSelectedIdTypePairs( - segmentOrTableFields, - )} - removeField={() => { - if (segmentOrTableFields.length > 1) { - return segmentOrTableFields.removeField(index); - } - segmentOrTableField.id.onChange(null); - segmentOrTableField.type.onChange(null); - segmentOrTableField.points_of_interest.onChange(""); - segmentOrTableField.caveats.onChange(""); - }} - /> - ), - )} - </div> - {important_segments_and_tables.length < 5 && - important_segments_and_tables.length < - Object.keys(tables).concat(Object.keys.segments) - .length && ( - <button - className="Button Button--primary Button--large" - type="button" - onClick={() => - important_segments_and_tables.addField({ - id: null, - type: null, - caveats: null, - points_of_interest: null, - }) - } - > - {t`Add another segment or table`} - </button> - )} - </div> - </GuideEditSection> - - <GuideEditSection - isCollapsed={things_to_know.value === null} - isDisabled={false} - collapsedTitle={t`Is there anything your users should understand or know before they start accessing the data?`} - collapsedIcon="reference" - expand={() => things_to_know.onChange("")} - > - <div className="text-measure"> - <SectionHeader> - {t`What should a user of this data know before they start accessing it?`} - </SectionHeader> - <textarea - className={S.guideDetailEditorTextarea} - placeholder={t`E.g., expectations around data privacy and use, common pitfalls or misunderstandings, information about data warehouse performance, legal notices, etc.`} - {...things_to_know} - /> - </div> - </GuideEditSection> - - <GuideEditSection - isCollapsed={ - contact.name.value === null && contact.email.value === null - } - isDisabled={false} - collapsedTitle={t`Is there someone your users could contact for help if they're confused about this guide?`} - collapsedIcon="mail" - expand={() => { - contact.name.onChange(""); - contact.email.onChange(""); - }} - > - <div> - <SectionHeader> - {t`Who should users contact for help if they're confused about this data?`} - </SectionHeader> - <div className="flex"> - <div className="flex-full"> - <h3 className="mb1">{t`Name`}</h3> - <input - className="input text-paragraph" - placeholder="Julie McHelpfulson" - type="text" - {...contact.name} - /> - </div> - <div className="flex-full"> - <h3 className="mb1">{t`Email address`}</h3> - <input - className="input text-paragraph" - placeholder="julie.mchelpfulson@acme.com" - type="text" - {...contact.email} - /> - </div> - </div> - </div> - </GuideEditSection> - </div> - )} - </LoadingAndErrorWrapper> - </form> - ); - } -} - -const SectionHeader = ( - { trim, children }, // eslint-disable-line react/prop-types -) => ( - <h2 className={cx("text-dark text-measure", { mb0: trim }, { mb4: !trim })}> - {children} - </h2> -); diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx index 3e3b5f9563aa3bb4e3157bd86ef5215db40cdf71..d70d325635f7b97783301f49b4151c30f12d96d1 100644 --- a/frontend/src/metabase/routes.jsx +++ b/frontend/src/metabase/routes.jsx @@ -55,8 +55,6 @@ import CreateDashboardModal from "metabase/components/CreateDashboardModal"; import { NotFound, Unauthorized } from "metabase/containers/ErrorPages"; -// Reference Guide -import GettingStartedGuideContainer from "metabase/reference/guide/GettingStartedGuideContainer"; // Reference Metrics import MetricListContainer from "metabase/reference/metrics/MetricListContainer"; import MetricDetailContainer from "metabase/reference/metrics/MetricDetailContainer"; @@ -263,11 +261,6 @@ export const getRoutes = store => ( {/* REFERENCE */} <Route path="/reference" title={`Data Reference`}> <IndexRedirect to="/reference/databases" /> - <Route - path="guide" - title={`Getting Started`} - component={GettingStartedGuideContainer} - /> <Route path="metrics" component={MetricListContainer} /> <Route path="metrics/:metricId" component={MetricDetailContainer} /> <Route diff --git a/frontend/src/metabase/visualizations/components/ChartSettings.jsx b/frontend/src/metabase/visualizations/components/ChartSettings.jsx index 4023b88f04356259d0cd73d7321b34a90459aa3a..e54d9729cc6b861a1bc159df0a758d497d8bfead 100644 --- a/frontend/src/metabase/visualizations/components/ChartSettings.jsx +++ b/frontend/src/metabase/visualizations/components/ChartSettings.jsx @@ -191,12 +191,20 @@ class ChartSettings extends Component { visibleWidgets = sections[currentSection] || []; } + // This checks whether the current section contains a column settings widget + // at the top level. If it does, we avoid hiding the section tabs and + // overriding the sidebar title. + const currentSectionHasColumnSettings = ( + sections[currentSection] || [] + ).some(widget => widget.id === "column_settings"); + const extraWidgetProps = { // NOTE: special props to support adding additional fields question: question, addField: addField, onShowWidget: this.handleShowWidget, onEndShowWidget: this.handleEndShowWidget, + currentSectionHasColumnSettings, }; const sectionPicker = ( @@ -239,7 +247,9 @@ class ChartSettings extends Component { // hide the section picker if the only widget is column_settings !( visibleWidgets.length === 1 && - visibleWidgets[0].id === "column_settings" + visibleWidgets[0].id === "column_settings" && + // and this section doesn't doesn't have that as a direct child + !currentSectionHasColumnSettings ); // default layout with visualization diff --git a/frontend/src/metabase/visualizations/components/settings/ChartNestedSettingColumns.jsx b/frontend/src/metabase/visualizations/components/settings/ChartNestedSettingColumns.jsx index dd77f4624af01b960a686ea69a8bb97a46792b14..75f0406f0b36efbcc4d9aa5d1de2cee099fa0cfd 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartNestedSettingColumns.jsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartNestedSettingColumns.jsx @@ -38,14 +38,20 @@ class ColumnWidgets extends React.Component { componentDidMount() { const { setSidebarPropsOverride, - onChangeEditingObject, object, + onEndShowWidget, + currentSectionHasColumnSettings, } = this.props; - if (setSidebarPropsOverride) { + // These two props (title and onBack) are overridden to display a column + // name instead of the visualization type when viewing a column's settings. + // If the column setting is directly within the section rather than an + // additional widget we drilled into, clicking back should still return us + // to the visualization list. In that case, we don't override these at all. + if (setSidebarPropsOverride && !currentSectionHasColumnSettings) { setSidebarPropsOverride({ title: displayNameForColumn(object), - onBack: () => onChangeEditingObject(), + onBack: onEndShowWidget, }); } } diff --git a/frontend/src/metabase/visualizations/lib/apply_axis.js b/frontend/src/metabase/visualizations/lib/apply_axis.js index 745ea5ed637a5f891d36bc4ab11b26d2027936e3..006b4c58c4ffc86f043225ea375fbc19210a70ef 100644 --- a/frontend/src/metabase/visualizations/lib/apply_axis.js +++ b/frontend/src/metabase/visualizations/lib/apply_axis.js @@ -10,7 +10,7 @@ import { formatValue } from "metabase/lib/formatting"; import { computeTimeseriesTicksInterval } from "./timeseries"; import timeseriesScale from "./timeseriesScale"; -import { isMultipleOf, getModuloScaleFactor } from "./numeric"; +import { isMultipleOf } from "./numeric"; import { getFriendlyName } from "./utils"; import { isHistogram } from "./renderer_utils"; @@ -220,14 +220,9 @@ export function applyChartQuantitativeXAxis( ); adjustXAxisTicksIfNeeded(chart.xAxis(), chart.width(), xValues); - // if xInterval is less than 1 we need to scale the values before doing - // modulo comparison. isMultipleOf will compute it for us but we can do it - // once here as an optimization - const modulorScale = getModuloScaleFactor(xInterval); - chart.xAxis().tickFormat(d => { // don't show ticks that aren't multiples of xInterval - if (isMultipleOf(d, xInterval, modulorScale)) { + if (isMultipleOf(d, xInterval)) { return formatValue(d, { ...chart.settings.column(dimensionColumn), type: "axis", diff --git a/frontend/src/metabase/visualizations/lib/numeric.js b/frontend/src/metabase/visualizations/lib/numeric.js index df1d6774b47d8d7e546c3574b6f326f7d028e753..8fff9fd582f2f5cb037b7e5722d52f1143455caf 100644 --- a/frontend/src/metabase/visualizations/lib/numeric.js +++ b/frontend/src/metabase/visualizations/lib/numeric.js @@ -6,6 +6,9 @@ export function dimensionIsNumeric({ cols, rows }, i = 0) { return isNumeric(cols[i]) || typeof (rows[0] && rows[0][i]) === "number"; } +// We seem to run into float bugs if we get any more precise than this. +const SMALLEST_PRECISION_EXP = -14; + export function precision(a) { if (!isFinite(a)) { return 0; @@ -13,14 +16,15 @@ export function precision(a) { if (!a) { return 0; } - let e = 1; - while (Math.round(a / e) !== a / e) { - e /= 10; - } - while (Math.round(a / Math.pow(10, e)) === a / Math.pow(10, e)) { - e *= 10; + + // Find the largest power of ten needed to evenly divide the value. We start + // with the power of ten greater than the value and walk backwards until we + // hit our limit of SMALLEST_PRECISION_EXP or isMultipleOf returns true. + let e = Math.ceil(Math.log10(Math.abs(a))); + while (e > SMALLEST_PRECISION_EXP && !isMultipleOf(a, Math.pow(10, e))) { + e--; } - return e; + return Math.pow(10, e); } export function decimalCount(a) { @@ -59,8 +63,10 @@ export function logTickFormat(axis) { axis.tickFormat(formatTick); } -export const getModuloScaleFactor = base => - Math.max(1, Math.pow(10, Math.ceil(Math.log10(1 / base)))); - -export const isMultipleOf = (value, base, scale = getModuloScaleFactor(base)) => - (value * scale) % (base * scale) === 0; +export const isMultipleOf = (value, base) => { + // Ideally we could use Number.EPSILON as constant diffThreshold here. + // However, we sometimes see very small errors that are bigger than EPSILON. + // For example, when called 1.23456789 and 1e-8 we see a diff of ~1e-16. + const diffThreshold = Math.pow(10, SMALLEST_PRECISION_EXP); + return Math.abs(value - Math.round(value / base) * base) < diffThreshold; +}; diff --git a/frontend/test/metabase-lib/lib/queries/NativeQuery.unit.spec.js b/frontend/test/metabase-lib/lib/queries/NativeQuery.unit.spec.js index 4840515562b101b1d1d984a3d6ee643d8efa6aa1..2e3072990c22304598a485769250276f527521a2 100644 --- a/frontend/test/metabase-lib/lib/queries/NativeQuery.unit.spec.js +++ b/frontend/test/metabase-lib/lib/queries/NativeQuery.unit.spec.js @@ -2,6 +2,7 @@ import { assocIn } from "icepick"; import { SAMPLE_DATASET, + PRODUCTS, MONGO_DATABASE, } from "__support__/sample_dataset_fixture"; @@ -212,4 +213,43 @@ describe("NativeQuery", () => { expect(q.canRun()).toBe(true); }); }); + describe("variables", () => { + it("should return empty array if there are no tags", () => { + const q = makeQuery().setQueryText("SELECT * FROM PRODUCTS"); + const variables = q.variables(); + expect(variables).toHaveLength(0); + }); + it("should return variable for non-dimension template tag", () => { + const q = makeQuery().setQueryText( + "SELECT * FROM PRODUCTS WHERE CATEGORY = {{category}}", + ); + const variables = q.variables(); + expect(variables).toHaveLength(1); + expect(variables.map(v => v.displayName())).toEqual(["category"]); + }); + it("should not return variable for dimension template tag", () => { + const q = makeQuery() + .setQueryText("SELECT * FROM PRODUCTS WHERE {{category}}") + .setTemplateTag("category", { name: "category", type: "dimension" }); + expect(q.variables()).toHaveLength(0); + }); + }); + describe("dimensionOptions", () => { + it("should return empty dimensionOptions if there are no tags", () => { + const q = makeQuery().setQueryText("SELECT * FROM PRODUCTS"); + expect(q.dimensionOptions().count).toBe(0); + }); + it("should return a dimension for a dimension template tag", () => { + const q = makeQuery() + .setQueryText("SELECT * FROM PRODUCTS WHERE {{category}}") + .setTemplateTag("category", { + name: "category", + type: "dimension", + dimension: ["field-id", PRODUCTS.CATEGORY.id], + }); + const dimensions = q.dimensionOptions().dimensions; + expect(dimensions).toHaveLength(1); + expect(dimensions.map(d => d.displayName())).toEqual(["Category"]); + }); + }); }); diff --git a/frontend/test/metabase-lib/lib/queries/StructuredQuery-nesting.unit.spec.js b/frontend/test/metabase-lib/lib/queries/StructuredQuery-nesting.unit.spec.js index 82bb486f2ec48d488b698f3d94d0bb4ac149fe71..cc3ab647b9bff859e640389c8bc1de17744c00d7 100644 --- a/frontend/test/metabase-lib/lib/queries/StructuredQuery-nesting.unit.spec.js +++ b/frontend/test/metabase-lib/lib/queries/StructuredQuery-nesting.unit.spec.js @@ -47,7 +47,7 @@ describe("StructuredQuery nesting", () => { expect( q .nest() - .filterFieldOptions() + .filterDimensionOptions() .dimensions.map(d => d.mbql()), ).toEqual([ ["field-literal", "PRODUCT_ID", "type/Integer"], diff --git a/frontend/test/metabase-lib/lib/queries/StructuredQuery.unit.spec.js b/frontend/test/metabase-lib/lib/queries/StructuredQuery.unit.spec.js index 305eb2c8bb14d116250ff6521b57d0cee64e6a06..b51e7af36e5ebcf5a3afc606ee3e463bfe353d3c 100644 --- a/frontend/test/metabase-lib/lib/queries/StructuredQuery.unit.spec.js +++ b/frontend/test/metabase-lib/lib/queries/StructuredQuery.unit.spec.js @@ -41,7 +41,7 @@ describe("StructuredQuery behavioral tests", () => { const queryWithBreakout = query.breakout(breakoutDimension.mbql()); - const filterDimensionOptions = queryWithBreakout.filterFieldOptions() + const filterDimensionOptions = queryWithBreakout.filterDimensionOptions() .dimensions; const filterDimension = filterDimensionOptions.find( d => d.field().id === ORDERS.TOTAL.id, @@ -370,7 +370,7 @@ describe("StructuredQuery", () => { pending(); }); - describe("filterFieldOptions", () => { + describe("filterDimensionOptions", () => { pending(); }); describe("filterSegmentOptions", () => { diff --git a/frontend/test/metabase/lib/api.unit.spec.js b/frontend/test/metabase/lib/api.unit.spec.js index 72627da80f4c16abe4ba3fbc9a40decdd2bbd93f..59e551b88524ae8ef43beca27dfd855c4547cbee 100644 --- a/frontend/test/metabase/lib/api.unit.spec.js +++ b/frontend/test/metabase/lib/api.unit.spec.js @@ -89,4 +89,19 @@ describe("api", () => { const response = await hello(); expect(response).toEqual({ status: "ok" }); }); + + it("should use _status from body when HTTP status is 202", async () => { + expect.assertions(2); + mock.get("/async-status", { + status: 202, + body: JSON.stringify({ _status: 400, message: "error message" }), + }); + const asyncStatus = GET("/async-status"); + try { + await asyncStatus(); + } catch (error) { + expect(error.status).toBe(400); + expect(error.data.message).toBe("error message"); + } + }); }); diff --git a/frontend/test/metabase/meta/Dashboard.unit.spec.js b/frontend/test/metabase/meta/Dashboard.unit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..2a83a96ed117c3c7fe8afd00fe4ced605bbf5bc6 --- /dev/null +++ b/frontend/test/metabase/meta/Dashboard.unit.spec.js @@ -0,0 +1,182 @@ +import { + metadata, + SAMPLE_DATASET, + REVIEWS, + ORDERS, + PRODUCTS, +} from "__support__/sample_dataset_fixture"; +import { getParameterMappingOptions } from "metabase/meta/Dashboard"; + +function structured(query) { + return SAMPLE_DATASET.question(query).card(); + // return { + // dataset_query: { + // database: SAMPLE_DATASET.id, + // type: "query", + // query: query, + // }, + // }; +} + +function native(native) { + return SAMPLE_DATASET.nativeQuestion(native).card(); + // return { + // dataset_query: { + // database: SAMPLE_DATASET.id, + // type: "native", + // native: native, + // }, + // }; +} + +describe("getParameterMappingOptions", () => { + describe("Structured Query", () => { + it("should return field-id and fk-> dimensions", () => { + const options = getParameterMappingOptions( + metadata, + { type: "date/single" }, + structured({ + "source-table": REVIEWS.id, + }), + ); + expect(options).toEqual([ + { + sectionName: "Review", + icon: "calendar", + name: "Created At", + target: ["dimension", ["field-id", REVIEWS.CREATED_AT.id]], + isForeign: false, + }, + { + sectionName: "Product", + name: "Created At", + icon: "calendar", + target: [ + "dimension", + [ + "fk->", + ["field-id", REVIEWS.PRODUCT_ID.id], + ["field-id", PRODUCTS.CREATED_AT.id], + ], + ], + isForeign: true, + }, + ]); + }); + it("should also return fields from explicitly joined tables", () => { + const options = getParameterMappingOptions( + metadata, + { type: "date/single" }, + structured({ + "source-table": REVIEWS.id, + joins: [ + { + alias: "Joined Table", + "source-table": ORDERS.id, + }, + ], + }), + ); + expect(options).toEqual([ + { + sectionName: "Review", + name: "Created At", + icon: "calendar", + target: ["dimension", ["field-id", 30]], + isForeign: false, + }, + { + sectionName: "Joined Table", + name: "Created At", + icon: "calendar", + target: [ + "dimension", + ["joined-field", "Joined Table", ["field-id", 1]], + ], + isForeign: true, + }, + { + sectionName: "Product", + name: "Created At", + icon: "calendar", + target: ["dimension", ["fk->", ["field-id", 32], ["field-id", 22]]], + isForeign: true, + }, + ]); + }); + it("should return fields in nested query", () => { + const options = getParameterMappingOptions( + metadata, + { type: "date/single" }, + structured({ + "source-query": { + "source-table": ORDERS.id, + }, + }), + ); + expect(options).toEqual([ + { + sectionName: undefined, + name: "Created At", + icon: "calendar", + target: [ + "dimension", + ["field-literal", "CREATED_AT", "type/DateTime"], + ], + isForeign: false, + }, + ]); + }); + }); + + describe("NativeQuery", () => { + it("should return variables for non-dimension template-tags", () => { + const options = getParameterMappingOptions( + metadata, + { type: "date/single" }, + native({ + query: "select * from ORDERS where CREATED_AT = {{created}}", + "template-tags": { + created: { + type: "date", + name: "created", + }, + }, + }), + ); + expect(options).toEqual([ + { + sectionName: "Variables", + name: "created", + icon: "calendar", + target: ["variable", ["template-tag", "created"]], + }, + ]); + }); + }); + it("should return dimensions for dimension template-tags", () => { + const options = getParameterMappingOptions( + metadata, + { type: "date/single" }, + native({ + query: "select * from ORDERS where CREATED_AT = {{created}}", + "template-tags": { + created: { + type: "dimension", + name: "created", + dimension: ["field-id", ORDERS.CREATED_AT.id], + }, + }, + }), + ); + expect(options).toEqual([ + { + sectionName: "Order", + name: "Created At", + icon: "calendar", + target: ["dimension", ["template-tag", "created"]], + isForeign: false, + }, + ]); + }); +}); diff --git a/frontend/test/metabase/query_builder/components/view/sidebars/ChartSettingsSidebar.unit.spec.js b/frontend/test/metabase/query_builder/components/view/sidebars/ChartSettingsSidebar.unit.spec.js index 3d82cea57b58c93a1600c0a21737f81980012d13..0ca162d43823a7a318da2aa1fc9272d182123704 100644 --- a/frontend/test/metabase/query_builder/components/view/sidebars/ChartSettingsSidebar.unit.spec.js +++ b/frontend/test/metabase/query_builder/components/view/sidebars/ChartSettingsSidebar.unit.spec.js @@ -1,17 +1,19 @@ import React from "react"; import "@testing-library/jest-dom/extend-expect"; -import { render, fireEvent } from "@testing-library/react"; +import { render, fireEvent, cleanup } from "@testing-library/react"; import { SAMPLE_DATASET } from "__support__/sample_dataset_fixture"; import ChartSettingsSidebar from "metabase/query_builder/components/view/sidebars/ChartSettingsSidebar"; describe("ChartSettingsSidebar", () => { + const data = { + rows: [[1]], + cols: [{ base_type: "type/Integer", name: "foo", display_name: "foo" }], + }; + afterEach(cleanup); + it("should hide title and section picker when viewing column settings", () => { - const data = { - rows: [["bar"]], - cols: [{ base_type: "type/Text", name: "foo", display_name: "foo" }], - }; const { container, getByText, queryByText } = render( <ChartSettingsSidebar question={SAMPLE_DATASET.question()} @@ -24,4 +26,39 @@ describe("ChartSettingsSidebar", () => { expect(queryByText("Table options")).toBe(null); expect(queryByText("Conditional Formatting")).toBe(null); }); + + it("should not hide the title for gauge charts", () => { + const { getByText } = render( + <ChartSettingsSidebar + question={SAMPLE_DATASET.question().setDisplay("gauge")} + result={{ data }} + />, + ); + // see options header with sections + getByText("Gauge options"); + getByText("Formatting"); + getByText("Display"); + + // click on formatting section + fireEvent.click(getByText("Formatting")); + + // you see the formatting stuff + getByText("Style"); + // but the sections and back title are unchanged + getByText("Gauge options"); + getByText("Formatting"); + getByText("Display"); + }); + + it("should not hide the title for scalar charts", () => { + const { getByText } = render( + <ChartSettingsSidebar + question={SAMPLE_DATASET.question().setDisplay("scalar")} + result={{ data }} + />, + ); + // see header with formatting fields + getByText("Number options"); + getByText("Style"); + }); }); diff --git a/frontend/test/metabase/reference/guide.e2e.spec.js b/frontend/test/metabase/reference/guide.e2e.spec.js deleted file mode 100644 index 8a4bf27b3ab85e60544257f464111edf159e038a..0000000000000000000000000000000000000000 --- a/frontend/test/metabase/reference/guide.e2e.spec.js +++ /dev/null @@ -1,112 +0,0 @@ -import { useSharedAdminLogin, createTestStore } from "__support__/e2e"; - -import React from "react"; -import { mount } from "enzyme"; - -import { SegmentApi, MetricApi } from "metabase/services"; - -import { - FETCH_DATABASE_METADATA, - FETCH_METRICS, - FETCH_SEGMENTS, -} from "metabase/redux/metadata"; - -import GettingStartedGuideContainer from "metabase/reference/guide/GettingStartedGuideContainer"; - -describe("The Reference Section", () => { - // Test data - const segmentDef = { - name: "A Segment", - description: "I did it!", - table_id: 1, - show_in_getting_started: true, - definition: { - "source-table": 1, - filter: ["time-interval", ["field-id", 1], -30, "day"], - }, - }; - - const anotherSegmentDef = { - name: "Another Segment", - description: "I did it again!", - table_id: 1, - show_in_getting_started: true, - definition: { - "source-table": 1, - filter: ["time-interval", ["field-id", 1], -30, "day"], - }, - }; - const metricDef = { - name: "A Metric", - description: "I did it!", - table_id: 1, - show_in_getting_started: true, - definition: { database: 1, query: { aggregation: [["count"]] } }, - }; - - const anotherMetricDef = { - name: "Another Metric", - description: "I did it again!", - table_id: 1, - show_in_getting_started: true, - definition: { database: 1, query: { aggregation: [["count"]] } }, - }; - - // Scaffolding - beforeAll(async () => { - useSharedAdminLogin(); - }); - - describe("The Getting Started Guide", async () => { - it("Should show an empty guide for non-admin users", async () => { - const store = await createTestStore(); - store.pushPath("/reference/"); - mount(store.connectContainer(<GettingStartedGuideContainer />)); - await store.waitForActions([ - FETCH_DATABASE_METADATA, - FETCH_SEGMENTS, - FETCH_METRICS, - ]); - }); - - xit("Should show an empty guide with a creation CTA for admin users", async () => {}); - - xit("A non-admin attempting to edit the guide should get an error", async () => {}); - - it("Adding metrics should to the guide should make them appear", async () => { - expect(0).toBe(0); - const metric = await MetricApi.create(metricDef); - expect(1).toBe(1); - const metric2 = await MetricApi.create(anotherMetricDef); - expect(2).toBe(2); - await MetricApi.delete({ - metricId: metric.id, - revision_message: "Please", - }); - expect(1).toBe(1); - await MetricApi.delete({ - metricId: metric2.id, - revision_message: "Please", - }); - expect(0).toBe(0); - }); - - it("Adding segments should to the guide should make them appear", async () => { - expect(0).toBe(0); - const segment = await SegmentApi.create(segmentDef); - expect(1).toBe(1); - const anotherSegment = await SegmentApi.create(anotherSegmentDef); - expect(2).toBe(2); - await SegmentApi.delete({ - segmentId: segment.id, - revision_message: "Please", - }); - expect(1).toBe(1); - await SegmentApi.delete({ - segmentId: anotherSegment.id, - revision_message: "Please", - }); - expect(0).toBe(0); - }); - }); -}); diff --git a/frontend/test/metabase/visualizations/components/ChartSettings.unit.spec.js b/frontend/test/metabase/visualizations/components/ChartSettings.unit.spec.js index 974ee2147c4b753fe8568d68a2b1eae2bb3de376..178f97c2902bcacfb4bab70b41bd6c295296b487 100644 --- a/frontend/test/metabase/visualizations/components/ChartSettings.unit.spec.js +++ b/frontend/test/metabase/visualizations/components/ChartSettings.unit.spec.js @@ -131,13 +131,21 @@ describe("ChartSettings", () => { }); it("should not show the section picker if showing a column setting", () => { + const columnSettingsWidget = widget({ + title: "Something", + section: "Formatting", + hidden: true, + id: "column_settings", + }); const { queryByText } = render( <ChartSettings {...DEFAULT_PROPS} widgets={[ - widget({ title: "Something", section: "Foo", id: "column_settings" }), + widget({ title: "List of columns", section: "Foo", id: "thing" }), widget({ title: "Other Thing", section: "Bar", id: "other_thing" }), + columnSettingsWidget, ]} + initial={{ widget: columnSettingsWidget }} />, ); expect(queryByText("Foo")).toBe(null); diff --git a/frontend/test/metabase/visualizations/lib/numeric.unit.spec.js b/frontend/test/metabase/visualizations/lib/numeric.unit.spec.js index 89cb3d0dc1c302893c5df6014521ff3cbf6c0454..2daea4a183b0a97ce63064fbc17aca229c26fc53 100644 --- a/frontend/test/metabase/visualizations/lib/numeric.unit.spec.js +++ b/frontend/test/metabase/visualizations/lib/numeric.unit.spec.js @@ -2,7 +2,6 @@ import { precision, computeNumericDataInverval, isMultipleOf, - getModuloScaleFactor, } from "metabase/visualizations/lib/numeric"; describe("visualization.lib.numeric", () => { @@ -23,10 +22,25 @@ describe("visualization.lib.numeric", () => { [0.9, 0.1], [-0.5, 0.1], [-0.9, 0.1], + [1.23, 0.01], + [1.234, 1e-3], + [1.2345, 1e-4], + [1.23456, 1e-5], + [1.234567, 1e-6], + [1.2345678, 1e-7], + [1.23456789, 1e-8], + [-1.23456789, 1e-8], + [-1.2345678912345, 1e-13], + [-1.23456789123456, 1e-14], + // very precise numbers are cut off at 10^-14 + [-1.23456789123456789123456789, 1e-14], ]; - for (const c of CASES) { - it("precision of " + c[0] + " should be " + c[1], () => { - expect(precision(c[0])).toEqual(c[1]); + for (const [n, p] of CASES) { + it(`precision of ${n} should be ${p}`, () => { + expect(Math.abs(precision(n) - p) < Number.EPSILON).toBe(true); + // The expect above doesn't print out the relevant values for failures. + // The next line fails but can be useful when debugging. + // expect(precision(n)).toBe(p); }); } }); @@ -46,20 +60,6 @@ describe("visualization.lib.numeric", () => { }); } }); - describe("getModuloScaleFactor", () => { - [ - [0.01, 100], - [0.05, 100], - [0.1, 10], - [1, 1], - [2, 1], - [10, 1], - [10 ** 10, 1], - ].map(([value, expected]) => - it(`should return ${expected} for ${value}`, () => - expect(getModuloScaleFactor(value)).toBe(expected)), - ); - }); describe("isMultipleOf", () => { [ [1, 0.1, true], @@ -71,9 +71,18 @@ describe("visualization.lib.numeric", () => { [0.25, 0.1, false], [0.000000001, 0.0000000001, true], [0.0000000001, 0.000000001, false], + [100, 1e-14, true], ].map(([value, base, expected]) => it(`${value} ${expected ? "is" : "is not"} a multiple of ${base}`, () => expect(isMultipleOf(value, base)).toBe(expected)), ); + + // With the current implementation this is guaranteed to be true. This test + // is left in incase that implementation changes. + [123456.123456, -123456.123456, 1.23456789, -1.23456789].map(value => + it(`${value} should be a multiple of its precision (${precision( + value, + )})`, () => expect(isMultipleOf(value, precision(value))).toBe(true)), + ); }); }); diff --git a/modules/drivers/bigquery/src/metabase/driver/bigquery.clj b/modules/drivers/bigquery/src/metabase/driver/bigquery.clj index 745286e2dea5baade6fb83205a2f7b46a13dfc2d..e99388ae242746f6545e2940d75cd2969d2882ff 100644 --- a/modules/drivers/bigquery/src/metabase/driver/bigquery.clj +++ b/modules/drivers/bigquery/src/metabase/driver/bigquery.clj @@ -12,6 +12,7 @@ [metabase.driver.google :as google] [metabase.driver.sql.util.unprepare :as unprepare] [metabase.query-processor + [error-type :as error-type] [store :as qp.store] [timezone :as qp.timezone] [util :as qputil]] @@ -20,8 +21,7 @@ (:import com.google.api.client.googleapis.auth.oauth2.GoogleCredential com.google.api.client.http.HttpRequestInitializer [com.google.api.services.bigquery Bigquery Bigquery$Builder BigqueryScopes] - [com.google.api.services.bigquery.model QueryRequest QueryResponse Table TableCell TableFieldSchema TableList - TableList$Tables TableReference TableRow TableSchema] + [com.google.api.services.bigquery.model QueryRequest QueryResponse Table TableCell TableFieldSchema TableList TableList$Tables TableReference TableRow TableSchema] java.util.Collections)) (driver/register! :bigquery, :parent #{:google :sql}) @@ -204,9 +204,14 @@ (defn- process-native* [database query-string] {:pre [(map? database) (map? (:details database))]} ;; automatically retry the query if it times out or otherwise fails. This is on top of the auto-retry added by - ;; `execute` so operations going through `process-native*` may be retried up to 3 times. - (u/auto-retry 1 - (post-process-native (execute-bigquery database query-string)))) + ;; `execute` + (letfn [(thunk [] + (post-process-native (execute-bigquery database query-string)))] + (try + (thunk) + (catch Throwable e + (when-not (error-type/client-error? (:type (u/all-ex-data e))) + (thunk)))))) (defn- effective-query-timezone-id [database] (if (get-in database [:details :use-jvm-timezone]) diff --git a/modules/drivers/bigquery/src/metabase/driver/bigquery/query_processor.clj b/modules/drivers/bigquery/src/metabase/driver/bigquery/query_processor.clj index 7c180473167682d50e98e213f894d0d6363f2836..50dc38ea85641197fd96a1e7f48eaff65a5d1fbc 100644 --- a/modules/drivers/bigquery/src/metabase/driver/bigquery/query_processor.clj +++ b/modules/drivers/bigquery/src/metabase/driver/bigquery/query_processor.clj @@ -3,6 +3,7 @@ [clojure.tools.logging :as log] [honeysql [core :as hsql] + [format :as hformat] [helpers :as h]] [java-time :as t] [metabase @@ -154,7 +155,10 @@ (defmethod temporal-type :datetime-field [[_ field unit]] ;; date extraction operations result in integers, so the type of the expression shouldn't be a temporal type - (if (u.date/extract-units unit) + ;; + ;; `:year` is both an extract unit and a truncate unit in terms of `u.date` capabilities, but in MBQL it should be a + ;; truncation operation + (if ((disj u.date/extract-units :year) unit) nil (temporal-type field))) @@ -166,7 +170,13 @@ [:field-id id] (temporal-type (qp.store/field id)) [:field-literal _ base-type] (base-type->temporal-type base-type)))) +(defn- with-temporal-type [x new-type] + (if (= (temporal-type x) new-type) + x + (vary-meta x assoc :bigquery/temporal-type new-type))) + (defmulti ^:private ->temporal-type + "Coerce `x` to target temporal type." {:arglists '([target-type x])} (fn [target-type x] [target-type (mbql.u/dispatch-by-clause-name-or-class x)]) @@ -212,7 +222,7 @@ nil (= (temporal-type x) target-type) - (vary-meta x assoc :bigquery/temporal-type target-type) + (with-temporal-type x target-type) :else (let [hsql-form (sql.qp/->honeysql :bigquery x) @@ -227,13 +237,12 @@ nil (= (temporal-type hsql-form) target-type) - (vary-meta hsql-form assoc :bigquery/temporal-type target-type) + (with-temporal-type hsql-form target-type) bigquery-type (do - (log/tracef "Casting %s (temporal type = %s) to %s" (binding [*print-meta* true] (pr-str x)) (temporal-type x) bigquery-type) - (with-meta (hx/cast bigquery-type (sql.qp/->honeysql :bigquery x)) - {:bigquery/temporal-type target-type})) + (log/tracef "Coercing %s (temporal type = %s) to %s" (binding [*print-meta* true] (pr-str x)) (pr-str (temporal-type x)) bigquery-type) + (with-temporal-type (hx/cast bigquery-type (sql.qp/->honeysql :bigquery x)) target-type)) :else x)))) @@ -242,21 +251,49 @@ [target-type [_ t unit]] [:absolute-datetime (->temporal-type target-type t) unit]) +(def ^:private temporal-type->supported-units + {:timestamp #{:microsecond :millisecond :second :minute :hour :day} + :datetime #{:microsecond :millisecond :second :minute :hour :day :week :month :quarter :year} + :date #{:day :week :month :quarter :year} + :time #{:microsecond :millisecond :second :minute :hour}}) + +(defmethod ->temporal-type [:temporal-type :relative-datetime] + [target-type [_ _ unit :as clause]] + {:post [(= target-type (temporal-type %))]} + (with-temporal-type + ;; check and see whether we need to do a conversion. If so, use the parent method which will just wrap this in a + ;; cast statement. + (if ((temporal-type->supported-units target-type) unit) + clause + ((get-method ->temporal-type :default) target-type clause)) + target-type)) + +(defrecord ^:private TruncForm [hsql-form unit] + hformat/ToSql + (to-sql [_] + (let [t (or (temporal-type hsql-form) :datetime) + f (case t + :date :date_trunc + :time :time_trunc + :datetime :datetime_trunc + :timestamp :timestamp_trunc)] + (hformat/to-sql (hsql/call f (->temporal-type t hsql-form) (hsql/raw (name unit))))))) + +(defmethod temporal-type TruncForm + [trunc-form] + (temporal-type (:hsql-form trunc-form))) + +(defmethod ->temporal-type [:temporal-type TruncForm] + [target-type trunc-form] + (map->TruncForm (update trunc-form :hsql-form (partial ->temporal-type target-type)))) + (defn- trunc "Generate a SQL call an appropriate truncation function, depending on the temporal type of `expr`." - [unit expr] - (let [expr-type (or (temporal-type expr) :datetime) - f (case expr-type - :date :date_trunc - :time :time_trunc - :datetime :datetime_trunc - :timestamp :timestamp_trunc)] - (with-meta (hsql/call f (->temporal-type expr-type expr) (hsql/raw (name unit))) - {:bigquery/temporal-type expr-type}))) + [unit hsql-form] + (TruncForm. hsql-form unit)) (defn- extract [unit expr] - (with-meta (hsql/call :extract unit (->temporal-type :timestamp expr)) - {:bigquery/temporal-type nil})) + (with-temporal-type (hsql/call :extract unit (->temporal-type :timestamp expr)) nil)) (defmethod sql.qp/date [:bigquery :minute] [_ _ expr] (trunc :minute expr)) (defmethod sql.qp/date [:bigquery :minute-of-hour] [_ _ expr] (extract :minute expr)) @@ -279,7 +316,7 @@ :milliseconds :timestamp_millis}] (defmethod sql.qp/unix-timestamp->timestamp [:bigquery unix-timestamp-type] [_ _ expr] - (vary-meta (hsql/call bigquery-fn expr) assoc :bigquery/temporal-type :timestamp))) + (with-temporal-type (hsql/call bigquery-fn expr) :timestamp))) (defmethod sql.qp/->float :bigquery [_ value] @@ -320,7 +357,7 @@ [driver field] (let [parent-method (get-method sql.qp/->honeysql [:sql (class Field)]) identifier (parent-method driver field)] - (vary-meta identifier assoc :bigquery/temporal-type (temporal-type field)))) + (with-temporal-type identifier (temporal-type field)))) (defmethod sql.qp/->honeysql [:bigquery Identifier] [_ identifier] @@ -336,7 +373,14 @@ (defmethod sql.qp/->honeysql [:bigquery clause-type] [driver clause] (let [hsql-form ((get-method sql.qp/->honeysql [:sql clause-type]) driver clause)] - (vary-meta hsql-form assoc :bigquery/temporal-type (temporal-type clause))))) + (with-temporal-type hsql-form (temporal-type clause))))) + +(defmethod sql.qp/->honeysql [:bigquery :relative-datetime] + [driver clause] + ;; wrap the parent method, converting the result if `clause` itself is typed + (let [t (temporal-type clause)] + (cond->> ((get-method sql.qp/->honeysql [:sql :relative-datetime]) driver clause) + t (->temporal-type t)))) (s/defn ^:private honeysql-form->sql :- s/Str [driver, honeysql-form :- su/Map] @@ -407,8 +451,7 @@ ;; ;; TODO - we should make sure these are in the QP store somewhere and then could at least batch the calls (let [table-name (db/select-one-field :name table/Table :id (u/get-id table-id))] - (with-meta (hx/identifier :field table-name field-name) - {:bigquery/temporal-type (temporal-type field)}))) + (with-temporal-type (hx/identifier :field table-name field-name) (temporal-type field)))) (defmethod sql.qp/apply-top-level-clause [:bigquery :breakout] [driver _ honeysql-form {breakouts :breakout, fields :fields}] @@ -441,8 +484,9 @@ (if-let [target-type (or (temporal-type f) (some temporal-type args))] (do (log/tracef "Coercing args in %s to temporal type %s" (binding [*print-meta* true] (pr-str clause)) target-type) - (u/prog1 (into [clause-type] (map (partial ->temporal-type target-type) (cons f args))) - (when-not (= clause <>) + (u/prog1 (into [clause-type] (map (partial ->temporal-type target-type) + (cons f args))) + (when (not= [clause (meta clause)] [<> (meta <>)]) (log/tracef "Coerced -> %s" (binding [*print-meta* true] (pr-str <>)))))) clause)) @@ -458,18 +502,55 @@ ;;; | Other Driver / SQLDriver Method Implementations | ;;; +----------------------------------------------------------------------------------------------------------------+ -(defmethod driver/date-add :bigquery - [driver expr amount unit] - (let [add-fn (case (temporal-type expr) - :timestamp :timestamp_add - :datetime :datetime_add - :date :date_add - :time :time_add - nil)] - (if-not add-fn - (driver/date-add driver (->temporal-type :datetime expr) amount unit) - (with-meta (hsql/call add-fn expr (hsql/raw (format "INTERVAL %d %s" (int amount) (name unit)))) - {:bigquery/temporal-type (temporal-type expr)})))) +(defn- interval [amount unit] + (hsql/raw (format "INTERVAL %d %s" (int amount) (name unit)))) + +(defn- assert-addable-unit [t-type unit] + (when-not (contains? (temporal-type->supported-units t-type) unit) + ;; trying to add an `hour` to a `date` or a `year` to a `time` is something we shouldn't be allowing in the UI in + ;; the first place + (throw (ex-info (tru "Invalid query: you cannot add a {0} to a {1} column." + (name unit) (name t-type)) + {:type error-type/invalid-query})))) + +;; We can coerce the HoneySQL form this wraps to whatever we want and generate the appropriate SQL. +;; Thus for something like filtering against a relative datetime +;; +;; [:time-interval <datetime field> -1 :day] +;; +;; +(defrecord ^:private AddIntervalForm [hsql-form amount unit] + hformat/ToSql + (to-sql [_] + (loop [hsql-form hsql-form] + (let [t (temporal-type hsql-form) + add-fn (case t + :timestamp :timestamp_add + :datetime :datetime_add + :date :date_add + :time :time_add + nil)] + (if-not add-fn + (recur (->temporal-type :datetime hsql-form)) + (do + (assert-addable-unit t unit) + (hformat/to-sql (hsql/call add-fn hsql-form (interval amount unit))))))))) + +(defmethod temporal-type AddIntervalForm + [add-interval] + (temporal-type (:hsql-form add-interval))) + +(defmethod ->temporal-type [:temporal-type AddIntervalForm] + [target-type add-interval-form] + (let [current-type (temporal-type (:hsql-form add-interval-form))] + (when (#{[:date :time] [:time :date]} [current-type target-type]) + (throw (ex-info (tru "It doesn''t make sense to convert between DATEs and TIMEs!") + {:type error-type/invalid-query})))) + (map->AddIntervalForm (update add-interval-form :hsql-form (partial ->temporal-type target-type)))) + +(defmethod sql.qp/add-interval-honeysql-form :bigquery + [_ hsql-form amount unit] + (AddIntervalForm. hsql-form amount unit)) (defmethod driver/mbql->native :bigquery [driver @@ -488,9 +569,27 @@ sql.qp/source-query-alias)) :mbql? true}))) -(defmethod sql.qp/current-datetime-fn :bigquery +(defrecord ^:private CurrentMomentForm [t] + hformat/ToSql + (to-sql [_] + (hformat/to-sql + (case (or t :timestamp) + :time :%current_time + :date :%current_date + :datetime :%current_datetime + :timestamp :%current_timestamp)))) + +(defmethod temporal-type CurrentMomentForm + [^CurrentMomentForm current-moment] + (.t current-moment)) + +(defmethod ->temporal-type [:temporal-type CurrentMomentForm] + [t _] + (CurrentMomentForm. t)) + +(defmethod sql.qp/current-datetime-honeysql-form :bigquery [_] - (with-meta (hsql/call :current_timestamp) {:bigquery/temporal-type :timestamp})) + (CurrentMomentForm. nil)) (defmethod sql.qp/quote-style :bigquery [_] diff --git a/modules/drivers/bigquery/test/metabase/driver/bigquery/query_processor_test.clj b/modules/drivers/bigquery/test/metabase/driver/bigquery/query_processor_test.clj index 0d4715288a75b847e9460cf8e82c90212ef370f9..d288029ee9dde306606cbdb7cc9c1950c7b28e8a 100644 --- a/modules/drivers/bigquery/test/metabase/driver/bigquery/query_processor_test.clj +++ b/modules/drivers/bigquery/test/metabase/driver/bigquery/query_processor_test.clj @@ -1,6 +1,10 @@ (ns metabase.driver.bigquery.query-processor-test - (:require [clojure.test :refer :all] - [honeysql.core :as hsql] + (:require [clojure + [string :as str] + [test :refer :all]] + [honeysql + [core :as hsql] + [format :as hformat]] [java-time :as t] [metabase [driver :as driver] @@ -305,6 +309,36 @@ (is (= [:= (hsql/call :extract :dayofweek expected-identifier) 1] (sql.qp/->honeysql :bigquery [:= [:datetime-field [:field-id (:id field)] :day-of-week] 1])))))))))) +(deftest reconcile-relative-datetimes-test + (testing "relative-datetime clauses on their own" + (doseq [[t [unit expected-sql]] + {:time [:hour "time_trunc(time_add(current_time(), INTERVAL -1 hour), hour)"] + :date [:year "date_trunc(date_add(current_date(), INTERVAL -1 year), year)"] + :datetime [:year "datetime_trunc(datetime_add(current_datetime(), INTERVAL -1 year), year)"] + ;; timestamp_add doesn't support `year` so this should cast a datetime instead + :timestamp [:year "CAST(datetime_trunc(datetime_add(current_datetime(), INTERVAL -1 year), year) AS timestamp)"]}] + (testing t + (let [reconciled-clause (#'bigquery.qp/->temporal-type t [:relative-datetime -1 unit])] + (is (= t + (#'bigquery.qp/temporal-type reconciled-clause)) + "Should have correct type metadata after reconciliation") + (is (= [(str "WHERE " expected-sql)] + (sql.qp/format-honeysql :bigquery + {:where (sql.qp/->honeysql :bigquery reconciled-clause)})) + "Should get converted to the correct SQL"))))) + + (testing "relative-datetime clauses inside filter clauses" + (doseq [[expected-type t] {:date #t "2020-01-31" + :datetime #t "2020-01-31T20:43:00.000" + :timestamp #t "2020-01-31T20:43:00.000-08:00"}] + (testing expected-type + (let [[_ _ relative-datetime] (sql.qp/->honeysql :bigquery + [:= + t + [:relative-datetime -1 :year]])] + (is (= expected-type + (#'bigquery.qp/temporal-type relative-datetime)))))))) + (deftest between-test (testing "Make sure :between clauses reconcile the temporal types of their args" (letfn [(between->sql [clause] @@ -413,8 +447,8 @@ :filter filter-clause}))))))))))))) (deftest datetime-parameterized-sql-test - (testing "Make sure Field filters against temporal fields generates correctly-typed SQL (#11578)" - (mt/test-driver :bigquery + (mt/test-driver :bigquery + (testing "Make sure Field filters against temporal fields generates correctly-typed SQL (#11578)" (mt/dataset attempted-murders (doseq [field [:datetime :date @@ -440,3 +474,102 @@ :name "d" :target [:dimension [:template-tag "d"]] :value value}]}))))))))))) + +(deftest current-datetime-honeysql-form-test + (testing (str "The object returned by `current-datetime-honeysql-form` should be a magic object that can take on " + "whatever temporal type we want.") + (let [form (sql.qp/current-datetime-honeysql-form :bigquery)] + (is (= nil + (#'bigquery.qp/temporal-type form)) + "When created the temporal type should be unspecified. The world's your oyster!") + (is (= ["current_timestamp()"] + (hformat/format form)) + "Should fall back to acting like a timestamp if we don't coerce it to something else first") + (doseq [[temporal-type expected-sql] {:date "current_date()" + :time "current_time()" + :datetime "current_datetime()" + :timestamp "current_timestamp()"}] + (testing (format "temporal type = %s" temporal-type) + (is (= temporal-type + (#'bigquery.qp/temporal-type (#'bigquery.qp/->temporal-type temporal-type form))) + "Should be possible to convert to another temporal type/should report its type correctly") + (is (= [expected-sql] + (hformat/format (#'bigquery.qp/->temporal-type temporal-type form))) + "Should convert to the correct SQL")))))) + +(deftest add-interval-honeysql-form-test + ;; this doesn't test conversion to/from time because there's no unit we can use that works for all for. So we'll + ;; just test the 3 that support `:day` and that should be proof the logic is working. (The code that actually uses + ;; this is tested e2e by `filter-by-relative-date-ranges-test` anyway.) + (doseq [initial-type [:date :datetime :timestamp] + :let [form (sql.qp/add-interval-honeysql-form + :bigquery + (#'bigquery.qp/->temporal-type + initial-type + (sql.qp/current-datetime-honeysql-form :bigquery)) + -1 + :day)]] + (testing (format "initial form = %s" (pr-str form)) + (is (= initial-type + (#'bigquery.qp/temporal-type form)) + "Should have the temporal-type of the form it wraps when created.") + (doseq [[new-type expected-sql] {:date "date_add(current_date(), INTERVAL -1 day)" + :datetime "datetime_add(current_datetime(), INTERVAL -1 day)" + :timestamp "timestamp_add(current_timestamp(), INTERVAL -1 day)"}] + (testing (format "\nconvert from %s -> %s" initial-type new-type) + (is (= new-type + (#'bigquery.qp/temporal-type (#'bigquery.qp/->temporal-type new-type form))) + "Should be possible to convert to another temporal type/should report its type correctly") + (is (= [expected-sql] + (hformat/format (#'bigquery.qp/->temporal-type new-type form))) + "Should convert to the correct SQL")))))) + +(defn- can-we-filter-against-relative-datetime? [field unit] + (let [{:keys [error]} (mt/run-mbql-query attempts + {:aggregation [[:count]] + :filter [:time-interval (mt/id :attempts field) :last unit]})] + (not error))) + +(deftest filter-by-relative-date-ranges-test + (testing "Make sure the SQL we generate for filters against relative-datetimes is typed correctly" + (mt/with-everything-store + (binding [sql.qp/*table-alias* "ABC"] + (doseq [[field-type [unit expected-sql]] + {:type/Time [:hour (str "WHERE time_trunc(ABC.time, hour)" + " = time_trunc(time_add(current_time(), INTERVAL -1 hour), hour)")] + :type/Date [:year (str "WHERE date_trunc(ABC.date, year)" + " = date_trunc(date_add(current_date(), INTERVAL -1 year), year)")] + :type/DateTime [:year (str "WHERE datetime_trunc(ABC.datetime, year)" + " = datetime_trunc(datetime_add(current_datetime(), INTERVAL -1 year), year)")] + ;; `timestamp_add` doesn't support `year` so it should cast a `datetime_trunc` instead + :type/DateTimeWithLocalTZ [:year (str "WHERE timestamp_trunc(ABC.datetimewithlocaltz, year)" + " = CAST(datetime_trunc(datetime_add(current_datetime(), INTERVAL -1 year), year) AS timestamp)")]}] + (mt/with-temp Field [f {:name (str/lower-case (name field-type)), :base_type field-type}] + (testing (format "%s field" field-type) + (is (= [expected-sql] + (hsql/format {:where (sql.qp/->honeysql :bigquery [:= + [:datetime-field [:field-id (:id f)] unit] + [:relative-datetime -1 unit]])})))))))))) + +(deftest filter-by-relative-date-ranges-e2e-test + (mt/test-driver :bigquery + (testing (str "Make sure filtering against relative date ranges works correctly regardless of underlying column " + "type (#11725)") + (mt/dataset attempted-murders + (is (= [[nil :minute :hour :day :week :month :quarter :year] + [:time true true false false false false false] + [:datetime true true true true true true true] + [:date false false true true true true true] + [:datetime_tz true true true true true true true]] + (let [units [:minute :hour :day :week :month :quarter :year] + fields [:time :datetime :date :datetime_tz]] + + (into + [(into [nil] units)] + (pmap + (fn [field] + (into [field] (pmap + (fn [unit] + (boolean (can-we-filter-against-relative-datetime? field unit))) + units))) + fields))))))))) diff --git a/modules/drivers/google/src/metabase/driver/google.clj b/modules/drivers/google/src/metabase/driver/google.clj index 176cc78f2e4c725eee8dcaf0313ac73729ea2207..8ca1bebe8d71684eed310d1bd135e250cbd30659 100644 --- a/modules/drivers/google/src/metabase/driver/google.clj +++ b/modules/drivers/google/src/metabase/driver/google.clj @@ -6,10 +6,10 @@ [driver :as driver] [util :as u]] [metabase.models.database :refer [Database]] + [metabase.query-processor.error-type :as error-type] [ring.util.codec :as codec] [toucan.db :as db]) - (:import [com.google.api.client.googleapis.auth.oauth2 GoogleAuthorizationCodeFlow GoogleAuthorizationCodeFlow$Builder - GoogleCredential GoogleCredential$Builder GoogleTokenResponse] + (:import [com.google.api.client.googleapis.auth.oauth2 GoogleAuthorizationCodeFlow GoogleAuthorizationCodeFlow$Builder GoogleCredential GoogleCredential$Builder GoogleTokenResponse] com.google.api.client.googleapis.javanet.GoogleNetHttpTransport [com.google.api.client.googleapis.json GoogleJsonError GoogleJsonResponseException] com.google.api.client.googleapis.services.AbstractGoogleClientRequest @@ -30,7 +30,7 @@ (def ^:private ^:const ^String redirect-uri "urn:ietf:wg:oauth:2.0:oob") (defn execute-no-auto-retry - "`execute` REQUEST, and catch any `GoogleJsonResponseException` is throws, converting them to `ExceptionInfo` and + "Execute `request`, and catch any `GoogleJsonResponseException` is throws, converting them to `ExceptionInfo` and rethrowing them." [^AbstractGoogleClientRequest request] (try (.execute request) @@ -44,10 +44,13 @@ "Execute `request`, and catch any `GoogleJsonResponseException` is throws, converting them to `ExceptionInfo` and rethrowing them. - This automatically retries any failed requests up to 2 times." + This automatically retries any failed requests." [^AbstractGoogleClientRequest request] - (u/auto-retry 2 - (execute-no-auto-retry request))) + (try + (execute-no-auto-retry request) + (catch Throwable e + (when-not (error-type/client-error? (:type (u/all-ex-data e))) + (execute-no-auto-retry request))))) (defn- create-application-name "Creates the application name string, separated out from the `def` below so it's testable with different values" diff --git a/modules/drivers/googleanalytics/test/metabase/driver/googleanalytics_test.clj b/modules/drivers/googleanalytics/test/metabase/driver/googleanalytics_test.clj index 218b88a8b4e5ef5054cb0831430b0ed5eafb53f5..4cc3e96f0182ba25640a5b667fa8cdc77bb35100 100644 --- a/modules/drivers/googleanalytics/test/metabase/driver/googleanalytics_test.clj +++ b/modules/drivers/googleanalytics/test/metabase/driver/googleanalytics_test.clj @@ -341,7 +341,7 @@ (tt/with-temp* [Database [db {:engine :googleanalytics}] Table [table {:db_id (u/get-id db)}] Field [field {:table_id (u/get-id table)}]] - (let [cnt (->> ((users/user->client :crowberto) :post 200 "card" + (let [cnt (->> ((users/user->client :crowberto) :post 202 "card" {:name "Metabase Websites, Sessions and 1 Day Active Users, Grouped by Date (day)" :display :table :visualization_settings {} diff --git a/modules/drivers/oracle/src/metabase/driver/oracle.clj b/modules/drivers/oracle/src/metabase/driver/oracle.clj index 27f4be986e5082ee51f2afdb6004028c9193ec7e..bfb0b35547b426645bdc73a11ab6eb750e711906 100644 --- a/modules/drivers/oracle/src/metabase/driver/oracle.clj +++ b/modules/drivers/oracle/src/metabase/driver/oracle.clj @@ -134,16 +134,18 @@ (defn- num-to-ym-interval [unit v] (hsql/call :numtoyminterval v (hx/literal unit))) (defmethod driver/date-add :oracle - [_ dt amount unit] - (hx/+ (hx/->timestamp dt) (case unit - :second (num-to-ds-interval :second amount) - :minute (num-to-ds-interval :minute amount) - :hour (num-to-ds-interval :hour amount) - :day (num-to-ds-interval :day amount) - :week (num-to-ds-interval :day (hx/* amount (hsql/raw 7))) - :month (num-to-ym-interval :month amount) - :quarter (num-to-ym-interval :month (hx/* amount (hsql/raw 3))) - :year (num-to-ym-interval :year amount)))) + [_ hsql-form amount unit] + (hx/+ + (hx/->timestamp hsql-form) + (case unit + :second (num-to-ds-interval :second amount) + :minute (num-to-ds-interval :minute amount) + :hour (num-to-ds-interval :hour amount) + :day (num-to-ds-interval :day amount) + :week (num-to-ds-interval :day (hx/* amount (hsql/raw 7))) + :month (num-to-ym-interval :month amount) + :quarter (num-to-ym-interval :month (hx/* amount (hsql/raw 3))) + :year (num-to-ym-interval :year amount)))) (defmethod sql.qp/unix-timestamp->timestamp [:oracle :seconds] [_ _ field-or-value] diff --git a/modules/drivers/presto/src/metabase/driver/presto.clj b/modules/drivers/presto/src/metabase/driver/presto.clj index 75665e14a77291cfc08a5778fbe4bbc0920f5c6d..63e4af8f9c262a9195409b34faf44013c982d23b 100644 --- a/modules/drivers/presto/src/metabase/driver/presto.clj +++ b/modules/drivers/presto/src/metabase/driver/presto.clj @@ -140,8 +140,8 @@ (= v "information_schema"))) (defmethod driver/date-add :presto - [_ dt amount unit] - (hsql/call :date_add (hx/literal unit) amount dt)) + [_ hsql-form amount unit] + (hsql/call :date_add (hx/literal unit) amount hsql-form)) (s/defn ^:private database->all-schemas :- #{su/NonBlankString} "Return a set of all schema names in this `database`." diff --git a/modules/drivers/redshift/src/metabase/driver/redshift.clj b/modules/drivers/redshift/src/metabase/driver/redshift.clj index 7e40a31b6103e3973b18d53aaece93f4777a7bd8..44d976f1e26ccfae6bf918e9c388f5cfa7459106 100644 --- a/modules/drivers/redshift/src/metabase/driver/redshift.clj +++ b/modules/drivers/redshift/src/metabase/driver/redshift.clj @@ -82,8 +82,8 @@ ;;; +----------------------------------------------------------------------------------------------------------------+ (defmethod driver/date-add :redshift - [_ dt amount unit] - (hsql/call :dateadd (hx/literal unit) amount (hx/->timestamp dt))) + [_ hsql-form amount unit] + (hsql/call :dateadd (hx/literal unit) amount (hx/->timestamp hsql-form))) (defmethod sql.qp/unix-timestamp->timestamp [:redshift :seconds] [_ _ expr] diff --git a/modules/drivers/snowflake/src/metabase/driver/snowflake.clj b/modules/drivers/snowflake/src/metabase/driver/snowflake.clj index 5bf66119366adedc342065a4d71ab9d5a1f6728b..756755625da81ef73e8a28a6827c55084154f901 100644 --- a/modules/drivers/snowflake/src/metabase/driver/snowflake.clj +++ b/modules/drivers/snowflake/src/metabase/driver/snowflake.clj @@ -106,11 +106,11 @@ :%current_timestamp) (defmethod driver/date-add :snowflake - [_ dt amount unit] + [_ hsql-form amount unit] (hsql/call :dateadd (hsql/raw (name unit)) (hsql/raw (int amount)) - (hx/->timestamp dt))) + (hx/->timestamp hsql-form))) (defn- extract [unit expr] (hsql/call :date_part unit (hx/->timestamp expr))) (defn- date-trunc [unit expr] (hsql/call :date_trunc unit (hx/->timestamp expr))) diff --git a/modules/drivers/sparksql/src/metabase/driver/hive_like.clj b/modules/drivers/sparksql/src/metabase/driver/hive_like.clj index 6b679330ab83756a63deda0e03a177401a769267..fd05e378fe800507d411d2aaa55dde86efc1749e 100644 --- a/modules/drivers/sparksql/src/metabase/driver/hive_like.clj +++ b/modules/drivers/sparksql/src/metabase/driver/hive_like.clj @@ -113,8 +113,8 @@ 3))) (defmethod driver/date-add :hive-like - [_ dt amount unit] - (hx/+ (hx/->timestamp dt) (hsql/raw (format "(INTERVAL '%d' %s)" (int amount) (name unit))))) + [_ hsql-form amount unit] + (hx/+ (hx/->timestamp hsql-form) (hsql/raw (format "(INTERVAL '%d' %s)" (int amount) (name unit))))) ;; ignore the schema when producing the identifier (defn qualified-name-components diff --git a/modules/drivers/sqlite/src/metabase/driver/sqlite.clj b/modules/drivers/sqlite/src/metabase/driver/sqlite.clj index c4ce0e037f85cebc2af5ce8e8c1ad5c4d86d95cf..4a0d941c054af96749b0b47671d9bf3d7b2a5509 100644 --- a/modules/drivers/sqlite/src/metabase/driver/sqlite.clj +++ b/modules/drivers/sqlite/src/metabase/driver/sqlite.clj @@ -162,7 +162,7 @@ (->date (sql.qp/->honeysql driver expr) (hx/literal "start of year"))) (defmethod driver/date-add :sqlite - [driver dt amount unit] + [driver hsql-form amount unit] (let [[multiplier sqlite-unit] (case unit :second [1 "seconds"] :minute [1 "minutes"] @@ -184,7 +184,7 @@ ;; The SQL we produce instead (for "last month") ends up looking something like: ;; DATE(DATETIME(DATE('2015-03-30', 'start of month'), '-1 month'), 'start of month'). ;; It's a little verbose, but gives us the correct answer (Feb 1st). - (->datetime (sql.qp/date driver unit dt) + (->datetime (sql.qp/date driver unit hsql-form) (hx/literal (format "%+d %s" (* amount multiplier) sqlite-unit))))) (defmethod sql.qp/unix-timestamp->timestamp [:sqlite :seconds] diff --git a/modules/drivers/sqlserver/src/metabase/driver/sqlserver.clj b/modules/drivers/sqlserver/src/metabase/driver/sqlserver.clj index a5e8e65b8751a7f436092160d38a8fc8bff441ef..82919180d29a9ce078afc0f6111bcdf72d329bf2 100644 --- a/modules/drivers/sqlserver/src/metabase/driver/sqlserver.clj +++ b/modules/drivers/sqlserver/src/metabase/driver/sqlserver.clj @@ -180,8 +180,8 @@ (hsql/call :datefromparts (hx/year expr) 1 1)) (defmethod driver/date-add :sqlserver - [_ dt amount unit] - (date-add unit amount dt)) + [_ hsql-form amount unit] + (date-add unit amount hsql-form)) (defmethod sql.qp/unix-timestamp->timestamp [:sqlserver :seconds] [_ _ expr] diff --git a/modules/drivers/vertica/src/metabase/driver/vertica.clj b/modules/drivers/vertica/src/metabase/driver/vertica.clj index fbec1bde96982b0f4513f47dad9daa6d82979e76..79d70d25a380934d44c568b355bfd1af748695b3 100644 --- a/modules/drivers/vertica/src/metabase/driver/vertica.clj +++ b/modules/drivers/vertica/src/metabase/driver/vertica.clj @@ -96,8 +96,8 @@ one-day)) (defmethod driver/date-add :vertica - [_ dt amount unit] - (hx/+ (hx/->timestamp dt) (hsql/raw (format "(INTERVAL '%d %s')" (int amount) (name unit))))) + [_ hsql-form amount unit] + (hx/+ (hx/->timestamp hsql-form) (hsql/raw (format "(INTERVAL '%d %s')" (int amount) (name unit))))) (defn- materialized-views "Fetch the Materialized Views for a Vertica `database`. diff --git a/resources/migrations/000_migrations.yaml b/resources/migrations/000_migrations.yaml index 141a14774f8acd315bcd11cd5714181f31d74e29..6eb884981d66d219ce8bbb1372f17f7ce90a721c 100644 --- a/resources/migrations/000_migrations.yaml +++ b/resources/migrations/000_migrations.yaml @@ -5304,3 +5304,875 @@ databaseChangeLog: tableName: metabase_field columnName: database_type newDataType: text + +# +# Migrations 107-160 are used to convert a MySQL or MariaDB database to utf8mb4 on launch -- see #11753 for a detailed explanation of these migrations +# + + - changeSet: + id: 107 + author: camsaul + comment: Added 0.34.2 + # If this migration fails for any reason continue with the next migration; do not fail the entire process if this one fails + failOnError: false + preConditions: + # If preconditions fail (i.e., dbms is not mysql or mariadb) then mark this migration as 'ran' + - onFail: MARK_RAN + # If we're generating SQL output for migrations instead of running via liquibase, fail the preconditions which means these migrations will be skipped + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER DATABASE CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + - changeSet: + id: 108 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `DATABASECHANGELOG` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 109 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `DATABASECHANGELOGLOCK` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 110 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `QRTZ_CALENDARS` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 111 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `QRTZ_FIRED_TRIGGERS` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 112 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `QRTZ_JOB_DETAILS` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 113 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `QRTZ_LOCKS` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 114 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `QRTZ_PAUSED_TRIGGER_GRPS` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 115 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `QRTZ_SCHEDULER_STATE` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 116 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `core_user` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 117 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `data_migrations` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 118 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `dependency` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 119 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `label` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 120 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `metabase_database` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 121 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `permissions_group` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 122 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `query` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 123 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `query_cache` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 124 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `query_execution` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 125 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `setting` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 126 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `task_history` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 127 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `QRTZ_TRIGGERS` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 128 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `activity` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 129 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `collection` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 130 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `collection_revision` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 131 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `computation_job` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 132 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `core_session` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 133 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `metabase_table` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 134 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `permissions` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 135 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `permissions_revision` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 136 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `revision` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 137 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `view_log` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 138 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `QRTZ_BLOB_TRIGGERS` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 139 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `QRTZ_CRON_TRIGGERS` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 140 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `QRTZ_SIMPLE_TRIGGERS` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 141 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `QRTZ_SIMPROP_TRIGGERS` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 142 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `computation_job_result` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 143 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `metabase_field` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 144 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `permissions_group_membership` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 145 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `pulse` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 146 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `report_dashboard` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 147 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `dashboard_favorite` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 148 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `dimension` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 149 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `metabase_fieldvalues` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 150 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `metric` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 151 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `pulse_channel` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 152 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `segment` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 153 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `pulse_channel_recipient` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 154 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `report_card` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 155 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `metric_important_field` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 156 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `report_cardfavorite` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 157 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `card_label` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 158 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `pulse_card` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 159 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `report_dashboardcard` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + - changeSet: + id: 160 + author: camsaul + comment: Added 0.34.2 + failOnError: false + preConditions: + - onFail: MARK_RAN + - onSqlOutput: FAIL + - or: + - dbms: + type: mysql + - dbms: + type: mariadb + changes: + - sql: + sql: ALTER TABLE `dashboardcard_series` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj index fab99e4df3758c73f517244e5516b47db7cc149c..d3d4a0907eee5ebc0541762f945f6aa91e67e401 100644 --- a/src/metabase/api/card.clj +++ b/src/metabase/api/card.clj @@ -144,8 +144,8 @@ `all`, but other options include `mine`, `fav`, `database`, `table`, `recent`, `popular`, and `archived`. See corresponding implementation functions above for the specific behavior of each filter option. :card_index:" [f model_id] - {f (s/maybe CardFilterOption) - model_id (s/maybe su/IntGreaterThanZero)} + {f (s/maybe CardFilterOption) + model_id (s/maybe su/IntGreaterThanZero)} (let [f (keyword f)] (when (contains? #{:database :table} f) (api/checkp (integer? model_id) "model_id" (format "model_id is a required parameter when filter mode is '%s'" @@ -157,7 +157,6 @@ ;; filterv because we want make sure all the filtering is done while current user perms set is still bound (filterv mi/can-read?)))) - (api/defendpoint GET "/:id" "Get `Card` with ID." [id] @@ -166,7 +165,6 @@ api/read-check) (events/publish-event! :card-read (assoc <> :actor_id api/*current-user-id*)))) - ;;; -------------------------------------------------- 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 @@ -175,8 +173,6 @@ ;; 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 result-metadata-async :- ManyToManyChannel "Get the right results metadata for this `card`, and return them in a channel. We'll check to see whether the `metadata` passed in seems valid,and, if so, return a channel that returns the value as-is; otherwise, we'll run the @@ -224,7 +220,7 @@ (let [data-keys [:dataset_query :description :display :name :visualization_settings :collection_id :collection_position] card-data (assoc (zipmap data-keys (map card-data data-keys)) - :creator_id api/*current-user-id*) + :creator_id api/*current-user-id*) result-metadata-chan (result-metadata-async dataset_query result_metadata metadata_checksum) out-chan (a/chan 1)] (a/go @@ -236,12 +232,12 @@ (async.u/single-value-pipe (save-new-card-async! card-data) out-chan)) (catch Throwable e (a/put! out-chan e) - (a/close! e)))) + (a/close! out-chan)))) ;; Return a channel out-chan)) -(api/defendpoint POST "/" +(api/defendpoint ^:returns-chan POST "/" "Create a new `Card`." [:as {{:keys [collection_id collection_position dataset_query description display metadata_checksum name result_metadata visualization_settings], :as body} :body}] @@ -418,7 +414,7 @@ ;; has with returned one -- See #4142 (hydrate card :creator :dashboard_count :can_write :collection))))) -(api/defendpoint PUT "/:id" +(api/defendpoint ^:returns-chan PUT "/:id" "Update a `Card`." [id :as {{:keys [dataset_query description display name visualization_settings archived collection_id collection_position enable_embedding embedding_params result_metadata metadata_checksum] @@ -526,6 +522,7 @@ (reverse (range starting-position (+ (count sorted-cards) starting-position))) (reverse sorted-cards))))) + (defn- move-cards-to-collection! [new-collection-id-or-nil card-ids] ;; if moving to a collection, make sure we have write perms for it (when new-collection-id-or-nil @@ -615,14 +612,14 @@ (api/check-not-archived card) (qp.async/process-query-and-save-execution! query options))) -(api/defendpoint POST "/:card-id/query" +(api/defendpoint ^:returns-chan POST "/:card-id/query" "Run the query associated with a Card." [card-id :as {{:keys [parameters ignore_cache], :or {ignore_cache false}} :body}] {ignore_cache (s/maybe s/Bool)} (binding [cache/*ignore-cached-results* ignore_cache] (run-query-for-card-async card-id, :parameters parameters))) -(api/defendpoint-async POST "/:card-id/query/:export-format" +(api/defendpoint-async ^:returns-chan POST "/:card-id/query/:export-format" "Run the query associated with a Card, and return its results as a file in the specified format. Note that this expects the parameters as serialized JSON in the 'parameters' parameter" [{{:keys [card-id export-format parameters]} :params} respond raise] diff --git a/src/metabase/api/common/internal.clj b/src/metabase/api/common/internal.clj index 8172677c5f95b51cdaf79cbc8ea77f633467986b..9a13959d923bc58aa0f82ba9d93f899c46fdac3b 100644 --- a/src/metabase/api/common/internal.clj +++ b/src/metabase/api/common/internal.clj @@ -10,7 +10,8 @@ [metabase.util [i18n :as ui18n :refer [tru]] [schema :as su]] - [schema.core :as s])) + [schema.core :as s]) + (:import clojure.core.async.impl.channels.ManyToManyChannel)) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | DOCSTRING GENERATION | @@ -33,8 +34,8 @@ [form] (cond (map? form) (args-form-flatten (mapcat (fn [[k v]] - [(args-form-flatten k) (args-form-flatten v)]) - form)) + [(args-form-flatten k) (args-form-flatten v)]) + form)) (sequential? form) (mapcat args-form-flatten form) :else [form])) @@ -59,7 +60,7 @@ (log/warn (u/format-color 'red (str "We don't have a nice error message for schema: %s\n" "Consider wrapping it in `su/with-api-error-message`.") - (u/pprint-to-str schema))))))) + (u/pprint-to-str schema))))))) (defn- param-name "Return the appropriate name for this PARAM-SYMB based on its SCHEMA. Usually this is just the name of the @@ -259,5 +260,7 @@ (contains? response :status) (contains? response :body)) response - {:status 200 + {:status (if (instance? ManyToManyChannel response) + 202 + 200) :body response})) diff --git a/src/metabase/api/dataset.clj b/src/metabase/api/dataset.clj index 76228a96597e66286185109e89b5e851d622bfe9..1faa9597c5c2ff5166ef323dcb0a5d8f01879b5a 100644 --- a/src/metabase/api/dataset.clj +++ b/src/metabase/api/dataset.clj @@ -40,7 +40,7 @@ (api/read-check Card source-card-id) source-card-id)) -(api/defendpoint POST "/" +(api/defendpoint ^:returns-chan POST "/" "Execute a query and retrieve the results in the usual format." [:as {{:keys [database], :as query} :body}] {database s/Int} @@ -108,7 +108,7 @@ (api/let-404 [export-conf (ex/export-formats export-format)] (if (= status :completed) ;; successful query, send file - {:status 200 + {:status 202 :body ((:export-fn export-conf) (map #(some % [:display_name :name]) cols) (maybe-modify-date-values cols rows)) @@ -146,7 +146,7 @@ (api/defendpoint POST [\"/:export-format\", :export-format export-format-regex]" (re-pattern (str "(" (str/join "|" (keys ex/export-formats)) ")"))) -(api/defendpoint-async POST ["/:export-format", :export-format export-format-regex] +(api/defendpoint-async ^:returns-chan POST ["/:export-format", :export-format export-format-regex] "Execute a query and download the result data as a file in the specified format." [{{:keys [export-format query]} :params} respond raise] {query su/JSONString diff --git a/src/metabase/api/public.clj b/src/metabase/api/public.clj index a8908795ce7879e67f785c8bf615b69e9fe7d880..ae7e11d804ce1bef575d2d113ba837b038d318dd 100644 --- a/src/metabase/api/public.clj +++ b/src/metabase/api/public.clj @@ -113,14 +113,14 @@ options)) -(api/defendpoint GET "/card/:uuid/query" +(api/defendpoint ^:returns-chan GET "/card/:uuid/query" "Fetch a publicly-accessible Card an return query results as well as `:card` information. Does not require auth credentials. Public sharing must be enabled." [uuid parameters] {parameters (s/maybe su/JSONString)} (run-query-for-card-with-public-uuid-async uuid (json/parse-string parameters keyword))) -(api/defendpoint-async GET "/card/:uuid/query/:export-format" +(api/defendpoint-async ^:returns-chan GET "/card/:uuid/query/:export-format" "Fetch a publicly-accessible Card and return query results in the specified format. Does not require auth credentials. Public sharing must be enabled." [{{:keys [uuid export-format parameters]} :params}, respond raise] @@ -262,7 +262,7 @@ :context context :constraints constraints))) -(api/defendpoint GET "/dashboard/:uuid/card/:card-id" +(api/defendpoint ^:returns-chan GET "/dashboard/:uuid/card/:card-id" "Fetch the results for a Card in a publicly-accessible Dashboard. Does not require auth credentials. Public sharing must be enabled." [uuid card-id parameters] diff --git a/src/metabase/async/api_response.clj b/src/metabase/async/api_response.clj index 8b2aced2eabc9cd8ddd67686b9816f9557030596..4e3fc7a556c43531001c3bc028bd4a4f61de4c62 100644 --- a/src/metabase/async/api_response.clj +++ b/src/metabase/async/api_response.clj @@ -61,7 +61,12 @@ (cond ;; An error has occurred, let the user know (instance? Throwable chunkk) - (json/generate-stream (:body (mw.exceptions/api-exception-response chunkk)) out) + (json/generate-stream (let [{:keys [body status] + :or {status 500}} (mw.exceptions/api-exception-response chunkk)] + (if (map? body) + (assoc body :_status status) + {:message body :_status status})) + out) ;; We've recevied the response, write it to the output stream and we're done (seqable? chunkk) @@ -189,6 +194,6 @@ (extend-protocol Sendable ManyToManyChannel (send* [input-chan _ respond _] - (respond - (assoc (response/response input-chan) - :content-type "applicaton/json; charset=utf-8")))) + (respond (assoc (response/response input-chan) + :content-type "applicaton/json; charset=utf-8" + :status 202)))) diff --git a/src/metabase/db.clj b/src/metabase/db.clj index 9a5ad0b792752c1c48997d6df70fa78233f2f365..cfbafa3f7b3b7f2afd666b113bb8b771f517f945 100644 --- a/src/metabase/db.clj +++ b/src/metabase/db.clj @@ -170,10 +170,10 @@ (migrate! :up)) ([direction] - (migrate! @db-connection-details direction)) + (migrate! (jdbc-spec) direction)) - ([db-details direction] - (jdbc/with-db-transaction [conn (jdbc-spec db-details)] + ([jdbc-spec direction] + (jdbc/with-db-transaction [conn jdbc-spec] ;; Tell transaction to automatically `.rollback` instead of `.commit` when the transaction finishes (log/debug (trs "Set transaction to automatically roll back...")) (jdbc/db-set-rollback-only! conn) @@ -273,8 +273,8 @@ ([driver :- s/Keyword, details :- su/Map] (log/info (u/format-color 'cyan (trs "Verifying {0} Database Connection ..." (name driver)))) + (classloader/require 'metabase.driver.util) (assert (binding [*allow-potentailly-unsafe-connections* true] - (classloader/require 'metabase.driver.util) ((resolve 'metabase.driver.util/can-connect-with-details?) driver details :throw-exceptions)) (trs "Unable to connect to Metabase {0} DB." (name driver))) (jdbc/with-db-metadata [metadata (jdbc-spec details)] @@ -293,7 +293,7 @@ "If we are not doing auto migrations then print out migration SQL for user to run manually. Then throw an exception to short circuit the setup process and make it clear we can't proceed." [db-details] - (let [sql (migrate! db-details :print)] + (let [sql (migrate! (jdbc-spec db-details) :print)] (log/info (str "Database Upgrade Required\n\n" "NOTICE: Your database requires updates to work with this version of Metabase. " "Please execute the following sql commands on your database before proceeding.\n\n" @@ -307,10 +307,10 @@ [auto-migrate? db-details] (log/info (trs "Running Database Migrations...")) (if auto-migrate? - (migrate! db-details :up) + (migrate! (jdbc-spec db-details) :up) ;; if `MB_DB_AUTOMIGRATE` is false, and we have migrations that need to be ran, print and quit. Otherwise continue ;; to start normally - (when (liquibase/with-liquibase [liquibase (jdbc-spec)] + (when (liquibase/with-liquibase [liquibase (jdbc-spec db-details)] (liquibase/has-unrun-migrations? liquibase)) (print-migrations-and-quit! db-details))) (log/info (trs "Database Migrations Current ... ") (u/emoji "✅"))) diff --git a/src/metabase/driver.clj b/src/metabase/driver.clj index 7a7933e944cbb74fcabd20df8b4ba7beb2352350..06aa491f06a2920aae0fd04727fdf00a0e7635c6 100644 --- a/src/metabase/driver.clj +++ b/src/metabase/driver.clj @@ -230,12 +230,13 @@ dispatch-on-initialized-driver :hierarchy #'hierarchy) +(defmulti ^{:deprecated "0.34.2"} date-add + "DEPRECATED -- this method is only used or implemented by `:sql` drivers. It has been superseded by + `metabase.driver.sql.query-processor/add-interval-honeysql-form`. Use/implement that method instead. DO NOT use or + implement this method for non-`:sql` drivers. -;; TODO - this is only used (or implemented for that matter) by SQL drivers. This should probably be moved into the -;; `:sql` driver. Don't bother to implement this for non-SQL drivers. -(defmulti date-add - "Return an driver-appropriate representation of a moment relative to the given time." - {:arglists '([driver dt amount unit])} + This method will be removed at some point in the future." + {:arglists '([driver hsql-form amount unit])} dispatch-on-initialized-driver :hierarchy #'hierarchy) diff --git a/src/metabase/driver/h2.clj b/src/metabase/driver/h2.clj index b08da9b2fb1aec291c07ddf40683907e1b8c0ea9..025e0fdfa6fe1947e5741264c10930ff8960a2e6 100644 --- a/src/metabase/driver/h2.clj +++ b/src/metabase/driver/h2.clj @@ -70,10 +70,10 @@ (defmethod driver/process-query-in-context :h2 [_ qp] (comp qp check-native-query-not-using-default-user)) -(defmethod driver/date-add :h2 [driver dt amount unit] +(defmethod driver/date-add :h2 [driver hsql-form amount unit] (if (= unit :quarter) - (recur driver dt (hx/* amount 3) :month) - (hsql/call :dateadd (hx/literal unit) amount dt))) + (recur driver hsql-form (hx/* amount 3) :month) + (hsql/call :dateadd (hx/literal unit) amount hsql-form))) (defmethod driver/humanize-connection-error-message :h2 [_ message] (condp re-matches message diff --git a/src/metabase/driver/mysql.clj b/src/metabase/driver/mysql.clj index 97f61179bf3703dfddcc76ec67b0c3553fabd243..2eb301fdfe2eeab9f90d1c7a61aa6467c1ce27f8 100644 --- a/src/metabase/driver/mysql.clj +++ b/src/metabase/driver/mysql.clj @@ -72,8 +72,8 @@ :placeholder "tinyInt1isBit=false")])) (defmethod driver/date-add :mysql - [_ dt amount unit] - (hsql/call :date_add dt (hsql/raw (format "INTERVAL %d %s" (int amount) (name unit))))) + [_ hsql-form amount unit] + (hsql/call :date_add hsql-form (hsql/raw (format "INTERVAL %d %s" (int amount) (name unit))))) (defmethod driver/humanize-connection-error-message :mysql [_ message] diff --git a/src/metabase/driver/postgres.clj b/src/metabase/driver/postgres.clj index 0727f7b5416ad333004d80166602c93a6c553876..482d090fc356b3cdff7180fd1fd422e0d1c066cb 100644 --- a/src/metabase/driver/postgres.clj +++ b/src/metabase/driver/postgres.clj @@ -41,8 +41,8 @@ (defmethod driver/display-name :postgres [_] "PostgreSQL") (defmethod driver/date-add :postgres - [_ dt amount unit] - (hx/+ (hx/->timestamp dt) + [_ hsql-form amount unit] + (hx/+ (hx/->timestamp hsql-form) (hsql/raw (format "(INTERVAL '%d %s')" (int amount) (name unit))))) (defmethod driver/humanize-connection-error-message :postgres diff --git a/src/metabase/driver/sql/query_processor.clj b/src/metabase/driver/sql/query_processor.clj index 850e80101920fda2339f998b460cad50876cbe70..8ba05cc98b6b602c6e15b734032bae385f432773 100644 --- a/src/metabase/driver/sql/query_processor.clj +++ b/src/metabase/driver/sql/query_processor.clj @@ -60,14 +60,28 @@ ;;; | Interface (Multimethods) | ;;; +----------------------------------------------------------------------------------------------------------------+ -(defmulti current-datetime-fn - "HoneySQL form that should be used to get the current `datetime` (or equivalent). Defaults to `:%now`." +(defmulti ^{:deprecated "0.34.2"} current-datetime-fn + "HoneySQL form that should be used to get the current `datetime` (or equivalent). Defaults to `:%now`. + + DEPRECATED: `current-datetime-fn` is a misnomer, since the result can actually be any valid HoneySQL form. + `current-datetime-honeysql-form` replaces this method; implement and call that method instead. This method will be + removed in favor of `current-datetime-honeysql-form` at some point in the future." {:arglists '([driver])} driver/dispatch-on-initialized-driver :hierarchy #'driver/hierarchy) (defmethod current-datetime-fn :sql [_] :%now) +(defmulti current-datetime-honeysql-form + "HoneySQL form that should be used to get the current `datetime` (or equivalent). Defaults to `:%now`." + {:arglists '([driver])} + driver/dispatch-on-initialized-driver + :hierarchy #'driver/hierarchy) + +(defmethod current-datetime-honeysql-form :sql + [driver] + (current-datetime-fn driver)) + ;; TODO - rename this to `date-bucket` or something that better describes what it actually does (defmulti date "Return a HoneySQL form for truncating a date or timestamp field or value to a given resolution, or extracting a date @@ -80,6 +94,17 @@ (defmethod date [:sql :default] [_ _ expr] expr) +(defmulti add-interval-honeysql-form + "Return a HoneySQL form that performs represents addition of some temporal interval to the original `hsql-form`. + + (add-interval-honeysql-form :my-driver hsql-form 1 :day) -> (hsql/call :date_add hsql-form 1 (hx/literal 'day'))" + {:arglists '([driver hsql-form amount unit])} + driver/dispatch-on-initialized-driver + :hierarchy #'driver/hierarchy) + +(defmethod add-interval-honeysql-form :sql [driver hsql-form amount unit] + (driver/date-add driver hsql-form amount unit)) + (defmulti field->identifier "Return a HoneySQL form that should be used as the identifier for `field`, an instance of the Field model. The default implementation returns a keyword generated by from the components returned by `field/qualified-name-components`. @@ -266,8 +291,8 @@ (defmethod ->honeysql [:sql :+] [driver [_ & args]] (if (mbql.u/datetime-arithmetics? args) (let [[field & intervals] args] - (reduce (fn [result [_ amount unit]] - (driver/date-add driver result amount unit)) + (reduce (fn [hsql-form [_ amount unit]] + (add-interval-honeysql-form driver hsql-form amount unit)) (->honeysql driver field) intervals)) (apply hsql/call :+ (map (partial ->honeysql driver) args)))) @@ -356,8 +381,8 @@ (defmethod ->honeysql [:sql :relative-datetime] [driver [_ amount unit]] (date driver unit (if (zero? amount) - (current-datetime-fn driver) - (driver/date-add driver (current-datetime-fn driver) amount unit)))) + (current-datetime-honeysql-form driver) + (add-interval-honeysql-form driver (current-datetime-honeysql-form driver) amount unit)))) ;;; +----------------------------------------------------------------------------------------------------------------+ diff --git a/src/metabase/middleware/exceptions.clj b/src/metabase/middleware/exceptions.clj index 1f8f8411f6a8b370b102f4b18cdf60ba17a22eb8..06be9b9031558760681501085b691b3afdb030b6 100644 --- a/src/metabase/middleware/exceptions.clj +++ b/src/metabase/middleware/exceptions.clj @@ -41,44 +41,37 @@ class) (defmethod api-exception-response Throwable [^Throwable e] - (let [{:keys [status-code], :as info} - (ex-data e) + (let [{:keys [status-code] :as info} (ex-data e) - other-info - (dissoc info :status-code :schema :type) + other-info (dissoc info :status-code :schema :type) + message (.getMessage e) + body (cond + ;; Exceptions that include a status code *and* other info are things like + ;; Field validation exceptions. Return those as is + (and status-code (seq other-info)) + (ui18n/localized-strings->strings other-info) - message - (.getMessage e) + ;; If status code was specified but other data wasn't, it's something like a + ;; 404. Return message as the (plain-text) body. + status-code + (str message) - body - (cond - ;; Exceptions that include a status code *and* other info are things like - ;; Field validation exceptions. Return those as is - (and status-code - (seq other-info)) - (ui18n/localized-strings->strings other-info) + ;; Otherwise it's a 500. Return a body that includes exception & filtered + ;; stacktrace for debugging purposes + :else + (assoc other-info + :message message + :type (class e) + :stacktrace (u/filtered-stacktrace e)))] - ;; If status code was specified but other data wasn't, it's something like a - ;; 404. Return message as the (plain-text) body. - status-code - (str message) - - ;; Otherwise it's a 500. Return a body that includes exception & filtered - ;; stacktrace for debugging purposes - :else - (assoc other-info - :message message - :type (class e) - :stacktrace (u/filtered-stacktrace e)))] {:status (or status-code 500) :headers (mw.security/security-headers) :body body})) (defmethod api-exception-response SQLException [e] - (-> - ((get-method api-exception-response (.getSuperclass SQLException)) e) - (assoc-in [:body :sql-exception-chain] (str/split (with-out-str (jdbc/print-sql-exception-chain e)) - #"\s*\n\s*")))) + (-> ((get-method api-exception-response (.getSuperclass SQLException)) e) + (assoc-in [:body :sql-exception-chain] (str/split (with-out-str (jdbc/print-sql-exception-chain e)) + #"\s*\n\s*")))) (defmethod api-exception-response EofException [e] (log/info (trs "Request canceled before finishing.")) diff --git a/src/metabase/util/date_2.clj b/src/metabase/util/date_2.clj index 9eca9c6e161737f53481973a7bebac61756f9d94..66122314cad664bb20b7dd5fc841787a652be0fa 100644 --- a/src/metabase/util/date_2.clj +++ b/src/metabase/util/date_2.clj @@ -115,6 +115,8 @@ :iso-week-of-year :month-of-year :quarter-of-year + ;; TODO - in this namespace `:year` is something you can both extract and truncate to. In MBQL `:year` is a truncation + ;; operation. Maybe we should rename this unit to clear up the potential confusion (?) :year}) (def ^:private week-fields* diff --git a/test/metabase/api/card_test.clj b/test/metabase/api/card_test.clj index 2a82679af786deff25ca88743daf2009c3b2a944..b021f3c1e71b30e4aff9520ae20e1999c4bb83b5 100644 --- a/test/metabase/api/card_test.clj +++ b/test/metabase/api/card_test.clj @@ -4,7 +4,6 @@ [clojure.data.csv :as csv] [clojure.test :refer :all] [dk.ative.docjure.spreadsheet :as spreadsheet] - [expectations :refer [expect]] [medley.core :as m] [metabase [email-test :as et] @@ -151,114 +150,115 @@ (u/get-id card-or-id))) ;; Filter cards by database -(expect - {1 true - 2 false - 3 true} - (tt/with-temp* [Database [db] - Card [card-1 {:database_id (data/id)}] - Card [card-2 {:database_id (u/get-id db)}]] - (with-cards-in-readable-collection [card-1 card-2] - (array-map - 1 (card-returned? :database (data/id) card-1) - 2 (card-returned? :database db card-1) - 3 (card-returned? :database db card-2))))) +(deftest filter-cards-by-db + (is (= {1 true + 2 false + 3 true} + (tt/with-temp* [Database [db] + Card [card-1 {:database_id (data/id)}] + Card [card-2 {:database_id (u/get-id db)}]] + (with-cards-in-readable-collection [card-1 card-2] + (array-map + 1 (card-returned? :database (data/id) card-1) + 2 (card-returned? :database db card-1) + 3 (card-returned? :database db card-2))))))) -(expect (get middleware.u/response-unauthentic :body) (http/client :get 401 "card")) -(expect (get middleware.u/response-unauthentic :body) (http/client :put 401 "card/13")) +(deftest card-authentication + (is (= (get middleware.u/response-unauthentic :body) (http/client :get 401 "card"))) + (is (= (get middleware.u/response-unauthentic :body) (http/client :put 401 "card/13")))) -;; Make sure `model_id` is required when `f` is :database -(expect {:errors {:model_id "model_id is a required parameter when filter mode is 'database'"}} - ((test-users/user->client :crowberto) :get 400 "card" :f :database)) +(deftest model_id-requied-when-f-is-database + (is (= {:errors {:model_id "model_id is a required parameter when filter mode is 'database'"}} + ((test-users/user->client :crowberto) :get 400 "card" :f :database)))) ;; Filter cards by table -(expect - {1 true - 2 false - 3 true} - (tt/with-temp* [Database [db] - Table [table-1 {:db_id (u/get-id db)}] - Table [table-2 {:db_id (u/get-id db)}] - Card [card-1 {:table_id (u/get-id table-1)}] - Card [card-2 {:table_id (u/get-id table-2)}]] - (with-cards-in-readable-collection [card-1 card-2] - (array-map - 1 (card-returned? :table (u/get-id table-1) (u/get-id card-1)) - 2 (card-returned? :table (u/get-id table-2) (u/get-id card-1)) - 3 (card-returned? :table (u/get-id table-2) (u/get-id card-2)))))) +(deftest filter-cards-by-table + (is (= {1 true + 2 false + 3 true} + (tt/with-temp* [Database [db] + Table [table-1 {:db_id (u/get-id db)}] + Table [table-2 {:db_id (u/get-id db)}] + Card [card-1 {:table_id (u/get-id table-1)}] + Card [card-2 {:table_id (u/get-id table-2)}]] + (with-cards-in-readable-collection [card-1 card-2] + (array-map + 1 (card-returned? :table (u/get-id table-1) (u/get-id card-1)) + 2 (card-returned? :table (u/get-id table-2) (u/get-id card-1)) + 3 (card-returned? :table (u/get-id table-2) (u/get-id card-2)))))))) ;; Make sure `model_id` is required when `f` is :table -(expect - {:errors {:model_id "model_id is a required parameter when filter mode is 'table'"}} - ((test-users/user->client :crowberto) :get 400 "card", :f :table)) +(deftest model_id-requied-when-f-is-table + (is (= {:errors {:model_id "model_id is a required parameter when filter mode is 'table'"}} + ((test-users/user->client :crowberto) :get 400 "card", :f :table)))) ;;; Filter by `recent` ;; Should return cards that were recently viewed by current user only -(expect - ["Card 3" - "Card 4" - "Card 1"] - (tt/with-temp* [Card [card-1 {:name "Card 1"}] - Card [card-2 {:name "Card 2"}] - Card [card-3 {:name "Card 3"}] - Card [card-4 {:name "Card 4"}] - ;; 3 was viewed most recently, followed by 4, then 1. Card 2 was viewed by a different user so - ;; shouldn't be returned - ViewLog [_ {:model "card", :model_id (u/get-id card-1), :user_id (test-users/user->id :rasta) - :timestamp #t "2015-12-01"}] - ViewLog [_ {:model "card", :model_id (u/get-id card-2), :user_id (test-users/user->id :trashbird) - :timestamp #t "2016-01-01"}] - ViewLog [_ {:model "card", :model_id (u/get-id card-3), :user_id (test-users/user->id :rasta) - :timestamp #t "2016-02-01"}] - ViewLog [_ {:model "card", :model_id (u/get-id card-4), :user_id (test-users/user->id :rasta) - :timestamp #t "2016-03-01"}] - ViewLog [_ {:model "card", :model_id (u/get-id card-3), :user_id (test-users/user->id :rasta) - :timestamp #t "2016-04-01"}]] - (with-cards-in-readable-collection [card-1 card-2 card-3 card-4] - (map :name ((test-users/user->client :rasta) :get 200 "card", :f :recent))))) +(deftest filter-by-recent + (is (= ["Card 3" + "Card 4" + "Card 1"] + (tt/with-temp* [Card [card-1 {:name "Card 1"}] + Card [card-2 {:name "Card 2"}] + Card [card-3 {:name "Card 3"}] + Card [card-4 {:name "Card 4"}] + ;; 3 was viewed most recently, followed by 4, then 1. Card 2 was viewed by a different user so + ;; shouldn't be returned + ViewLog [_ {:model "card", :model_id (u/get-id card-1), :user_id (test-users/user->id :rasta) + :timestamp #t "2015-12-01"}] + ViewLog [_ {:model "card", :model_id (u/get-id card-2), :user_id (test-users/user->id :trashbird) + :timestamp #t "2016-01-01"}] + ViewLog [_ {:model "card", :model_id (u/get-id card-3), :user_id (test-users/user->id :rasta) + :timestamp #t "2016-02-01"}] + ViewLog [_ {:model "card", :model_id (u/get-id card-4), :user_id (test-users/user->id :rasta) + :timestamp #t "2016-03-01"}] + ViewLog [_ {:model "card", :model_id (u/get-id card-3), :user_id (test-users/user->id :rasta) + :timestamp #t "2016-04-01"}]] + (with-cards-in-readable-collection [card-1 card-2 card-3 card-4] + (map :name ((test-users/user->client :rasta) :get 200 "card", :f :recent))))))) ;;; Filter by `popular` ;; `f=popular` should return cards sorted by number of ViewLog entries for all users; cards with no entries should be ;; excluded -(expect - ["Card 3" - "Card 2"] - (tt/with-temp* [Card [card-1 {:name "Card 1"}] - Card [card-2 {:name "Card 2"}] - Card [card-3 {:name "Card 3"}] - ;; 3 entries for card 3, 2 for card 2, none for card 1, - ViewLog [_ {:model "card", :model_id (u/get-id card-3), :user_id (test-users/user->id :rasta)}] - ViewLog [_ {:model "card", :model_id (u/get-id card-2), :user_id (test-users/user->id :trashbird)}] - ViewLog [_ {:model "card", :model_id (u/get-id card-2), :user_id (test-users/user->id :rasta)}] - ViewLog [_ {:model "card", :model_id (u/get-id card-3), :user_id (test-users/user->id :crowberto)}] - ViewLog [_ {:model "card", :model_id (u/get-id card-3), :user_id (test-users/user->id :rasta)}]] - (with-cards-in-readable-collection [card-1 card-2 card-3] - (map :name ((test-users/user->client :rasta) :get 200 "card", :f :popular))))) +(deftest filter-by-popular + (is (= ["Card 3" + "Card 2"] + (tt/with-temp* [Card [card-1 {:name "Card 1"}] + Card [card-2 {:name "Card 2"}] + Card [card-3 {:name "Card 3"}] + ;; 3 entries for card 3, 2 for card 2, none for card 1, + ViewLog [_ {:model "card", :model_id (u/get-id card-3), :user_id (test-users/user->id :rasta)}] + ViewLog [_ {:model "card", :model_id (u/get-id card-2), :user_id (test-users/user->id :trashbird)}] + ViewLog [_ {:model "card", :model_id (u/get-id card-2), :user_id (test-users/user->id :rasta)}] + ViewLog [_ {:model "card", :model_id (u/get-id card-3), :user_id (test-users/user->id :crowberto)}] + ViewLog [_ {:model "card", :model_id (u/get-id card-3), :user_id (test-users/user->id :rasta)}]] + (with-cards-in-readable-collection [card-1 card-2 card-3] + (map :name ((test-users/user->client :rasta) :get 200 "card", :f :popular))))))) ;;; Filter by `archived` ;; check that the set of Card IDs returned with f=archived is equal to the set of archived cards -(expect - #{"Card 2" "Card 3"} - (tt/with-temp* [Card [card-1 {:name "Card 1"}] - Card [card-2 {:name "Card 2", :archived true}] - Card [card-3 {:name "Card 3", :archived true}]] - (with-cards-in-readable-collection [card-1 card-2 card-3] - (set (map :name ((test-users/user->client :rasta) :get 200 "card", :f :archived)))))) +(deftest filter-by-archived + (is (= #{"Card 2" "Card 3"} + (tt/with-temp* [Card [card-1 {:name "Card 1"}] + Card [card-2 {:name "Card 2", :archived true}] + Card [card-3 {:name "Card 3", :archived true}]] + (with-cards-in-readable-collection [card-1 card-2 card-3] + (set (map :name ((test-users/user->client :rasta) :get 200 "card", :f :archived)))))))) ;;; Filter by `fav` -(expect - [{:name "Card 1", :favorite true}] - (tt/with-temp* [Card [card-1 {:name "Card 1"}] - Card [card-2 {:name "Card 2"}] - Card [card-3 {:name "Card 3"}] - CardFavorite [_ {:card_id (u/get-id card-1), :owner_id (test-users/user->id :rasta)}] - CardFavorite [_ {:card_id (u/get-id card-2), :owner_id (test-users/user->id :crowberto)}]] - (with-cards-in-readable-collection [card-1 card-2 card-3] - (for [card ((test-users/user->client :rasta) :get 200 "card", :f :fav)] - (select-keys card [:name :favorite]))))) +(deftest filter-by-fav + (is (= [{:name "Card 1", :favorite true}] + (tt/with-temp* [Card [card-1 {:name "Card 1"}] + Card [card-2 {:name "Card 2"}] + Card [card-3 {:name "Card 3"}] + CardFavorite [_ {:card_id (u/get-id card-1), :owner_id (test-users/user->id :rasta)}] + CardFavorite [_ {:card_id (u/get-id card-2), :owner_id (test-users/user->id :crowberto)}]] + (with-cards-in-readable-collection [card-1 card-2 card-3] + (for [card ((test-users/user->client :rasta) :get 200 "card", :f :fav)] + (select-keys card [:name :favorite]))))))) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -266,78 +266,78 @@ ;;; +----------------------------------------------------------------------------------------------------------------+ ;; Test that we can make a card -(let [card-name (tu/random-name)] - (expect - (merge - card-defaults - {:name card-name - :collection_id true - :collection true - :creator_id (test-users/user->id :rasta) - :dataset_query true - :query_type "query" - :visualization_settings {:global {:title nil}} - :database_id true - :table_id true - :can_write true - :dashboard_count 0 - :read_permissions nil - :result_metadata true - :creator (merge - (select-keys (test-users/fetch-user :rasta) [:id :date_joined :last_login]) - {:common_name "Rasta Toucan" - :is_superuser false - :is_qbnewb true - :last_name "Toucan" - :first_name "Rasta" - :email "rasta@metabase.com"})}) - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp* [Collection [collection]] - (tu/with-model-cleanup [Card] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - (-> ((test-users/user->client :rasta) :post 200 "card" - (assoc (card-with-name-and-query card-name (mbql-count-query (data/id) (data/id :venues))) - :collection_id (u/get-id collection))) - (dissoc :created_at :updated_at :id) - (update :table_id integer?) - (update :database_id integer?) - (update :collection_id integer?) - (update :dataset_query map?) - (update :collection map?) - (update :result_metadata (partial every? map?)))))))) +(deftest create-a-card + (let [card-name (tu/random-name)] + (is (= (merge + card-defaults + {:name card-name + :collection_id true + :collection true + :creator_id (test-users/user->id :rasta) + :dataset_query true + :query_type "query" + :visualization_settings {:global {:title nil}} + :database_id true + :table_id true + :can_write true + :dashboard_count 0 + :read_permissions nil + :result_metadata true + :creator (merge + (select-keys (test-users/fetch-user :rasta) [:id :date_joined :last_login]) + {:common_name "Rasta Toucan" + :is_superuser false + :is_qbnewb true + :last_name "Toucan" + :first_name "Rasta" + :email "rasta@metabase.com"})}) + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp* [Collection [collection]] + (tu/with-model-cleanup [Card] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + (-> ((test-users/user->client :rasta) :post 202 "card" + (assoc (card-with-name-and-query card-name (mbql-count-query (data/id) (data/id :venues))) + :collection_id (u/get-id collection))) + (dissoc :created_at :updated_at :id) + (update :table_id integer?) + (update :database_id integer?) + (update :collection_id integer?) + (update :dataset_query map?) + (update :collection map?) + (update :result_metadata (partial every? map?)))))))))) ;; 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"}] - (tu/with-non-admin-groups-no-root-collection-perms - (let [metadata [{:base_type :type/Integer - :display_name "Count Chocula" - :name "count_chocula" - :special_type :type/Number}] - card-name (tu/random-name)] - (tt/with-temp Collection [collection] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - (tu/with-model-cleanup [Card] - ;; create a card with the metadata - ((test-users/user->client :rasta) :post 200 "card" - (assoc (card-with-name-and-query card-name) - :collection_id (u/get-id collection) - :result_metadata metadata - :metadata_checksum (#'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)))))) +(deftest saving-card-saves-query-metadata + (is (= [{:base_type "type/Integer" + :display_name "Count Chocula" + :name "count_chocula" + :special_type "type/Number"}] + (tu/with-non-admin-groups-no-root-collection-perms + (let [metadata [{:base_type :type/Integer + :display_name "Count Chocula" + :name "count_chocula" + :special_type :type/Number}] + card-name (tu/random-name)] + (tt/with-temp Collection [collection] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + (tu/with-model-cleanup [Card] + ;; create a card with the metadata + ((test-users/user->client :rasta) :post 202 "card" + (assoc (card-with-name-and-query card-name) + :collection_id (u/get-id collection) + :result_metadata metadata + :metadata_checksum (#'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)))))))) ;; we should be able to save a Card if the `result_metadata` is *empty* (but not nil) (#9286) -(expect - (tu/with-model-cleanup [Card] - ;; create a card with the metadata - ((test-users/user->client :rasta) :post 200 "card" - (assoc (card-with-name-and-query) - :result_metadata [] - :metadata_checksum (#'results-metadata/metadata-checksum []))))) +(deftest save-card-with-empty-result-metadata + (is (tu/with-model-cleanup [Card] + ;; create a card with the metadata + ((test-users/user->client :rasta) :post 202 "card" + (assoc (card-with-name-and-query) + :result_metadata [] + :metadata_checksum (#'results-metadata/metadata-checksum [])))))) (defn- fingerprint-integers->doubles @@ -350,124 +350,128 @@ ;; When integer values are passed to the FE, they will be returned as floating point values. Our hashing should ensure ;; that integer and floating point values hash the same so we don't needlessly rerun the query -(expect - [{:base_type "type/Integer" - :display_name "Count Chocula" - :name "count_chocula" - :special_type "type/Number" - :fingerprint {:global {:distinct-count 285}, - :type {:type/Number {:min 5.0, :max 2384.0, :avg 1000.2}}}}] - (tu/with-non-admin-groups-no-root-collection-perms - (let [metadata [{:base_type :type/Integer - :display_name "Count Chocula" - :name "count_chocula" - :special_type :type/Number - :fingerprint {:global {:distinct-count 285}, - :type {:type/Number {:min 5, :max 2384, :avg 1000.2}}}}] - card-name (tu/random-name)] - (tt/with-temp Collection [collection] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - (tu/throw-if-called qp.async/result-metadata-for-query-async - (tu/with-model-cleanup [Card] - ;; create a card with the metadata - ((test-users/user->client :rasta) :post 200 "card" - (assoc (card-with-name-and-query card-name) - :collection_id (u/get-id collection) - :result_metadata (map fingerprint-integers->doubles metadata) - :metadata_checksum (#'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))))))) +(deftest ints-returned-as-floating-point + (is (= [{:base_type "type/Integer" + :display_name "Count Chocula" + :name "count_chocula" + :special_type "type/Number" + :fingerprint {:global {:distinct-count 285}, + :type {:type/Number {:min 5.0, :max 2384.0, :avg 1000.2}}}}] + (tu/with-non-admin-groups-no-root-collection-perms + (let [metadata [{:base_type :type/Integer + :display_name "Count Chocula" + :name "count_chocula" + :special_type :type/Number + :fingerprint {:global {:distinct-count 285}, + :type {:type/Number {:min 5, :max 2384, :avg 1000.2}}}}] + card-name (tu/random-name)] + (tt/with-temp Collection [collection] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + (tu/throw-if-called qp.async/result-metadata-for-query-async + (tu/with-model-cleanup [Card] + ;; create a card with the metadata + ((test-users/user->client :rasta) :post 202 "card" + (assoc (card-with-name-and-query card-name) + :collection_id (u/get-id collection) + :result_metadata (map fingerprint-integers->doubles metadata) + :metadata_checksum (#'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/Quantity" - :fingerprint {:global {:distinct-count 1 - :nil% 0.0}, - :type {:type/Number {:min 100.0, :max 100.0, :avg 100.0, :q1 100.0, :q3 100.0 :sd nil}}}}] - (tu/with-non-admin-groups-no-root-collection-perms - (let [metadata [{:base_type :type/Integer - :display_name "Count Chocula" - :name "count_chocula" - :special_type :type/Quantity}] - card-name (tu/random-name)] - (tt/with-temp Collection [collection] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - (tu/with-model-cleanup [Card] - ;; create a card with the metadata - ((test-users/user->client :rasta) :post 200 "card" - (assoc (card-with-name-and-query card-name) - :collection_id (u/get-id collection) - :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)))))) +(deftest saving-card-fetches-correct-metadata + (is (= [{:base_type "type/Integer" + :display_name "Count" + :name "count" + :special_type "type/Quantity" + :fingerprint {:global {:distinct-count 1 + :nil% 0.0}, + :type {:type/Number {:min 100.0, :max 100.0, :avg 100.0, :q1 100.0, :q3 100.0 :sd nil}}}}] + (tu/with-non-admin-groups-no-root-collection-perms + (let [metadata [{:base_type :type/Integer + :display_name "Count Chocula" + :name "count_chocula" + :special_type :type/Quantity}] + card-name (tu/random-name)] + (tt/with-temp Collection [collection] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + (tu/with-model-cleanup [Card] + ;; create a card with the metadata + ((test-users/user->client :rasta) :post 202 "card" + (assoc (card-with-name-and-query card-name) + :collection_id (u/get-id collection) + :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)))))))) ;; Check that the generated query to fetch the query result metadata includes user information in the generated query -(expect - {:metadata-results [{:base_type "type/Integer" - :display_name "Count" - :name "count" - :special_type "type/Quantity" - :fingerprint {:global {:distinct-count 1 - :nil% 0.0}, - :type {:type/Number {:min 100.0, :max 100.0, :avg 100.0, :q1 100.0, :q3 100.0 :sd nil}}}}] - :has-user-id-remark? true} - (tu/with-non-admin-groups-no-root-collection-perms - (let [metadata [{:base_type :type/Integer - :display_name "Count Chocula" - :name "count_chocula" - :special_type :type/Quantity}] - card-name (tu/random-name)] - (tt/with-temp Collection [collection] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - (tu/with-model-cleanup [Card] - ;; Rebind the `cancelable-run-query` function so that we can capture the generated SQL and inspect it - (let [orig-fn (var-get #'sql-jdbc.execute/cancelable-run-query) - sql-result (atom [])] - (with-redefs [sql-jdbc.execute/cancelable-run-query (fn [db sql params opts] - (swap! sql-result conj sql) - (orig-fn db sql params opts))] - ;; create a card with the metadata - ((test-users/user->client :rasta) :post 200 "card" - (assoc (card-with-name-and-query card-name) - :collection_id (u/get-id collection) - :result_metadata metadata - :metadata_checksum "ABCDEF"))) ; bad checksum - ;; now check the correct metadata was fetched and was saved in the DB - {:metadata-results (db/select-one-field :result_metadata Card :name card-name) - ;; Was the user id found in the generated SQL? - :has-user-id-remark? (-> (str "userID: " (test-users/user->id :rasta)) - re-pattern - (re-find (first @sql-result)) - boolean)})))))) +(deftest generated-query-includes-user-info + (is (= {:metadata-results [{:base_type "type/Integer" + :display_name "Count" + :name "count" + :special_type "type/Quantity" + :fingerprint {:global {:distinct-count 1 + :nil% 0.0}, + :type {:type/Number {:min 100.0 + :max 100.0 + :avg 100.0 + :q1 100.0 + :q3 100.0 + :sd nil}}}}] + :has-user-id-remark? true} + (tu/with-non-admin-groups-no-root-collection-perms + (let [metadata [{:base_type :type/Integer + :display_name "Count Chocula" + :name "count_chocula" + :special_type :type/Quantity}] + card-name (tu/random-name)] + (tt/with-temp Collection [collection] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + (tu/with-model-cleanup [Card] + ;; Rebind the `cancelable-run-query` function so that we can capture the generated SQL and inspect it + (let [orig-fn (var-get #'sql-jdbc.execute/cancelable-run-query) + sql-result (atom [])] + (with-redefs [sql-jdbc.execute/cancelable-run-query (fn [db sql params opts] + (swap! sql-result conj sql) + (orig-fn db sql params opts))] + ;; create a card with the metadata + ((test-users/user->client :rasta) :post 202 "card" + (assoc (card-with-name-and-query card-name) + :collection_id (u/get-id collection) + :result_metadata metadata + :metadata_checksum "ABCDEF"))) ; bad checksum + ;; now check the correct metadata was fetched and was saved in the DB + {:metadata-results (db/select-one-field :result_metadata Card :name card-name) + ;; Was the user id found in the generated SQL? + :has-user-id-remark? (-> (str "userID: " (test-users/user->id :rasta)) + re-pattern + (re-find (first @sql-result)) + boolean)})))))))) ;; Make sure we can create a Card with a Collection position -(expect - #metabase.models.card.CardInstance{:collection_id true, :collection_position 1} - (tu/with-non-admin-groups-no-root-collection-perms - (tu/with-model-cleanup [Card] - (let [card-name (tu/random-name)] - (tt/with-temp Collection [collection] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - ((test-users/user->client :rasta) :post 200 "card" (assoc (card-with-name-and-query card-name) - :collection_id (u/get-id collection), :collection_position 1)) - (some-> (db/select-one [Card :collection_id :collection_position] :name card-name) - (update :collection_id (partial = (u/get-id collection))))))))) +(deftest create-card-with-collection-position + (is (= #metabase.models.card.CardInstance{:collection_id true, :collection_position 1} + (tu/with-non-admin-groups-no-root-collection-perms + (tu/with-model-cleanup [Card] + (let [card-name (tu/random-name)] + (tt/with-temp Collection [collection] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + ((test-users/user->client :rasta) :post 202 "card" (assoc (card-with-name-and-query card-name) + :collection_id (u/get-id collection), :collection_position 1)) + (some-> (db/select-one [Card :collection_id :collection_position] :name card-name) + (update :collection_id (partial = (u/get-id collection))))))))))) ;; ...but not if we don't have permissions for the Collection -(expect - nil - (tu/with-non-admin-groups-no-root-collection-perms - (tu/with-model-cleanup [Card] - (let [card-name (tu/random-name)] - (tt/with-temp Collection [collection] - ((test-users/user->client :rasta) :post 403 "card" (assoc (card-with-name-and-query card-name) - :collection_id (u/get-id collection), :collection_position 1)) - (some-> (db/select-one [Card :collection_id :collection_position] :name card-name) - (update :collection_id (partial = (u/get-id collection))))))))) +(deftest need-permission-for-collection + (is (nil? (tu/with-non-admin-groups-no-root-collection-perms + (tu/with-model-cleanup [Card] + (let [card-name (tu/random-name)] + (tt/with-temp Collection [collection] + ((test-users/user->client :rasta) :post 403 "card" (assoc (card-with-name-and-query card-name) + :collection_id (u/get-id collection), :collection_position 1)) + (some-> (db/select-one [Card :collection_id :collection_position] :name card-name) + (update :collection_id (partial = (u/get-id collection))))))))))) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -509,180 +513,169 @@ (perms/grant-collection-read-permissions! (perms-group/all-users) collection) ((test-users/user->client :rasta) :get 200 (str "card/" (u/get-id card))))) -;; Check that a user without permissions isn't allowed to fetch the card -(expect - "You don't have permissions to do that." - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp* [Database [db] - Table [table {:db_id (u/get-id db)}] - Card [card {:dataset_query (mbql-count-query (u/get-id db) (u/get-id table))}]] - ;; revoke permissions for default group to this database - (perms/revoke-permissions! (perms-group/all-users) (u/get-id db)) - ;; now a non-admin user shouldn't be able to fetch this card - ((test-users/user->client :rasta) :get 403 (str "card/" (u/get-id card)))))) +(deftest check-that-a-user-without-permissions-isn-t-allowed-to-fetch-the-card + (is (= "You don't have permissions to do that." + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp* [Database [db] + Table [table {:db_id (u/get-id db)}] + Card [card {:dataset_query (mbql-count-query (u/get-id db) (u/get-id table))}]] + ;; revoke permissions for default group to this database + (perms/revoke-permissions! (perms-group/all-users) (u/get-id db)) + ;; now a non-admin user shouldn't be able to fetch this card + ((test-users/user->client :rasta) :get 403 (str "card/" (u/get-id card)))))))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | UPDATING A CARD | ;;; +----------------------------------------------------------------------------------------------------------------+ -;; updating a card that doesn't exist should give a 404 -(expect "Not found." - ((test-users/user->client :crowberto) :put 404 "card/12345")) - -;; Test that we can edit a Card -(expect - {1 "Original Name" - 2 "Updated Name"} - (tt/with-temp Card [card {:name "Original Name"}] - (with-cards-in-writeable-collection card - (array-map - 1 (db/select-one-field :name Card, :id (u/get-id card)) - 2 (do ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:name "Updated Name"}) - (db/select-one-field :name Card, :id (u/get-id card))))))) - -;; Can we update a Card's archived status? -(expect - {1 false - 2 true - 3 false} - (tt/with-temp Card [card] - (with-cards-in-writeable-collection card - (let [archived? (fn [] (:archived (Card (u/get-id card)))) - set-archived! (fn [archived] - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:archived archived}) - (archived?))] - (array-map - 1 (archived?) - 2 (set-archived! true) - 3 (set-archived! false)))))) - -;; we shouldn't be able to update archived status if we don't have collection *write* perms -(expect - "You don't have permissions to do that." - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp* [Collection [collection] - Card [card {:collection_id (u/get-id collection)}]] - (perms/grant-collection-read-permissions! (perms-group/all-users) collection) - ((test-users/user->client :rasta) :put 403 (str "card/" (u/get-id card)) {:archived true})))) + +(deftest updating-a-card-that-doesn-t-exist-should-give-a-404 + (is (= "Not found." + ((test-users/user->client :crowberto) :put 404 "card/12345")))) + + +(deftest test-that-we-can-edit-a-card + (is (= {1 "Original Name" + 2 "Updated Name"} + (tt/with-temp Card [card {:name "Original Name"}] + (with-cards-in-writeable-collection card + (array-map + 1 (db/select-one-field :name Card, :id (u/get-id card)) + 2 (do ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id card)) {:name "Updated Name"}) + (db/select-one-field :name Card, :id (u/get-id card))))))))) + + +(deftest can-we-update-a-card-s-archived-status- + (is (= {1 false + 2 true + 3 false} + (tt/with-temp Card [card] + (with-cards-in-writeable-collection card + (let [archived? (fn [] (:archived (Card (u/get-id card)))) + set-archived! (fn [archived] + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id card)) {:archived archived}) + (archived?))] + (array-map + 1 (archived?) + 2 (set-archived! true) + 3 (set-archived! false)))))))) + +(deftest we-shouldn-t-be-able-to-update-archived-status-if-we-don-t-have-collection--write--perms + (is (= "You don't have permissions to do that." + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp* [Collection [collection] + Card [card {:collection_id (u/get-id collection)}]] + (perms/grant-collection-read-permissions! (perms-group/all-users) collection) + ((test-users/user->client :rasta) :put 403 (str "card/" (u/get-id card)) {:archived true})))))) ;; Can we clear the description of a Card? (#4738) -(expect - nil - (tt/with-temp Card [card {:description "What a nice Card"}] - (with-cards-in-writeable-collection card - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:description nil}) - (db/select-one-field :description Card :id (u/get-id card))))) - -;; description should be blankable as well -(expect - "" - (tt/with-temp Card [card {:description "What a nice Card"}] - (with-cards-in-writeable-collection card - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:description ""}) - (db/select-one-field :description Card :id (u/get-id card))))) - -;; Can we update a card's embedding_params? -(expect - {:abc "enabled"} - (tt/with-temp Card [card] - (tu/with-temporary-setting-values [enable-embedding true] - ((test-users/user->client :crowberto) :put 200 (str "card/" (u/get-id card)) {:embedding_params {:abc "enabled"}})) - (db/select-one-field :embedding_params Card :id (u/get-id card)))) - -;; We shouldn't be able to update them if we're not an admin... -(expect - "You don't have permissions to do that." - (tt/with-temp Card [card] - (tu/with-temporary-setting-values [enable-embedding true] - ((test-users/user->client :rasta) :put 403 (str "card/" (u/get-id card)) {:embedding_params {:abc "enabled"}})))) - -;; ...or if embedding isn't enabled -(expect - "Embedding is not enabled." - (tt/with-temp Card [card] - (tu/with-temporary-setting-values [enable-embedding false] - ((test-users/user->client :crowberto) :put 400 (str "card/" (u/get-id card)) {:embedding_params {:abc "enabled"}})))) - -;; 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] - (with-cards-in-writeable-collection card - ;; update the Card's query - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id card)) - {:dataset_query (mbql-count-query) - :result_metadata metadata - :metadata_checksum (#'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/Quantity" - :fingerprint {:global {:distinct-count 1 - :nil% 0.0}, - :type {:type/Number {:min 100.0, :max 100.0, :avg 100.0, :q1 100.0, :q3 100.0 :sd nil}}}}] - (let [metadata [{:base_type :type/Integer - :display_name "Count Chocula" - :name "count_chocula" - :special_type :type/Quantity}]] - (tt/with-temp Card [card] - (with-cards-in-writeable-collection card - ;; update the Card's query - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id card)) - {:dataset_query (mbql-count-query) - :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)))))) - -;; Can we change the Collection position of a Card? -(expect - 1 - (tt/with-temp Card [card] - (with-cards-in-writeable-collection card - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id card)) - {:collection_position 1}) - (db/select-one-field :collection_position Card :id (u/get-id card))))) - -;; ...and unset (unpin) it as well? -(expect - nil - (tt/with-temp Card [card {:collection_position 1}] - (with-cards-in-writeable-collection card - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id card)) - {:collection_position nil}) - (db/select-one-field :collection_position Card :id (u/get-id card))))) - -;; ...we shouldn't be able to if we don't have permissions for the Collection -(expect - nil - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp* [Collection [collection] - Card [card {:collection_id (u/get-id collection)}]] - ((test-users/user->client :rasta) :put 403 (str "card/" (u/get-id card)) - {:collection_position 1}) - (db/select-one-field :collection_position Card :id (u/get-id card))))) - -(expect - 1 - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp* [Collection [collection] - Card [card {:collection_id (u/get-id collection), :collection_position 1}]] - ((test-users/user->client :rasta) :put 403 (str "card/" (u/get-id card)) - {:collection_position nil}) - (db/select-one-field :collection_position Card :id (u/get-id card))))) +(deftest can-we-clear-the-description-of-a-card----4738- + (is (nil? (tt/with-temp Card [card {:description "What a nice Card"}] + (with-cards-in-writeable-collection card + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id card)) {:description nil}) + (db/select-one-field :description Card :id (u/get-id card))))))) + +(deftest description-should-be-blankable-as-well + (is (= "" + (tt/with-temp Card [card {:description "What a nice Card"}] + (with-cards-in-writeable-collection card + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id card)) {:description ""}) + (db/select-one-field :description Card :id (u/get-id card))))))) + +(deftest can-we-update-a-card-s-embedding-params- + (is (= {:abc "enabled"} + (tt/with-temp Card [card] + (tu/with-temporary-setting-values [enable-embedding true] + ((test-users/user->client :crowberto) :put 202 (str "card/" (u/get-id card)) {:embedding_params {:abc "enabled"}})) + (db/select-one-field :embedding_params Card :id (u/get-id card)))))) + +(deftest we-shouldn-t-be-able-to-update-them-if-we-re-not-an-admin--- + (is (= "You don't have permissions to do that." + (tt/with-temp Card [card] + (tu/with-temporary-setting-values [enable-embedding true] + ((test-users/user->client :rasta) :put 403 (str "card/" (u/get-id card)) {:embedding_params {:abc "enabled"}})))))) + +(deftest ---or-if-embedding-isn-t-enabled + (is (= "Embedding is not enabled." + (tt/with-temp Card [card] + (tu/with-temporary-setting-values [enable-embedding false] + ((test-users/user->client :crowberto) :put 400 (str "card/" (u/get-id card)) {:embedding_params {:abc "enabled"}})))))) + +(deftest make-sure-when-updating-a-card-the-query-metadata-is-saved--if-correct- + (is (= [{: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] + (with-cards-in-writeable-collection card + ;; update the Card's query + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id card)) + {:dataset_query (mbql-count-query) + :result_metadata metadata + :metadata_checksum (#'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)))))))) + +(deftest make-sure-when-updating-a-card-the-correct-query-metadata-is-fetched--if-incorrect- + (is (= [{:base_type "type/Integer" + :display_name "Count" + :name "count" + :special_type "type/Quantity" + :fingerprint {:global {:distinct-count 1 + :nil% 0.0}, + :type {:type/Number {:min 100.0, :max 100.0, :avg 100.0, :q1 100.0, :q3 100.0 :sd nil}}}}] + (let [metadata [{:base_type :type/Integer + :display_name "Count Chocula" + :name "count_chocula" + :special_type :type/Quantity}]] + (tt/with-temp Card [card] + (with-cards-in-writeable-collection card + ;; update the Card's query + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id card)) + {:dataset_query (mbql-count-query) + :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)))))))) + +(deftest can-we-change-the-collection-position-of-a-card- + (is (= 1 + (tt/with-temp Card [card] + (with-cards-in-writeable-collection card + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id card)) + {:collection_position 1}) + (db/select-one-field :collection_position Card :id (u/get-id card))))))) + +(deftest ---and-unset--unpin--it-as-well- + (is (nil? (tt/with-temp Card [card {:collection_position 1}] + (with-cards-in-writeable-collection card + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id card)) + {:collection_position nil}) + (db/select-one-field :collection_position Card :id (u/get-id card))))))) + + + +(deftest ---we-shouldn-t-be-able-to-if-we-don-t-have-permissions-for-the-collection + (is (nil? (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp* [Collection [collection] + Card [card {:collection_id (u/get-id collection)}]] + ((test-users/user->client :rasta) :put 403 (str "card/" (u/get-id card)) + {:collection_position 1}) + (db/select-one-field :collection_position Card :id (u/get-id card))))))) + +(deftest gets-a-card + (is (= 1 + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp* [Collection [collection] + Card [card {:collection_id (u/get-id collection), :collection_position 1}]] + ((test-users/user->client :rasta) :put 403 (str "card/" (u/get-id card)) + {:collection_position nil}) + (db/select-one-field :collection_position Card :id (u/get-id card))))))) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -710,246 +703,243 @@ (partition-all 2 model-and-name-syms))) ~@body)) -;; Check to make sure we can move a card in a collection of just cards -(expect - {"c" 1 - "a" 2 - "b" 3 - "d" 4} - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp Collection [collection] - (with-ordered-items collection [Card a - Card b - Card c - Card d] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id c)) - {:collection_position 1}) - (get-name->collection-position :rasta collection))))) - -;; Change the position of the 4th card to 1st, all other cards should inc their position -(expect - {"d" 1 - "a" 2 - "b" 3 - "c" 4} - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp Collection [collection] - (with-ordered-items collection [Dashboard a - Dashboard b - Pulse c - Card d] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id d)) - {:collection_position 1}) - (get-name->collection-position :rasta collection))))) - -;; Change the position of the 1st card to the 4th, all of the other items dec -(expect - {"b" 1 - "c" 2 - "d" 3 - "a" 4} - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp Collection [collection] - (with-ordered-items collection [Card a - Dashboard b - Pulse c - Dashboard d] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id a)) - {:collection_position 4}) - (get-name->collection-position :rasta collection))))) - -;; Change the position of a card from nil to 2nd, should adjust the existing items -(expect - {"a" 1 - "b" 2 - "c" 3 - "d" 4} - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp* [Collection [{coll-id :id :as collection}] - Card [_ {:name "a", :collection_id coll-id, :collection_position 1}] - ;; Card b does not start with a collection_position - Card [b {:name "b", :collection_id coll-id}] - Dashboard [_ {:name "c", :collection_id coll-id, :collection_position 2}] - Card [_ {:name "d", :collection_id coll-id, :collection_position 3}]] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id b)) - {:collection_position 2}) - (get-name->collection-position :rasta coll-id)))) - -;; Update an existing card to no longer have a position, should dec items after it's position -(expect - {"a" 1 - "b" nil - "c" 2 - "d" 3} - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp Collection [collection] - (with-ordered-items collection [Card a - Card b - Dashboard c - Pulse d] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id b)) - {:collection_position nil}) - (get-name->collection-position :rasta collection))))) +(deftest check-to-make-sure-we-can-move-a-card-in-a-collection-of-just-cards + (is (= {"c" 1 + "a" 2 + "b" 3 + "d" 4} + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp Collection [collection] + (with-ordered-items collection [Card a + Card b + Card c + Card d] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id c)) + {:collection_position 1}) + (get-name->collection-position :rasta collection))))))) + +(deftest change-the-position-of-the-4th-card-to-1st--all-other-cards-should-inc-their-position + (is (= {"d" 1 + "a" 2 + "b" 3 + "c" 4} + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp Collection [collection] + (with-ordered-items collection [Dashboard a + Dashboard b + Pulse c + Card d] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id d)) + {:collection_position 1}) + (get-name->collection-position :rasta collection))))))) + +(deftest change-the-position-of-the-1st-card-to-the-4th--all-of-the-other-items-dec + (is (= {"b" 1 + "c" 2 + "d" 3 + "a" 4} + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp Collection [collection] + (with-ordered-items collection [Card a + Dashboard b + Pulse c + Dashboard d] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id a)) + {:collection_position 4}) + (get-name->collection-position :rasta collection))))))) + +(deftest change-the-position-of-a-card-from-nil-to-2nd--should-adjust-the-existing-items + (is (= {"a" 1 + "b" 2 + "c" 3 + "d" 4} + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp* [Collection [{coll-id :id :as collection}] + Card [_ {:name "a", :collection_id coll-id, :collection_position 1}] + ;; Card b does not start with a collection_position + Card [b {:name "b", :collection_id coll-id}] + Dashboard [_ {:name "c", :collection_id coll-id, :collection_position 2}] + Card [_ {:name "d", :collection_id coll-id, :collection_position 3}]] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id b)) + {:collection_position 2}) + (get-name->collection-position :rasta coll-id)))))) + +(deftest update-an-existing-card-to-no-longer-have-a-position--should-dec-items-after-it-s-position + (is (= {"a" 1 + "b" nil + "c" 2 + "d" 3} + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp Collection [collection] + (with-ordered-items collection [Card a + Card b + Dashboard c + Pulse d] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id b)) + {:collection_position nil}) + (get-name->collection-position :rasta collection))))))) ;; Change the collection the card is in, leave the position, should cause old and new collection to have their ;; positions updated -(expect - [{"a" 1 - "f" 2 - "b" 3 - "c" 4 - "d" 5} - {"e" 1 - "g" 2 - "h" 3}] - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp* [Collection [collection-1] - Collection [collection-2]] - (with-ordered-items collection-1 [Dashboard a - Card b - Pulse c - Dashboard d] - (with-ordered-items collection-2 [Pulse e - Card f - Card g - Dashboard h] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection-1) - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection-2) - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id f)) - {:collection_id (u/get-id collection-1)}) - [(get-name->collection-position :rasta collection-1) - (get-name->collection-position :rasta collection-2)]))))) - -;; Change the collection and the position, causing both collections and the updated card to have their order changed -(expect - [{"h" 1 - "a" 2 - "b" 3 - "c" 4 - "d" 5} - {"e" 1 - "f" 2 - "g" 3}] - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp* [Collection [collection-1] - Collection [collection-2]] - (with-ordered-items collection-1 [Pulse a - Pulse b - Dashboard c - Dashboard d] - (with-ordered-items collection-2 [Dashboard e - Dashboard f - Pulse g - Card h] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection-1) - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection-2) - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id h)) - {:collection_position 1, :collection_id (u/get-id collection-1)}) - [(get-name->collection-position :rasta collection-1) - (get-name->collection-position :rasta collection-2)]))))) +(deftest update-collection-positions + (is (= [{"a" 1 + "f" 2 + "b" 3 + "c" 4 + "d" 5} + {"e" 1 + "g" 2 + "h" 3}] + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp* [Collection [collection-1] + Collection [collection-2]] + (with-ordered-items collection-1 [Dashboard a + Card b + Pulse c + Dashboard d] + (with-ordered-items collection-2 [Pulse e + Card f + Card g + Dashboard h] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection-1) + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection-2) + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id f)) + {:collection_id (u/get-id collection-1)}) + [(get-name->collection-position :rasta collection-1) + (get-name->collection-position :rasta collection-2)]))))))) + +(deftest change-the-collection-and-the-position--causing-both-collections-and-the-updated-card-to-have-their-order-changed + (is (= [{"h" 1 + "a" 2 + "b" 3 + "c" 4 + "d" 5} + {"e" 1 + "f" 2 + "g" 3}] + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp* [Collection [collection-1] + Collection [collection-2]] + (with-ordered-items collection-1 [Pulse a + Pulse b + Dashboard c + Dashboard d] + (with-ordered-items collection-2 [Dashboard e + Dashboard f + Pulse g + Card h] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection-1) + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection-2) + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id h)) + {:collection_position 1, :collection_id (u/get-id collection-1)}) + [(get-name->collection-position :rasta collection-1) + (get-name->collection-position :rasta collection-2)]))))))) + ;; Add a new card to an existing collection at position 1, will cause all existing positions to increment by 1 -(expect - ;; Original collection, before adding the new card - [{"b" 1 - "c" 2 - "d" 3} - ;; Add new card at index 1 - {"a" 1 - "b" 2 - "c" 3 - "d" 4}] - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp Collection [collection] - (tu/with-model-cleanup [Card] - (with-ordered-items collection [Dashboard b - Pulse c - Card d] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - [(get-name->collection-position :rasta collection) - (do - ((test-users/user->client :rasta) :post 200 "card" - (merge (card-with-name-and-query "a") - {:collection_id (u/get-id collection) - :collection_position 1})) - (get-name->collection-position :rasta collection))]))))) - -;; Add a new card to the end of an existing collection -(expect - ;; Original collection, before adding the new card - [{"a" 1 - "b" 2 - "c" 3} - ;; Add new card at index 4 - {"a" 1 - "b" 2 - "c" 3 - "d" 4}] - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp Collection [collection] - (tu/with-model-cleanup [Card] - (with-ordered-items collection [Card a - Dashboard b - Pulse c] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - [(get-name->collection-position :rasta collection) - (do - ((test-users/user->client :rasta) :post 200 "card" - (merge (card-with-name-and-query "d") - {:collection_id (u/get-id collection) - :collection_position 4})) - (get-name->collection-position :rasta collection))]))))) +(deftest add-new-card-to-existing-collection-at-position-1 + (is (= + ;; Original collection, before adding the new card + [{"b" 1 + "c" 2 + "d" 3} + ;; Add new card at index 1 + {"a" 1 + "b" 2 + "c" 3 + "d" 4}] + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp Collection [collection] + (tu/with-model-cleanup [Card] + (with-ordered-items collection [Dashboard b + Pulse c + Card d] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + [(get-name->collection-position :rasta collection) + (do + ((test-users/user->client :rasta) :post 202 "card" + (merge (card-with-name-and-query "a") + {:collection_id (u/get-id collection) + :collection_position 1})) + (get-name->collection-position :rasta collection))]))))))) + +(deftest add-new-card-to-end-of-existing-collection + ;; Add a new card to the end of an existing collection + (is (= + ;; Original collection, before adding the new card + [{"a" 1 + "b" 2 + "c" 3} + ;; Add new card at index 4 + {"a" 1 + "b" 2 + "c" 3 + "d" 4}] + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp Collection [collection] + (tu/with-model-cleanup [Card] + (with-ordered-items collection [Card a + Dashboard b + Pulse c] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + [(get-name->collection-position :rasta collection) + (do + ((test-users/user->client :rasta) :post 202 "card" + (merge (card-with-name-and-query "d") + {:collection_id (u/get-id collection) + :collection_position 4})) + (get-name->collection-position :rasta collection))]))))))) ;; When adding a new card to a collection that does not have a position, it should not change existing positions -(expect - ;; Original collection, before adding the new card - [{"a" 1 - "b" 2 - "c" 3} - ;; Add new card without a position - {"a" 1 - "b" 2 - "c" 3 - "d" nil}] - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp Collection [collection] - (tu/with-model-cleanup [Card] - (with-ordered-items collection [Pulse a - Card b - Dashboard c] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - [(get-name->collection-position :rasta collection) - (do - ((test-users/user->client :rasta) :post 200 "card" - (merge (card-with-name-and-query "d") - {:collection_id (u/get-id collection) - :collection_position nil})) - (get-name->collection-position :rasta collection))]))))) - -(expect - {"d" 1 - "a" 2 - "b" 3 - "c" 4 - "e" 5 - "f" 6} - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp Collection [collection] - (with-ordered-items collection [Dashboard a - Dashboard b - Card c - Card d - Pulse e - Pulse f] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id d)) - {:collection_position 1, :collection_id (u/get-id collection)}) - (name->position ((test-users/user->client :rasta) :get 200 (format "collection/%s/items" (u/get-id collection)))))))) +(deftest adding-card-doesn-not-change-existing-positions + (is (= + ;; Original collection, before adding the new card + [{"a" 1 + "b" 2 + "c" 3} + ;; Add new card without a position + {"a" 1 + "b" 2 + "c" 3 + "d" nil}] + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp Collection [collection] + (tu/with-model-cleanup [Card] + (with-ordered-items collection [Pulse a + Card b + Dashboard c] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + [(get-name->collection-position :rasta collection) + (do + ((test-users/user->client :rasta) :post 202 "card" + (merge (card-with-name-and-query "d") + {:collection_id (u/get-id collection) + :collection_position nil})) + (get-name->collection-position :rasta collection))])))))) + + (is (= {"d" 1 + "a" 2 + "b" 3 + "c" 4 + "e" 5 + "f" 6} + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp Collection [collection] + (with-ordered-items collection [Dashboard a + Dashboard b + Card c + Card d + Pulse e + Pulse f] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id d)) + {:collection_position 1, :collection_id (u/get-id collection)}) + (name->position ((test-users/user->client :rasta) :get 200 (format "collection/%s/items" (u/get-id collection)))))))))) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -969,23 +959,23 @@ [{:message "Archiving a Card should trigger Alert deletion" :expected-email "the question was archived by Rasta Toucan" :f (fn [{:keys [card]}] - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:archived true}))} + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id card)) {:archived true}))} {:message "Validate changing a display type triggers alert deletion" :card {:display :table} :expected-email "the question was edited by Rasta Toucan" :f (fn [{:keys [card]}] - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:display :line}))} + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id card)) {:display :line}))} {:message "Changing the display type from line to table should force a delete" :card {:display :line} :expected-email "the question was edited by Rasta Toucan" :f (fn [{:keys [card]}] - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:display :table}))} + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id card)) {:display :table}))} {:message "Removing the goal value will trigger the alert to be deleted" :card {:display :line :visualization_settings {:graph.goal_value 10}} :expected-email "the question was edited by Rasta Toucan" :f (fn [{:keys [card]}] - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:visualization_settings {:something "else"}}))} + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id card)) {:visualization_settings {:something "else"}}))} {:message "Adding an additional breakout will cause the alert to be removed" :card {:display :line :visualization_settings {:graph.goal_value 10} @@ -997,7 +987,7 @@ "hour"]])} :expected-email "the question was edited by Crowberto Corv" :f (fn [{:keys [card]}] - ((test-users/user->client :crowberto) :put 200 (str "card/" (u/get-id card)) + ((test-users/user->client :crowberto) :put 202 (str "card/" (u/get-id card)) {:dataset_query (assoc-in (mbql-count-query (data/id) (data/id :checkins)) [:query :breakout] [[:datetime-field (data/id :checkins :date) "hour"] [:datetime-field (data/id :checkins :date) "minute"]])}))}]] @@ -1029,53 +1019,50 @@ (Pulse (u/get-id pulse))) "Alert should have been deleted"))))))) -;; Changing the display type from line to area/bar is fine and doesn't delete the alert -(expect - {:emails-1 {} - :pulse-1 true - :emails-2 {} - :pulse-2 true} - (tt/with-temp* [Card [card {:display :line - :visualization_settings {:graph.goal_value 10}}] - Pulse [pulse {:alert_condition "goal" - :alert_first_only false - :creator_id (test-users/user->id :rasta) - :name "Original Alert Name"}] - PulseCard [_ {:pulse_id (u/get-id pulse) - :card_id (u/get-id card) - :position 0}] - PulseChannel [pc {:pulse_id (u/get-id pulse)}] - PulseChannelRecipient [_ {:user_id (test-users/user->id :rasta) - :pulse_channel_id (u/get-id pc)}]] - (with-cards-in-writeable-collection card - (et/with-fake-inbox - (array-map - :emails-1 (do - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:display :area}) - (et/regex-email-bodies #"the question was edited by Rasta Toucan")) - :pulse-1 (boolean (Pulse (u/get-id pulse))) - :emails-2 (do - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:display :bar}) - (et/regex-email-bodies #"the question was edited by Rasta Toucan")) - :pulse-2 (boolean (Pulse (u/get-id pulse)))))))) +(deftest changing-the-display-type-from-line-to-area-bar-is-fine-and-doesn-t-delete-the-alert + (is (= {:emails-1 {} + :pulse-1 true + :emails-2 {} + :pulse-2 true} + (tt/with-temp* [Card [card {:display :line + :visualization_settings {:graph.goal_value 10}}] + Pulse [pulse {:alert_condition "goal" + :alert_first_only false + :creator_id (test-users/user->id :rasta) + :name "Original Alert Name"}] + PulseCard [_ {:pulse_id (u/get-id pulse) + :card_id (u/get-id card) + :position 0}] + PulseChannel [pc {:pulse_id (u/get-id pulse)}] + PulseChannelRecipient [_ {:user_id (test-users/user->id :rasta) + :pulse_channel_id (u/get-id pc)}]] + (with-cards-in-writeable-collection card + (et/with-fake-inbox + (array-map + :emails-1 (do + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id card)) {:display :area}) + (et/regex-email-bodies #"the question was edited by Rasta Toucan")) + :pulse-1 (boolean (Pulse (u/get-id pulse))) + :emails-2 (do + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id card)) {:display :bar}) + (et/regex-email-bodies #"the question was edited by Rasta Toucan")) + :pulse-2 (boolean (Pulse (u/get-id pulse)))))))))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | 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 - (tt/with-temp Card [card] - (with-cards-in-writeable-collection card - ((test-users/user->client :rasta) :delete 204 (str "card/" (u/get-id card))) - (Card (u/get-id card))))) +(deftest check-that-we-can-delete-a-card + (is (nil? (tt/with-temp Card [card] + (with-cards-in-writeable-collection card + ((test-users/user->client :rasta) :delete 204 (str "card/" (u/get-id card))) + (Card (u/get-id card))))))) ;; deleting a card that doesn't exist should return a 404 (#1957) -(expect - "Not found." - ((test-users/user->client :crowberto) :delete 404 "card/12345")) +(deftest deleting-a-card-that-doesn-t-exist-should-return-a-404---1957- + (is (= "Not found." + ((test-users/user->client :crowberto) :delete 404 "card/12345")))) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -1093,65 +1080,59 @@ ((test-users/user->client :rasta) :delete 204 (format "card/%d/favorite" (u/get-id card)))) ;; ## GET /api/card/:id/favorite -;; Can we see if a Card is a favorite ? -(expect - false - (tt/with-temp Card [card] - (with-cards-in-readable-collection card - (fave? card)))) +(deftest can-we-see-if-a-card-is-a-favorite-- + (is (= false + (tt/with-temp Card [card] + (with-cards-in-readable-collection card + (fave? card)))))) ;; ## POST /api/card/:id/favorite -;; Can we favorite a card? -(expect - {1 false - 2 true} - (tt/with-temp Card [card] - (with-cards-in-readable-collection card - (array-map - 1 (fave? card) - 2 (do (fave! card) - (fave? card)))))) +(deftest can-we-favorite-a-card- + (is (= {1 false + 2 true} + (tt/with-temp Card [card] + (with-cards-in-readable-collection card + (array-map + 1 (fave? card) + 2 (do (fave! card) + (fave? card)))))))) ;; DELETE /api/card/:id/favorite -;; Can we unfavorite a card? -(expect - {1 false - 2 true - 3 false} - (tt/with-temp Card [card] - (with-cards-in-readable-collection card - (array-map - 1 (fave? card) - 2 (do (fave! card) - (fave? card)) - 3 (do (unfave! card) - (fave? card)))))) - +(deftest can-we-unfavorite-a-card- + (is (= {1 false + 2 true + 3 false} + (tt/with-temp Card [card] + (with-cards-in-readable-collection card + (array-map + 1 (fave? card) + 2 (do (fave! card) + (fave? card)) + 3 (do (unfave! card) + (fave? card)))))))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | CSV/JSON/XLSX DOWNLOADS | ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; Tests for GET /api/card/:id/json +(deftest endpoint-should-return-an-array-of-maps--one-for-each-row + (is (= [{(keyword "COUNT(*)") 75}] + (with-temp-native-card [_ card] + (with-cards-in-readable-collection card + ((test-users/user->client :rasta) :post 202 (format "card/%d/query/json" (u/get-id card)))))))) + +(deftest tests-for-get--api-card--id-xlsx + (is (= [{:col "COUNT(*)"} {:col 75.0}] + (with-temp-native-card [_ card] + (with-cards-in-readable-collection card + (->> ((test-users/user->client :rasta) :post 202 (format "card/%d/query/xlsx" (u/get-id card)) + {:request-options {:as :byte-array}}) + ByteArrayInputStream. + spreadsheet/load-workbook + (spreadsheet/select-sheet "Query result") + (spreadsheet/select-columns {:A :col}))))))) -;; endpoint should return an array of maps, one for each row -(expect - [{(keyword "COUNT(*)") 75}] - (with-temp-native-card [_ card] - (with-cards-in-readable-collection card - ((test-users/user->client :rasta) :post 200 (format "card/%d/query/json" (u/get-id card)))))) - -;;; Tests for GET /api/card/:id/xlsx -(expect - [{:col "COUNT(*)"} {:col 75.0}] - (with-temp-native-card [_ card] - (with-cards-in-readable-collection card - (->> ((test-users/user->client :rasta) :post 200 (format "card/%d/query/xlsx" (u/get-id card)) - {:request-options {:as :byte-array}}) - ByteArrayInputStream. - spreadsheet/load-workbook - (spreadsheet/select-sheet "Query result") - (spreadsheet/select-columns {:A :col}))))) ;;; Test GET /api/card/:id/query/csv & GET /api/card/:id/json & GET /api/card/:id/query/xlsx **WITH PARAMETERS** (def ^:private ^:const ^String encoded-params @@ -1159,143 +1140,148 @@ :target [:variable [:template-tag :category]] :value 2}])) -;; CSV -(expect - (str "COUNT(*)\n" - "8\n") - (with-temp-native-card-with-params [_ card] - (with-cards-in-readable-collection card - ((test-users/user->client :rasta) :post 200 (format "card/%d/query/csv?parameters=%s" (u/get-id card) encoded-params))))) - -;; JSON -(expect - [{(keyword "COUNT(*)") 8}] - (with-temp-native-card-with-params [_ card] - (with-cards-in-readable-collection card - ((test-users/user->client :rasta) :post 200 (format "card/%d/query/json?parameters=%s" (u/get-id card) encoded-params))))) - -;; XLSX -(expect - [{:col "COUNT(*)"} {:col 8.0}] - (with-temp-native-card-with-params [_ card] - (with-cards-in-readable-collection card - (->> ((test-users/user->client :rasta) :post 200 (format "card/%d/query/xlsx?parameters=%s" (u/get-id card) encoded-params) - {:request-options {:as :byte-array}}) - ByteArrayInputStream. - spreadsheet/load-workbook - (spreadsheet/select-sheet "Query result") - (spreadsheet/select-columns {:A :col}))))) + +(deftest query-csv + (is (= (str "COUNT(*)\n" + "8\n") + (with-temp-native-card-with-params [_ card] + (with-cards-in-readable-collection card + ((test-users/user->client :rasta) :post 202 (format "card/%d/query/csv?parameters=%s" (u/get-id card) encoded-params))))))) + + + +(deftest query-json + (is (= [{(keyword "COUNT(*)") 8}] + (with-temp-native-card-with-params [_ card] + (with-cards-in-readable-collection card + ((test-users/user->client :rasta) :post 202 (format "card/%d/query/json?parameters=%s" (u/get-id card) encoded-params))))))) + + + +(deftest query-xlsx + (is (= [{:col "COUNT(*)"} {:col 8.0}] + (with-temp-native-card-with-params [_ card] + (with-cards-in-readable-collection card + (->> ((test-users/user->client :rasta) :post 202 (format "card/%d/query/xlsx?parameters=%s" (u/get-id card) encoded-params) + {:request-options {:as :byte-array}}) + ByteArrayInputStream. + spreadsheet/load-workbook + (spreadsheet/select-sheet "Query result") + (spreadsheet/select-columns {:A :col}))))))) + ;; Downloading CSV/JSON/XLSX results shouldn't be subject to the default query constraints -- even if the query comes ;; in with `add-default-userland-constraints` (as will be the case if the query gets saved from one that had it -- see ;; #9831) -(expect - 101 - (with-redefs [constraints/default-query-constraints {:max-results 10, :max-results-bare-rows 10}] - (tt/with-temp Card [card {:dataset_query {:database (data/id) - :type :query - :query {:source-table (data/id :venues)} - :middleware - {:add-default-userland-constraints? true - :userland-query? true}}}] - (with-cards-in-readable-collection card - (let [results ((test-users/user->client :rasta) :post 200 (format "card/%d/query/csv" (u/get-id card)))] - (count (csv/read-csv results))))))) + + +(deftest formatted-export + (is (= 101 + (with-redefs [constraints/default-query-constraints {:max-results 10, :max-results-bare-rows 10}] + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :query + :query {:source-table (data/id :venues)} + :middleware + {:add-default-userland-constraints? true + :userland-query? true}}}] + (with-cards-in-readable-collection card + (let [results ((test-users/user->client :rasta) :post 202 (format "card/%d/query/csv" (u/get-id card)))] + (count (csv/read-csv results))))))))) + ;; non-"download" queries should still get the default constraints ;; (this also is a sanitiy check to make sure the `with-redefs` in the test above actually works) -(expect - 10 - (with-redefs [constraints/default-query-constraints {:max-results 10, :max-results-bare-rows 10}] - (tt/with-temp Card [card {:dataset_query {:database (data/id) - :type :query - :query {:source-table (data/id :venues)} - :middleware - {:add-default-userland-constraints? true - :userland-query? true}}}] - (with-cards-in-readable-collection card - (let [{row-count :row_count, :as result} - ((test-users/user->client :rasta) :post 200 (format "card/%d/query" (u/get-id card)))] - (or row-count result)))))) +(deftest non-download-queries + (is (= 10 + (with-redefs [constraints/default-query-constraints {:max-results 10, :max-results-bare-rows 10}] + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :query + :query {:source-table (data/id :venues)} + :middleware + {:add-default-userland-constraints? true + :userland-query? true}}}] + (with-cards-in-readable-collection card + (let [{row-count :row_count, :as result} + ((test-users/user->client :rasta) :post 202 (format "card/%d/query" (u/get-id card)))] + (or row-count result)))))))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | COLLECTIONS | ;;; +----------------------------------------------------------------------------------------------------------------+ -;; Make sure we can create a card and specify its `collection_id` at the same time -(expect + +(deftest make-sure-we-can-create-a-card-and-specify-its--collection-id--at-the-same-time (tu/with-non-admin-groups-no-root-collection-perms (tt/with-temp Collection [collection] (tu/with-model-cleanup [Card] (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - (let [card ((test-users/user->client :rasta) :post 200 "card" + (let [card ((test-users/user->client :rasta) :post 202 "card" (assoc (card-with-name-and-query) - :collection_id (u/get-id collection)))] + :collection_id (u/get-id collection)))] (= (db/select-one-field :collection_id Card :id (u/get-id card)) (u/get-id collection))))))) -;; Make sure we card creation fails if we try to set a `collection_id` we don't have permissions for -(expect - "You don't have permissions to do that." - (tu/with-non-admin-groups-no-root-collection-perms - (tu/with-model-cleanup [Card] - (tt/with-temp Collection [collection] - ((test-users/user->client :rasta) :post 403 "card" - (assoc (card-with-name-and-query) - :collection_id (u/get-id collection))))))) +(deftest make-sure-we-card-creation-fails-if-we-try-to-set-a--collection-id--we-don-t-have-permissions-for + (is (= "You don't have permissions to do that." + (tu/with-non-admin-groups-no-root-collection-perms + (tu/with-model-cleanup [Card] + (tt/with-temp Collection [collection] + ((test-users/user->client :rasta) :post 403 "card" + (assoc (card-with-name-and-query) + :collection_id (u/get-id collection))))))))) -;; Make sure we can change the `collection_id` of a Card if it's not in any collection -(expect +(deftest make-sure-we-can-change-the--collection-id--of-a-card-if-it-s-not-in-any-collection (tt/with-temp* [Card [card] Collection [collection]] - ((test-users/user->client :crowberto) :put 200 (str "card/" (u/get-id card)) {:collection_id (u/get-id collection)}) + ((test-users/user->client :crowberto) :put 202 (str "card/" (u/get-id card)) {:collection_id (u/get-id collection)}) (= (db/select-one-field :collection_id Card :id (u/get-id card)) (u/get-id collection)))) -;; Make sure we can still change *anything* for a Card if we don't have permissions for the Collection it belongs to -(expect - "You don't have permissions to do that." - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp* [Collection [collection] - Card [card {:collection_id (u/get-id collection)}]] - ((test-users/user->client :rasta) :put 403 (str "card/" (u/get-id card)) {:name "Number of Blueberries Consumed Per Month"})))) +(deftest make-sure-we-can-still-change--anything--for-a-card-if-we-don-t-have-permissions-for-the-collection-it-belongs-to + (is (= "You don't have permissions to do that." + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp* [Collection [collection] + Card [card {:collection_id (u/get-id collection)}]] + ((test-users/user->client :rasta) :put 403 (str "card/" (u/get-id card)) {:name "Number of Blueberries Consumed Per Month"})))))) + ;; Make sure that we can't change the `collection_id` of a Card if we don't have write permissions for the new ;; collection -(expect - "You don't have permissions to do that." - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp* [Collection [original-collection] - Collection [new-collection] - Card [card {:collection_id (u/get-id original-collection)}]] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) original-collection) - ((test-users/user->client :rasta) :put 403 (str "card/" (u/get-id card)) {:collection_id (u/get-id new-collection)})))) +(deftest cant-change-collection-id-of-card-without-write-permission-in-new-collection + (is (= "You don't have permissions to do that." + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp* [Collection [original-collection] + Collection [new-collection] + Card [card {:collection_id (u/get-id original-collection)}]] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) original-collection) + ((test-users/user->client :rasta) :put 403 (str "card/" (u/get-id card)) {:collection_id (u/get-id new-collection)})))))) + ;; Make sure that we can't change the `collection_id` of a Card if we don't have write permissions for the current ;; collection -(expect - "You don't have permissions to do that." - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp* [Collection [original-collection] - Collection [new-collection] - Card [card {:collection_id (u/get-id original-collection)}]] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) new-collection) - ((test-users/user->client :rasta) :put 403 (str "card/" (u/get-id card)) {:collection_id (u/get-id new-collection)})))) +(deftest cant-change-collection-id-of-card-without-write-permission-in-current-collection + (is (= "You don't have permissions to do that." + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp* [Collection [original-collection] + Collection [new-collection] + Card [card {:collection_id (u/get-id original-collection)}]] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) new-collection) + ((test-users/user->client :rasta) :put 403 (str "card/" (u/get-id card)) {:collection_id (u/get-id new-collection)})))))) + -;; But if we do have permissions for both, we should be able to change it. -(expect + +(deftest but-if-we-do-have-permissions-for-both--we-should-be-able-to-change-it- (tu/with-non-admin-groups-no-root-collection-perms (tt/with-temp* [Collection [original-collection] Collection [new-collection] Card [card {:collection_id (u/get-id original-collection)}]] (perms/grant-collection-readwrite-permissions! (perms-group/all-users) original-collection) (perms/grant-collection-readwrite-permissions! (perms-group/all-users) new-collection) - ((test-users/user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:collection_id (u/get-id new-collection)}) + ((test-users/user->client :rasta) :put 202 (str "card/" (u/get-id card)) {:collection_id (u/get-id new-collection)}) (= (db/select-one-field :collection_id Card :id (u/get-id card)) (u/get-id new-collection))))) - ;;; ------------------------------ Bulk Collections Update (POST /api/card/collections) ------------------------------ (defn- collection-names @@ -1324,109 +1310,102 @@ :collections (collection-names cards-or-card-ids))) -;; Test that we can bulk move some Cards with no collection into a collection -(expect - {:response {:status "ok"} - :collections ["Pog Collection" - "Pog Collection"]} - (tt/with-temp* [Collection [collection {:name "Pog Collection"}] - Card [card-1] - Card [card-2]] - (POST-card-collections! :crowberto 200 collection [card-1 card-2]))) - -;; Test that we can bulk move some Cards from one collection to another -(expect - {:response {:status "ok"} - :collections ["New Collection" "New Collection"]} - (tt/with-temp* [Collection [old-collection {:name "Old Collection"}] - Collection [new-collection {:name "New Collection"}] - Card [card-1 {:collection_id (u/get-id old-collection)}] - Card [card-2 {:collection_id (u/get-id old-collection)}]] - (POST-card-collections! :crowberto 200 new-collection [card-1 card-2]))) - -;; Test that we can bulk remove some Cards from a collection -(expect - {:response {:status "ok"} - :collections [nil nil]} - (tt/with-temp* [Collection [collection] - Card [card-1 {:collection_id (u/get-id collection)}] - Card [card-2 {:collection_id (u/get-id collection)}]] - (POST-card-collections! :crowberto 200 nil [card-1 card-2]))) - -;; Check that we aren't allowed to move Cards if we don't have permissions for destination collection -(expect - {:response "You don't have permissions to do that." - :collections [nil nil]} - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp* [Collection [collection] - Card [card-1] - Card [card-2]] - (POST-card-collections! :rasta 403 collection [card-1 card-2])))) - -;; Check that we aren't allowed to move Cards if we don't have permissions for source collection -(expect - {:response "You don't have permissions to do that." - :collections ["Horseshoe Collection" "Horseshoe Collection"]} - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp* [Collection [collection {:name "Horseshoe Collection"}] - Card [card-1 {:collection_id (u/get-id collection)}] - Card [card-2 {:collection_id (u/get-id collection)}]] - (POST-card-collections! :rasta 403 nil [card-1 card-2])))) - -;; Check that we aren't allowed to move Cards if we don't have permissions for the Card -(expect - {:response "You don't have permissions to do that." - :collections [nil nil]} - (tu/with-non-admin-groups-no-root-collection-perms - (tt/with-temp* [Collection [collection] - Database [database] - Table [table {:db_id (u/get-id database)}] - Card [card-1 {:dataset_query (mbql-count-query (u/get-id database) (u/get-id table))}] - Card [card-2 {:dataset_query (mbql-count-query (u/get-id database) (u/get-id table))}]] - (perms/revoke-permissions! (perms-group/all-users) (u/get-id database)) - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - (POST-card-collections! :rasta 403 collection [card-1 card-2])))) +(deftest test-that-we-can-bulk-move-some-cards-with-no-collection-into-a-collection + (is (= {:response {:status "ok"} + :collections ["Pog Collection" + "Pog Collection"]} + (tt/with-temp* [Collection [collection {:name "Pog Collection"}] + Card [card-1] + Card [card-2]] + (POST-card-collections! :crowberto 200 collection [card-1 card-2]))))) + +(deftest test-that-we-can-bulk-move-some-cards-from-one-collection-to-another + (is (= {:response {:status "ok"} + :collections ["New Collection" "New Collection"]} + (tt/with-temp* [Collection [old-collection {:name "Old Collection"}] + Collection [new-collection {:name "New Collection"}] + Card [card-1 {:collection_id (u/get-id old-collection)}] + Card [card-2 {:collection_id (u/get-id old-collection)}]] + (POST-card-collections! :crowberto 200 new-collection [card-1 card-2]))))) + +(deftest test-that-we-can-bulk-remove-some-cards-from-a-collection + (is (= {:response {:status "ok"} + :collections [nil nil]} + (tt/with-temp* [Collection [collection] + Card [card-1 {:collection_id (u/get-id collection)}] + Card [card-2 {:collection_id (u/get-id collection)}]] + (POST-card-collections! :crowberto 200 nil [card-1 card-2]))))) + +(deftest check-that-we-aren-t-allowed-to-move-cards-if-we-don-t-have-permissions-for-destination-collection + (is (= {:response "You don't have permissions to do that." + :collections [nil nil]} + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp* [Collection [collection] + Card [card-1] + Card [card-2]] + (POST-card-collections! :rasta 403 collection [card-1 card-2])))))) + +(deftest check-that-we-aren-t-allowed-to-move-cards-if-we-don-t-have-permissions-for-source-collection + (is (= {:response "You don't have permissions to do that." + :collections ["Horseshoe Collection" "Horseshoe Collection"]} + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp* [Collection [collection {:name "Horseshoe Collection"}] + Card [card-1 {:collection_id (u/get-id collection)}] + Card [card-2 {:collection_id (u/get-id collection)}]] + (POST-card-collections! :rasta 403 nil [card-1 card-2])))))) + +(deftest check-that-we-aren-t-allowed-to-move-cards-if-we-don-t-have-permissions-for-the-card + (is (= {:response "You don't have permissions to do that." + :collections [nil nil]} + (tu/with-non-admin-groups-no-root-collection-perms + (tt/with-temp* [Collection [collection] + Database [database] + Table [table {:db_id (u/get-id database)}] + Card [card-1 {:dataset_query (mbql-count-query (u/get-id database) (u/get-id table))}] + Card [card-2 {:dataset_query (mbql-count-query (u/get-id database) (u/get-id table))}]] + (perms/revoke-permissions! (perms-group/all-users) (u/get-id database)) + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + (POST-card-collections! :rasta 403 collection [card-1 card-2])))))) ;; Test that we can bulk move some Cards from one collection to another, while updating the collection position of the ;; old collection and the new collection -(expect - [{:response {:status "ok"} - :collections ["New Collection" "New Collection"]} - {"a" 4 ;-> Moved to the new collection, gets the first slot available - "b" 5 - "c" 1 ;-> With a and b no longer in the collection, c is first - "d" 1 ;-> Existing cards in new collection are untouched and position unchanged - "e" 2 - "f" 3}] - (tt/with-temp* [Collection [{coll-id-1 :id} {:name "Old Collection"}] - Collection [{coll-id-2 :id - :as new-collection} {:name "New Collection"}] - Card [card-a {:name "a", :collection_id coll-id-1, :collection_position 1}] - Card [card-b {:name "b", :collection_id coll-id-1, :collection_position 2}] - Card [card-c {:name "c", :collection_id coll-id-1, :collection_position 3}] - Card [card-d {:name "d", :collection_id coll-id-2, :collection_position 1}] - Card [card-e {:name "e", :collection_id coll-id-2, :collection_position 2}] - Card [card-f {:name "f", :collection_id coll-id-2, :collection_position 3}]] - [(POST-card-collections! :crowberto 200 new-collection [card-a card-b]) - (merge (name->position ((test-users/user->client :crowberto) :get 200 (format "collection/%s/items" coll-id-1) :model "card" :archived "false")) - (name->position ((test-users/user->client :crowberto) :get 200 (format "collection/%s/items" coll-id-2) :model "card" :archived "false")))])) - -;; Moving a card without a collection_position keeps the collection_position nil -(expect - [{:response {:status "ok"} - :collections ["New Collection" "New Collection"]} - {"a" nil - "b" 1 - "c" 2}] - (tt/with-temp* [Collection [{coll-id-1 :id} {:name "Old Collection"}] - Collection [{coll-id-2 :id - :as new-collection} {:name "New Collection"}] - Card [card-a {:name "a", :collection_id coll-id-1}] - Card [card-b {:name "b", :collection_id coll-id-2, :collection_position 1}] - Card [card-c {:name "c", :collection_id coll-id-2, :collection_position 2}]] - [(POST-card-collections! :crowberto 200 new-collection [card-a card-b]) - (merge (name->position ((test-users/user->client :crowberto) :get 200 (format "collection/%s/items" coll-id-1) :model "card" :archived "false")) - (name->position ((test-users/user->client :crowberto) :get 200 (format "collection/%s/items" coll-id-2) :model "card" :archived "false")))])) +(deftest bulk-move-cards + (is (= [{:response {:status "ok"} + :collections ["New Collection" "New Collection"]} + {"a" 4 ;-> Moved to the new collection, gets the first slot available + "b" 5 + "c" 1 ;-> With a and b no longer in the collection, c is first + "d" 1 ;-> Existing cards in new collection are untouched and position unchanged + "e" 2 + "f" 3}] + (tt/with-temp* [Collection [{coll-id-1 :id} {:name "Old Collection"}] + Collection [{coll-id-2 :id + :as new-collection} {:name "New Collection"}] + Card [card-a {:name "a", :collection_id coll-id-1, :collection_position 1}] + Card [card-b {:name "b", :collection_id coll-id-1, :collection_position 2}] + Card [card-c {:name "c", :collection_id coll-id-1, :collection_position 3}] + Card [card-d {:name "d", :collection_id coll-id-2, :collection_position 1}] + Card [card-e {:name "e", :collection_id coll-id-2, :collection_position 2}] + Card [card-f {:name "f", :collection_id coll-id-2, :collection_position 3}]] + [(POST-card-collections! :crowberto 200 new-collection [card-a card-b]) + (merge (name->position ((test-users/user->client :crowberto) :get 200 (format "collection/%s/items" coll-id-1) :model "card" :archived "false")) + (name->position ((test-users/user->client :crowberto) :get 200 (format "collection/%s/items" coll-id-2) :model "card" :archived "false")))])))) + +(deftest moving-a-card-without-a-collection-position-keeps-the-collection-position-nil + (is (= [{:response {:status "ok"} + :collections ["New Collection" "New Collection"]} + {"a" nil + "b" 1 + "c" 2}] + (tt/with-temp* [Collection [{coll-id-1 :id} {:name "Old Collection"}] + Collection [{coll-id-2 :id + :as new-collection} {:name "New Collection"}] + Card [card-a {:name "a", :collection_id coll-id-1}] + Card [card-b {:name "b", :collection_id coll-id-2, :collection_position 1}] + Card [card-c {:name "c", :collection_id coll-id-2, :collection_position 2}]] + [(POST-card-collections! :crowberto 200 new-collection [card-a card-b]) + (merge (name->position ((test-users/user->client :crowberto) :get 200 (format "collection/%s/items" coll-id-1) :model "card" :archived "false")) + (name->position ((test-users/user->client :crowberto) :get 200 (format "collection/%s/items" coll-id-2) :model "card" :archived "false")))])))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | PUBLIC SHARING ENDPOINTS | @@ -1438,96 +1417,83 @@ ;;; ----------------------------------------- POST /api/card/:id/public_link ----------------------------------------- -;; Test that we can share a Card -(expect + +(deftest test-that-we-can-share-a-card (tu/with-temporary-setting-values [enable-public-sharing true] (tt/with-temp Card [card] (let [{uuid :uuid} ((test-users/user->client :crowberto) :post 200 (format "card/%d/public_link" (u/get-id card)))] (db/exists? Card :id (u/get-id card), :public_uuid uuid))))) -;; Test that we *cannot* share a Card if we aren't admins -(expect - "You don't have permissions to do that." - (tu/with-temporary-setting-values [enable-public-sharing true] - (tt/with-temp Card [card] - ((test-users/user->client :rasta) :post 403 (format "card/%d/public_link" (u/get-id card)))))) - -;; Test that we *cannot* share a Card if the setting is disabled -(expect - "Public sharing is not enabled." - (tu/with-temporary-setting-values [enable-public-sharing false] - (tt/with-temp Card [card] - ((test-users/user->client :crowberto) :post 400 (format "card/%d/public_link" (u/get-id card)))))) - -;; Test that we *cannot* share a Card if the Card has been archived -(expect - {:message "The object has been archived.", :error_code "archived"} - (tu/with-temporary-setting-values [enable-public-sharing true] - (tt/with-temp Card [card {:archived true}] - ((test-users/user->client :crowberto) :post 404 (format "card/%d/public_link" (u/get-id card)))))) - -;; Test that we get a 404 if the Card doesn't exist -(expect - "Not found." - (tu/with-temporary-setting-values [enable-public-sharing true] - ((test-users/user->client :crowberto) :post 404 (format "card/%d/public_link" Integer/MAX_VALUE)))) - -;; Test that if a Card has already been shared we reüse the existing UUID -(expect +(deftest test-that-we--cannot--share-a-card-if-we-aren-t-admins + (is (= "You don't have permissions to do that." + (tu/with-temporary-setting-values [enable-public-sharing true] + (tt/with-temp Card [card] + ((test-users/user->client :rasta) :post 403 (format "card/%d/public_link" (u/get-id card)))))))) + +(deftest test-that-we--cannot--share-a-card-if-the-setting-is-disabled + (is (= "Public sharing is not enabled." + (tu/with-temporary-setting-values [enable-public-sharing false] + (tt/with-temp Card [card] + ((test-users/user->client :crowberto) :post 400 (format "card/%d/public_link" (u/get-id card)))))))) + +(deftest test-that-we--cannot--share-a-card-if-the-card-has-been-archived + (is (= {:message "The object has been archived.", :error_code "archived"} + (tu/with-temporary-setting-values [enable-public-sharing true] + (tt/with-temp Card [card {:archived true}] + ((test-users/user->client :crowberto) :post 404 (format "card/%d/public_link" (u/get-id card)))))))) + +(deftest test-that-we-get-a-404-if-the-card-doesn-t-exist + (is (= "Not found." + (tu/with-temporary-setting-values [enable-public-sharing true] + ((test-users/user->client :crowberto) :post 404 (format "card/%d/public_link" Integer/MAX_VALUE)))))) + +(deftest test-that-if-a-card-has-already-been-shared-we-re-se-the-existing-uuid (tu/with-temporary-setting-values [enable-public-sharing true] (tt/with-temp Card [card (shared-card)] (= (:public_uuid card) (:uuid ((test-users/user->client :crowberto) :post 200 (format "card/%d/public_link" (u/get-id card)))))))) - ;;; ---------------------------------------- DELETE /api/card/:id/public_link ---------------------------------------- -;; Test that we can unshare a Card -(expect - false - (tu/with-temporary-setting-values [enable-public-sharing true] - (tt/with-temp Card [card (shared-card)] - ((test-users/user->client :crowberto) :delete 204 (format "card/%d/public_link" (u/get-id card))) - (db/exists? Card :id (u/get-id card), :public_uuid (:public_uuid card))))) - -;; Test that we *cannot* unshare a Card if we are not admins -(expect - "You don't have permissions to do that." - (tu/with-temporary-setting-values [enable-public-sharing true] - (tt/with-temp Card [card (shared-card)] - ((test-users/user->client :rasta) :delete 403 (format "card/%d/public_link" (u/get-id card)))))) - -;; Test that we get a 404 if Card isn't shared -(expect - "Not found." - (tu/with-temporary-setting-values [enable-public-sharing true] - (tt/with-temp Card [card] - ((test-users/user->client :crowberto) :delete 404 (format "card/%d/public_link" (u/get-id card)))))) - -;; Test that we get a 404 if Card doesn't exist -(expect - "Not found." - (tu/with-temporary-setting-values [enable-public-sharing true] - ((test-users/user->client :crowberto) :delete 404 (format "card/%d/public_link" Integer/MAX_VALUE)))) - -;; Test that we can fetch a list of publicly-accessible cards -(expect - [{:name true, :id true, :public_uuid true}] - (tu/with-temporary-setting-values [enable-public-sharing true] - (tt/with-temp Card [card (shared-card)] - (for [card ((test-users/user->client :crowberto) :get 200 "card/public")] - (m/map-vals boolean (select-keys card [:name :id :public_uuid])))))) - -;; Test that we can fetch a list of embeddable cards -(expect - [{:name true, :id true}] - (tu/with-temporary-setting-values [enable-embedding true] - (tt/with-temp Card [card {:enable_embedding true}] - (for [card ((test-users/user->client :crowberto) :get 200 "card/embeddable")] - (m/map-vals boolean (select-keys card [:name :id])))))) - -;; Test related/recommended entities -(expect - #{:table :metrics :segments :dashboard-mates :similar-questions :canonical-metric :dashboards :collections} - (tt/with-temp Card [card] - (-> ((test-users/user->client :crowberto) :get 200 (format "card/%s/related" (u/get-id card))) keys set))) +(deftest test-that-we-can-unshare-a-card + (is (= false + (tu/with-temporary-setting-values [enable-public-sharing true] + (tt/with-temp Card [card (shared-card)] + ((test-users/user->client :crowberto) :delete 204 (format "card/%d/public_link" (u/get-id card))) + (db/exists? Card :id (u/get-id card), :public_uuid (:public_uuid card))))))) + +(deftest test-that-we--cannot--unshare-a-card-if-we-are-not-admins + (is (= "You don't have permissions to do that." + (tu/with-temporary-setting-values [enable-public-sharing true] + (tt/with-temp Card [card (shared-card)] + ((test-users/user->client :rasta) :delete 403 (format "card/%d/public_link" (u/get-id card)))))))) + +(deftest test-that-we-get-a-404-if-card-isn-t-shared + (is (= "Not found." + (tu/with-temporary-setting-values [enable-public-sharing true] + (tt/with-temp Card [card] + ((test-users/user->client :crowberto) :delete 404 (format "card/%d/public_link" (u/get-id card)))))))) + +(deftest test-that-we-get-a-404-if-card-doesn-t-exist + (is (= "Not found." + (tu/with-temporary-setting-values [enable-public-sharing true] + ((test-users/user->client :crowberto) :delete 404 (format "card/%d/public_link" Integer/MAX_VALUE)))))) + +(deftest test-that-we-can-fetch-a-list-of-publicly-accessible-cards + (is (= [{:name true, :id true, :public_uuid true}] + (tu/with-temporary-setting-values [enable-public-sharing true] + (tt/with-temp Card [card (shared-card)] + (for [card ((test-users/user->client :crowberto) :get 200 "card/public")] + (m/map-vals boolean (select-keys card [:name :id :public_uuid])))))))) + +(deftest test-that-we-can-fetch-a-list-of-embeddable-cards + (is (= [{:name true, :id true}] + (tu/with-temporary-setting-values [enable-embedding true] + (tt/with-temp Card [card {:enable_embedding true}] + (for [card ((test-users/user->client :crowberto) :get 200 "card/embeddable")] + (m/map-vals boolean (select-keys card [:name :id])))))))) + +(deftest test-related-recommended-entities + (is (= #{:table :metrics :segments :dashboard-mates :similar-questions :canonical-metric :dashboards :collections} + (tt/with-temp Card [card] + (-> ((test-users/user->client :crowberto) :get 200 (format "card/%s/related" (u/get-id card))) keys set))))) diff --git a/test/metabase/api/database_test.clj b/test/metabase/api/database_test.clj index dcfad8686d7fd34becd55995508d85dd5ad6e78f..c67106aa45ee60d938c06fa8f361dedbd88e1718 100644 --- a/test/metabase/api/database_test.clj +++ b/test/metabase/api/database_test.clj @@ -3,7 +3,6 @@ (:require [clojure [string :as str] [test :refer :all]] - [expectations :refer [expect]] [medley.core :as m] [metabase [driver :as driver] @@ -42,8 +41,8 @@ ;; HELPER FNS (driver/register! ::test-driver - :parent :sql-jdbc - :abstract? true) + :parent :sql-jdbc + :abstract? true) (defmethod driver/connection-properties ::test-driver [_] nil) @@ -88,15 +87,15 @@ :schedule_type "hourly"}})) ;; ## GET /api/database/:id -;; regular users *should not* see DB details -(expect - (add-schedules (dissoc (db-details) :details)) - ((test-users/user->client :rasta) :get 200 (format "database/%d" (data/id)))) -;; superusers *should* see DB details -(expect - (add-schedules (db-details)) - ((test-users/user->client :crowberto) :get 200 (format "database/%d" (data/id)))) +(deftest regular-users--should-not--see-db-details + (is (= (add-schedules (dissoc (db-details) :details)) + ((test-users/user->client :rasta) :get 200 (format "database/%d" (data/id)))))) + +(deftest superusers--should--see-db-details + (is (= (add-schedules (db-details)) + ((test-users/user->client :crowberto) :get 200 (format "database/%d" (data/id)))))) + ;; ## POST /api/database (defn- create-db-via-api! [& [m]] @@ -115,59 +114,58 @@ ;; Check that we can create a Database (tu/expect-schema - (merge - (m/map-vals s/eq default-db-details) - {:created_at java.time.temporal.Temporal - :engine (s/eq ::test-driver) - :id su/IntGreaterThanZero - :details (s/eq {:db "my_db"}) - :updated_at java.time.temporal.Temporal - :name su/NonBlankString - :features (s/eq (driver.u/features ::test-driver))}) - (create-db-via-api!)) + (merge + (m/map-vals s/eq default-db-details) + {:created_at java.time.temporal.Temporal + :engine (s/eq ::test-driver) + :id su/IntGreaterThanZero + :details (s/eq {:db "my_db"}) + :updated_at java.time.temporal.Temporal + :name su/NonBlankString + :features (s/eq (driver.u/features ::test-driver))}) + (create-db-via-api!)) ;; can we set `auto_run_queries` to `false` when we create the Database? -(expect - {:auto_run_queries false} - (select-keys (create-db-via-api! {:auto_run_queries false}) [:auto_run_queries])) +(deftest set-auto-run-queries-false + (is (= {:auto_run_queries false} + (select-keys (create-db-via-api! {:auto_run_queries false}) [:auto_run_queries])))) ;; can we set `is_full_sync` to `false` when we create the Database? -(expect - {:is_full_sync false} - (select-keys (create-db-via-api! {:is_full_sync false}) [:is_full_sync])) +(deftest set-is-full-sync + (is (= {:is_full_sync false} + (select-keys (create-db-via-api! {:is_full_sync false}) [:is_full_sync])))) ;; ## DELETE /api/database/:id ;; Check that we can delete a Database -(expect - false +(deftest delete-a-db (tt/with-temp Database [db] ((test-users/user->client :crowberto) :delete 204 (format "database/%d" (:id db))) - (db/exists? Database :id (u/get-id db)))) + (is (false? (db/exists? Database :id (u/get-id db)))))) ;; ## PUT /api/database/:id ;; Check that we can update fields in a Database -(expect - {:details {:host "localhost", :port 5432, :dbname "fakedb", :user "rastacan"} - :engine :h2 - :name "Cam's Awesome Toucan Database" - :is_full_sync false - :features (driver.u/features :h2)} +(deftest can-update-db-fields (tt/with-temp Database [{db-id :id}] (let [updates {:name "Cam's Awesome Toucan Database" :engine "h2" :is_full_sync false :details {:host "localhost", :port 5432, :dbname "fakedb", :user "rastacan"}}] ((test-users/user->client :crowberto) :put 200 (format "database/%d" db-id) updates)) - (into {} (db/select-one [Database :name :engine :details :is_full_sync], :id db-id)))) + (is (= {:details {:host "localhost", :port 5432, :dbname "fakedb", :user "rastacan"} + :engine :h2 + :name "Cam's Awesome Toucan Database" + :is_full_sync false + :features (driver.u/features :h2)} + (into {} (db/select-one [Database :name :engine :details :is_full_sync], :id db-id)))))) ;; should be able to set `auto_run_queries` when updating a Database -(expect - {:auto_run_queries false} +(deftest set-auto-run-queries (tt/with-temp Database [{db-id :id}] (let [updates {:auto_run_queries false}] ((test-users/user->client :crowberto) :put 200 (format "database/%d" db-id) updates)) - (into {} (db/select-one [Database :auto_run_queries], :id db-id)))) + (is (= {:auto_run_queries false} + (into {} (db/select-one [Database :auto_run_queries], :id db-id)))))) (def ^:private default-table-details @@ -225,23 +223,25 @@ (apply (test-users/user->client user-kw) :get 200 "database" options))) ;; Database details *should not* come back for Rasta since she's not a superuser -(tt/expect-with-temp [Database [{db-id :id, db-name :name} {:engine (u/qualified-name ::test-driver)}]] - (sorted-databases - (cons - (test-driver-database db-id) - (all-test-data-databases))) - (api-database-list db-name :rasta)) +(deftest no-dbs-for-rasta + (tt/with-temp* [Database [{db-id :id, db-name :name} {:engine (u/qualified-name ::test-driver)}]] + (is (= (sorted-databases + (cons + (test-driver-database db-id) + (all-test-data-databases))) + (api-database-list db-name :rasta))))) ;; GET /api/databases (include tables) -(tt/expect-with-temp [Database [{db-id :id, db-name :name} {:engine (u/qualified-name ::test-driver)}]] - (sorted-databases - (cons - (assoc (test-driver-database db-id) :tables []) - (for [db (all-test-data-databases)] - (assoc db :tables (->> (db/select Table, :db_id (u/get-id db), :active true) - (map table-details) - (sort-by (comp str/lower-case :name))))))) - (api-database-list db-name :rasta, :include_tables true)) +(deftest db-endpoint-includes-tables + (tt/with-temp* [Database [{db-id :id, db-name :name} {:engine (u/qualified-name ::test-driver)}]] + (is (= (sorted-databases + (cons + (assoc (test-driver-database db-id) :tables []) + (for [db (all-test-data-databases)] + (assoc db :tables (->> (db/select Table, :db_id (u/get-id db), :active true) + (map table-details) + (sort-by (comp str/lower-case :name))))))) + (api-database-list db-name :rasta, :include_tables true))))) ;; ## GET /api/database/:id/metadata @@ -263,46 +263,45 @@ field [:updated_at :id :created_at :last_analyzed :fingerprint :fingerprint_version :fk_target_field_id]))) -(expect - (merge - default-db-details - (select-keys (data/db) [:created_at :id :updated_at :timezone]) - {:engine "h2" - :name "test-data" - :features (map u/qualified-name (driver.u/features :h2)) - :tables [(merge - default-table-details - (db/select-one [Table :created_at :updated_at :fields_hash] :id (data/id :categories)) - {:schema "PUBLIC" - :name "CATEGORIES" - :display_name "Categories" - :fields [(merge - (field-details (Field (data/id :categories :id))) - {:table_id (data/id :categories) - :special_type "type/PK" - :name "ID" - :display_name "ID" - :database_type "BIGINT" - :base_type "type/BigInteger" - :visibility_type "normal" - :has_field_values "none"}) - (merge - (field-details (Field (data/id :categories :name))) - {:table_id (data/id :categories) - :special_type "type/Name" - :name "NAME" - :display_name "Name" - :database_type "VARCHAR" - :base_type "type/Text" - :visibility_type "normal" - :has_field_values "list"})] - :segments [] - :metrics [] - :rows nil - :id (data/id :categories) - :db_id (data/id)})]}) - (let [resp ((test-users/user->client :rasta) :get 200 (format "database/%d/metadata" (data/id)))] - (assoc resp :tables (filter #(= "CATEGORIES" (:name %)) (:tables resp))))) +(deftest TODO-give-this-a-name + (is (= (merge default-db-details + (select-keys (data/db) [:created_at :id :updated_at :timezone]) + {:engine "h2" + :name "test-data" + :features (map u/qualified-name (driver.u/features :h2)) + :tables [(merge + default-table-details + (db/select-one [Table :created_at :updated_at :fields_hash] :id (data/id :categories)) + {:schema "PUBLIC" + :name "CATEGORIES" + :display_name "Categories" + :fields [(merge + (field-details (Field (data/id :categories :id))) + {:table_id (data/id :categories) + :special_type "type/PK" + :name "ID" + :display_name "ID" + :database_type "BIGINT" + :base_type "type/BigInteger" + :visibility_type "normal" + :has_field_values "none"}) + (merge + (field-details (Field (data/id :categories :name))) + {:table_id (data/id :categories) + :special_type "type/Name" + :name "NAME" + :display_name "Name" + :database_type "VARCHAR" + :base_type "type/Text" + :visibility_type "normal" + :has_field_values "list"})] + :segments [] + :metrics [] + :rows nil + :id (data/id :categories) + :db_id (data/id)})]}) + (let [resp ((test-users/user->client :rasta) :get 200 (format "database/%d/metadata" (data/id)))] + (assoc resp :tables (filter #(= "CATEGORIES" (:name %)) (:tables resp))))))) ;;; GET /api/database/:id/autocomplete_suggestions @@ -310,21 +309,19 @@ (defn- suggestions-with-prefix [prefix] ((test-users/user->client :rasta) :get 200 (format "database/%d/autocomplete_suggestions" (data/id)) :prefix prefix)) -(expect - [["USERS" "Table"] - ["USER_ID" "CHECKINS :type/Integer :type/FK"]] - (suggestions-with-prefix "u")) +(deftest succestions-with-prefix + (is (= [["USERS" "Table"] + ["USER_ID" "CHECKINS :type/Integer :type/FK"]] + (suggestions-with-prefix "u"))) -(expect - [["CATEGORIES" "Table"] - ["CHECKINS" "Table"] - ["CATEGORY_ID" "VENUES :type/Integer :type/FK"]] - (suggestions-with-prefix "c")) + (is (= [["CATEGORIES" "Table"] + ["CHECKINS" "Table"] + ["CATEGORY_ID" "VENUES :type/Integer :type/FK"]] + (suggestions-with-prefix "c"))) -(expect - [["CATEGORIES" "Table"] - ["CATEGORY_ID" "VENUES :type/Integer :type/FK"]] - (suggestions-with-prefix "cat")) + (is (= [["CATEGORIES" "Table"] + ["CATEGORY_ID" "VENUES :type/Integer :type/FK"]] + (suggestions-with-prefix "cat")))) ;;; GET /api/database?include_cards=true @@ -361,44 +358,44 @@ :description nil} kvs)) -(tt/expect-with-temp [Card [card (card-with-native-query "Kanye West Quote Views Per Month")]] - (saved-questions-virtual-db - (virtual-table-for-card card)) - (do +(deftest saved-questions-db-is-last-on-list + (tt/with-temp* [Card [card (card-with-native-query "Kanye West Quote Views Per Month")]] ;; run the Card which will populate its result_metadata column - ((test-users/user->client :crowberto) :post 200 (format "card/%d/query" (u/get-id card))) + ((test-users/user->client :crowberto) :post 202 (format "card/%d/query" (u/get-id card))) ;; Now fetch the database list. The 'Saved Questions' DB should be last on the list - (last ((test-users/user->client :crowberto) :get 200 "database" :include_cards true)))) + (is (= (-> card + virtual-table-for-card + saved-questions-virtual-db) + (last ((test-users/user->client :crowberto) :get 200 "database" :include_cards true)))))) ;; Make sure saved questions are NOT included if the setting is disabled -(expect - nil - (tt/with-temp Card [card (card-with-native-query "Kanye West Quote Views Per Month")] - (tu/with-temporary-setting-values [enable-nested-queries false] - ;; run the Card which will populate its result_metadata column - ((test-users/user->client :crowberto) :post 200 (format "card/%d/query" (u/get-id card))) - ;; Now fetch the database list. The 'Saved Questions' DB should NOT be in the list - (some (fn [database] - (when (= (u/get-id database) mbql.s/saved-questions-virtual-database-id) - database)) - ((test-users/user->client :crowberto) :get 200 "database" :include_cards true))))) +(deftest saved-questions-not-inluded-if-setting-disabled + (is (nil? (tt/with-temp Card [card (card-with-native-query "Kanye West Quote Views Per Month")] + (tu/with-temporary-setting-values [enable-nested-queries false] + ;; run the Card which will populate its result_metadata column + ((test-users/user->client :crowberto) :post 202 (format "card/%d/query" (u/get-id card))) + ;; Now fetch the database list. The 'Saved Questions' DB should NOT be in the list + (some (fn [database] + (when (= (u/get-id database) mbql.s/saved-questions-virtual-database-id) + database)) + ((test-users/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 - (virtual-table-for-card coin-card :schema "Coins") - (virtual-table-for-card stamp-card :schema "Stamps")) - (do +(deftest pretend-collections-are-schemas + (tt/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))]] ;; run the Cards which will populate their result_metadata columns (doseq [card [stamp-card coin-card]] - ((test-users/user->client :crowberto) :post 200 (format "card/%d/query" (u/get-id card)))) + ((test-users/user->client :crowberto) :post 202 (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 ((test-users/user->client :crowberto) :get 200 "database" :include_cards true)))) + (is (= (saved-questions-virtual-db + (virtual-table-for-card coin-card :schema "Coins") + (virtual-table-for-card stamp-card :schema "Stamps")) + (last ((test-users/user->client :crowberto) :get 200 "database" :include_cards true)))))) (defn- fetch-virtual-database [] (some #(when (= (:name %) "Saved Questions") @@ -406,76 +403,80 @@ ((test-users/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)) +(deftest remove-cards-with-ambiguous-columns + (tt/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"}])]] + (is (= (-> ok-card + virtual-table-for-card + saved-questions-virtual-db) + (fetch-virtual-database))))) ;; make sure that GET /api/database/include_cards=true removes Cards that belong to a driver that doesn't support ;; nested queries (driver/register! ::no-nested-query-support - :parent :sql-jdbc - :abstract? true) + :parent :sql-jdbc + :abstract? true) (defmethod driver/supports? [::no-nested-query-support :nested-queries] [_ _] false) -(tt/expect-with-temp [Database [bad-db {:engine ::no-nested-query-support, :details {}}] - Card [bad-card {:name "Bad Card" - :dataset_query {:database (u/get-id bad-db) - :type :native - :native {:query "[QUERY GOES HERE]"}} - :result_metadata [{:name "sparrows"}] - :database_id (u/get-id bad-db)}] - Card [ok-card (assoc (card-with-native-query "OK Card") - :result_metadata [{:name "finches"}])]] - (saved-questions-virtual-db - (virtual-table-for-card ok-card)) - (fetch-virtual-database)) +(deftest TODO-name-test-1 + (tt/with-temp* [Database [bad-db {:engine ::no-nested-query-support, :details {}}] + Card [bad-card {:name "Bad Card" + :dataset_query {:database (u/get-id bad-db) + :type :native + :native {:query "[QUERY GOES HERE]"}} + :result_metadata [{:name "sparrows"}] + :database_id (u/get-id bad-db)}] + Card [ok-card (assoc (card-with-native-query "OK Card") + :result_metadata [{:name "finches"}])]] + (is (= (-> ok-card + virtual-table-for-card + saved-questions-virtual-db) + (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"}])) + :source-table (data/id :checkins)) + :result_metadata [{:name "num_toucans"}])) ;; cumulative count -(tt/expect-with-temp [Card [ok-card (ok-mbql-card)] - Card [_ (merge - (data/$ids checkins - (card-with-mbql-query "Cum Count Card" - :source-table $$checkins - :aggregation [[:cum-count]] - :breakout [!month.date])) - {:result_metadata [{:name "num_toucans"}]})]] - (saved-questions-virtual-db - (virtual-table-for-card ok-card)) - (fetch-virtual-database)) - - +(deftest cumulative-count + (tt/with-temp* [Card [ok-card (ok-mbql-card)] + Card [_ (merge + (data/$ids checkins + (card-with-mbql-query "Cum Count Card" + :source-table $$checkins + :aggregation [[:cum-count]] + :breakout [!month.date])) + {:result_metadata [{:name "num_toucans"}]})]] + (is (= (-> ok-card + virtual-table-for-card + saved-questions-virtual-db) + (fetch-virtual-database))))) ;; make sure that GET /api/database/:id/metadata works for the Saved Questions 'virtual' database -(tt/expect-with-temp [Card [card (assoc (card-with-native-query "Birthday Card") - :result_metadata [{:name "age_in_bird_years"}])]] - (saved-questions-virtual-db - (assoc (virtual-table-for-card card) - :fields [{:name "age_in_bird_years" - :table_id (str "card__" (u/get-id card)) - :id ["field-literal" "age_in_bird_years" "type/*"] - :special_type nil - :base_type nil - :default_dimension_option nil - :dimension_options []}])) - ((test-users/user->client :crowberto) :get 200 - (format "database/%d/metadata" mbql.s/saved-questions-virtual-database-id))) +(deftest works-with-saved-questions-virtual-db + (tt/with-temp* [Card [card (assoc (card-with-native-query "Birthday Card") + :result_metadata [{:name "age_in_bird_years"}])]] + (is (= (saved-questions-virtual-db + (assoc (virtual-table-for-card card) + :fields [{:name "age_in_bird_years" + :table_id (str "card__" (u/get-id card)) + :id ["field-literal" "age_in_bird_years" "type/*"] + :special_type nil + :base_type nil + :default_dimension_option nil + :dimension_options []}])) + ((test-users/user->client :crowberto) :get 200 + (format "database/%d/metadata" mbql.s/saved-questions-virtual-database-id)))))) ;; if no eligible Saved Questions exist the virtual DB metadata endpoint should just return `nil` -(expect - nil - ((test-users/user->client :crowberto) :get 200 - (format "database/%d/metadata" mbql.s/saved-questions-virtual-database-id))) +(deftest return-nil-when-no-eligible-saved-questions + (is (nil? ((test-users/user->client :crowberto) :get 200 + (format "database/%d/metadata" mbql.s/saved-questions-virtual-database-id))))) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -495,42 +496,41 @@ :schedule_type "hourly"}) ;; Can we create a NEW database and give it custom schedules? -(expect - {:cache_field_values_schedule "0 0 23 ? * 6L *" - :metadata_sync_schedule "0 0 * * * ? *"} - (let [db-name (tu/random-name)] - (try - (let [db (with-redefs [driver/available? (constantly true)] - ((test-users/user->client :crowberto) :post 200 "database" - {:name db-name - :engine (u/qualified-name ::test-driver) - :details {:db "my_db"} - :schedules {:cache_field_values schedule-map-for-last-friday-at-11pm - :metadata_sync schedule-map-for-hourly}}))] - (db/select-one [Database :cache_field_values_schedule :metadata_sync_schedule] :id (u/get-id db))) - (finally (db/delete! Database :name db-name))))) +(deftest create-new-db-with-custom-schedules + (is (= {:cache_field_values_schedule "0 0 23 ? * 6L *" + :metadata_sync_schedule "0 0 * * * ? *"} + (let [db-name (tu/random-name)] + (try (let [db (with-redefs [driver/available? (constantly true)] + ((test-users/user->client :crowberto) :post 200 "database" + {:name db-name + :engine (u/qualified-name ::test-driver) + :details {:db "my_db"} + :schedules {:cache_field_values schedule-map-for-last-friday-at-11pm + :metadata_sync schedule-map-for-hourly}}))] + (into {} (db/select-one [Database :cache_field_values_schedule :metadata_sync_schedule] :id (u/get-id db)))) + (finally (db/delete! Database :name db-name))))))) ;; Can we UPDATE the schedules for an existing database? -(expect - {:cache_field_values_schedule "0 0 23 ? * 6L *" - :metadata_sync_schedule "0 0 * * * ? *"} - (tt/with-temp Database [db {:engine "h2", :details (:details (data/db))}] - ((test-users/user->client :crowberto) :put 200 (format "database/%d" (u/get-id db)) - (assoc db - :schedules {:cache_field_values schedule-map-for-last-friday-at-11pm - :metadata_sync schedule-map-for-hourly})) - (db/select-one [Database :cache_field_values_schedule :metadata_sync_schedule] :id (u/get-id db)))) +(deftest update-schedules-for-existing-db + (is (= {:cache_field_values_schedule "0 0 23 ? * 6L *" + :metadata_sync_schedule "0 0 * * * ? *"} + (tt/with-temp Database [db {:engine "h2", :details (:details (data/db))}] + ((test-users/user->client :crowberto) :put 200 (format "database/%d" (u/get-id db)) + (assoc db + :schedules {:cache_field_values schedule-map-for-last-friday-at-11pm + :metadata_sync schedule-map-for-hourly})) + (into {} (db/select-one [Database :cache_field_values_schedule :metadata_sync_schedule] :id (u/get-id db))))))) ;; If we FETCH a database will it have the correct 'expanded' schedules? -(expect - {:cache_field_values_schedule "0 0 23 ? * 6L *" - :metadata_sync_schedule "0 0 * * * ? *" - :schedules {:cache_field_values schedule-map-for-last-friday-at-11pm - :metadata_sync schedule-map-for-hourly}} - (tt/with-temp Database [db {:metadata_sync_schedule "0 0 * * * ? *" - :cache_field_values_schedule "0 0 23 ? * 6L *"}] - (-> ((test-users/user->client :crowberto) :get 200 (format "database/%d" (u/get-id db))) - (select-keys [:cache_field_values_schedule :metadata_sync_schedule :schedules])))) +(deftest fetch-db-with-expanded-schedules + (is (= {:cache_field_values_schedule "0 0 23 ? * 6L *" + :metadata_sync_schedule "0 0 * * * ? *" + :schedules {:cache_field_values schedule-map-for-last-friday-at-11pm + :metadata_sync schedule-map-for-hourly}} + (tt/with-temp Database [db {:metadata_sync_schedule "0 0 * * * ? *" + :cache_field_values_schedule "0 0 23 ? * 6L *"}] + (-> ((test-users/user->client :crowberto) :get 200 (format "database/%d" (u/get-id db))) + (select-keys [:cache_field_values_schedule :metadata_sync_schedule :schedules])))))) ;; Five minutes (def ^:private long-timeout (* 5 60 1000)) @@ -541,59 +541,59 @@ (deliver promise-to-deliver true)))) ;; Can we trigger a metadata sync for a DB? -(expect - [true true] - (let [sync-called? (promise) - analyze-called? (promise)] - (tt/with-temp Database [db {:engine "h2", :details (:details (data/db))}] - (with-redefs [sync-metadata/sync-db-metadata! (deliver-when-db sync-called? db) - analyze/analyze-db! (deliver-when-db analyze-called? db)] - ((test-users/user->client :crowberto) :post 200 (format "database/%d/sync_schema" (u/get-id db))) - ;; Block waiting for the promises from sync and analyze to be delivered. Should be delivered instantly, - ;; however if something went wrong, don't hang forever, eventually timeout and fail - [(deref sync-called? long-timeout :sync-never-called) - (deref analyze-called? long-timeout :analyze-never-called)])))) +(deftest trigger-metadata-sync-for-db + (is (= [true true] + (let [sync-called? (promise) + analyze-called? (promise)] + (tt/with-temp Database [db {:engine "h2", :details (:details (data/db))}] + (with-redefs [sync-metadata/sync-db-metadata! (deliver-when-db sync-called? db) + analyze/analyze-db! (deliver-when-db analyze-called? db)] + ((test-users/user->client :crowberto) :post 200 (format "database/%d/sync_schema" (u/get-id db))) + ;; Block waiting for the promises from sync and analyze to be delivered. Should be delivered instantly, + ;; however if something went wrong, don't hang forever, eventually timeout and fail + [(deref sync-called? long-timeout :sync-never-called) + (deref analyze-called? long-timeout :analyze-never-called)])))))) ;; (Non-admins should not be allowed to trigger sync) -(expect - "You don't have permissions to do that." - ((test-users/user->client :rasta) :post 403 (format "database/%d/sync_schema" (data/id)))) +(deftest non-admins-cant-trigger-sync + (is (= "You don't have permissions to do that." + ((test-users/user->client :rasta) :post 403 (format "database/%d/sync_schema" (data/id)))))) ;; Can we RESCAN all the FieldValues for a DB? -(expect - :sync-called - (let [update-field-values-called? (promise)] - (tt/with-temp Database [db {:engine "h2", :details (:details (data/db))}] - (with-redefs [field-values/update-field-values! (fn [synced-db] - (when (= (u/get-id synced-db) (u/get-id db)) - (deliver update-field-values-called? :sync-called)))] - ((test-users/user->client :crowberto) :post 200 (format "database/%d/rescan_values" (u/get-id db))) - (deref update-field-values-called? long-timeout :sync-never-called))))) +(deftest can-rescan-fieldvalues-for-a-db + (is (= :sync-called + (let [update-field-values-called? (promise)] + (tt/with-temp Database [db {:engine "h2", :details (:details (data/db))}] + (with-redefs [field-values/update-field-values! (fn [synced-db] + (when (= (u/get-id synced-db) (u/get-id db)) + (deliver update-field-values-called? :sync-called)))] + ((test-users/user->client :crowberto) :post 200 (format "database/%d/rescan_values" (u/get-id db))) + (deref update-field-values-called? long-timeout :sync-never-called))))))) ;; (Non-admins should not be allowed to trigger re-scan) -(expect - "You don't have permissions to do that." - ((test-users/user->client :rasta) :post 403 (format "database/%d/rescan_values" (data/id)))) +(deftest nonadmins-cant-trigger-rescan + (is (= "You don't have permissions to do that." + ((test-users/user->client :rasta) :post 403 (format "database/%d/rescan_values" (data/id)))))) ;; Can we DISCARD all the FieldValues for a DB? -(expect - {:values-1-still-exists? false - :values-2-still-exists? false} - (tt/with-temp* [Database [db {:engine "h2", :details (:details (data/db))}] - Table [table-1 {:db_id (u/get-id db)}] - Table [table-2 {:db_id (u/get-id db)}] - Field [field-1 {:table_id (u/get-id table-1)}] - Field [field-2 {:table_id (u/get-id table-2)}] - FieldValues [values-1 {:field_id (u/get-id field-1), :values [1 2 3 4]}] - FieldValues [values-2 {:field_id (u/get-id field-2), :values [1 2 3 4]}]] - ((test-users/user->client :crowberto) :post 200 (format "database/%d/discard_values" (u/get-id db))) - {:values-1-still-exists? (db/exists? FieldValues :id (u/get-id values-1)) - :values-2-still-exists? (db/exists? FieldValues :id (u/get-id values-2))})) +(deftest discard-db-fieldvalues + (is (= {:values-1-still-exists? false + :values-2-still-exists? false} + (tt/with-temp* [Database [db {:engine "h2", :details (:details (data/db))}] + Table [table-1 {:db_id (u/get-id db)}] + Table [table-2 {:db_id (u/get-id db)}] + Field [field-1 {:table_id (u/get-id table-1)}] + Field [field-2 {:table_id (u/get-id table-2)}] + FieldValues [values-1 {:field_id (u/get-id field-1), :values [1 2 3 4]}] + FieldValues [values-2 {:field_id (u/get-id field-2), :values [1 2 3 4]}]] + ((test-users/user->client :crowberto) :post 200 (format "database/%d/discard_values" (u/get-id db))) + {:values-1-still-exists? (db/exists? FieldValues :id (u/get-id values-1)) + :values-2-still-exists? (db/exists? FieldValues :id (u/get-id values-2))})))) ;; (Non-admins should not be allowed to discard all FieldValues) -(expect - "You don't have permissions to do that." - ((test-users/user->client :rasta) :post 403 (format "database/%d/discard_values" (data/id)))) +(deftest nonadmins-cant-discard-all-fieldvalues + (is (= "You don't have permissions to do that." + ((test-users/user->client :rasta) :post 403 (format "database/%d/discard_values" (data/id)))))) ;;; Tests for /POST /api/database/validate @@ -607,35 +607,35 @@ nil {:valid false, :message "Error!"})) -(expect - "You don't have permissions to do that." - (with-redefs [database-api/test-database-connection test-database-connection] - ((test-users/user->client :rasta) :post 403 "database/validate" - {:details {:engine :h2, :details (:details (data/db))}}))) - -(expect - (:details (data/db)) - (with-redefs [database-api/test-database-connection test-database-connection] - (#'database-api/test-connection-details "h2" (:details (data/db))))) - -(expect - {:valid true} - (with-redefs [database-api/test-database-connection test-database-connection] - ((test-users/user->client :crowberto) :post 200 "database/validate" - {:details {:engine :h2, :details (:details (data/db))}}))) - -(expect - {:valid false, :message "Error!"} - (with-redefs [database-api/test-database-connection test-database-connection] - (tu.log/suppress-output - (#'database-api/test-connection-details "h2" {:db "ABC"})))) - -(expect - {:valid false} - (with-redefs [database-api/test-database-connection test-database-connection] - (tu.log/suppress-output - ((test-users/user->client :crowberto) :post 200 "database/validate" - {:details {:engine :h2, :details {:db "ABC"}}})))) +(deftest nonadmins-cant-do-something + (is (= "You don't have permissions to do that." + (with-redefs [database-api/test-database-connection test-database-connection] + ((test-users/user->client :rasta) :post 403 "database/validate" + {:details {:engine :h2, :details (:details (data/db))}}))))) + +(deftest gets-details + (is (= (:details (data/db)) + (with-redefs [database-api/test-database-connection test-database-connection] + (#'database-api/test-connection-details "h2" (:details (data/db))))))) + +(deftest TODO-is-valid + (is (= {:valid true} + (with-redefs [database-api/test-database-connection test-database-connection] + ((test-users/user->client :crowberto) :post 200 "database/validate" + {:details {:engine :h2, :details (:details (data/db))}}))))) + +(deftest TODO-rename-is-not-valid + (is (= {:valid false, :message "Error!"} + (with-redefs [database-api/test-database-connection test-database-connection] + (tu.log/suppress-output + (#'database-api/test-connection-details "h2" {:db "ABC"})))))) + +(deftest TODO-rename-is-also-nt-valid + (is (= {:valid false} + (with-redefs [database-api/test-database-connection test-database-connection] + (tu.log/suppress-output + ((test-users/user->client :crowberto) :post 200 "database/validate" + {:details {:engine :h2, :details {:db "ABC"}}})))))) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -643,138 +643,138 @@ ;;; +----------------------------------------------------------------------------------------------------------------+ ;; Tests for GET /api/database/:id/schemas: should work if user has full DB perms... -(expect - ["schema1"] - (tt/with-temp* [Database [{db-id :id}] - Table [_ {:db_id db-id, :schema "schema1"}] - Table [_ {:db_id db-id, :schema "schema1"}]] - ((test-users/user->client :rasta) :get 200 (format "database/%d/schemas" db-id)))) +(deftest get-schemas-if-user-has-full-db-perms + (is (= ["schema1"] + (tt/with-temp* [Database [{db-id :id}] + Table [_ {:db_id db-id, :schema "schema1"}] + Table [_ {:db_id db-id, :schema "schema1"}]] + ((test-users/user->client :rasta) :get 200 (format "database/%d/schemas" db-id)))))) ;; ...or full schema perms... -(expect - ["schema1"] - (tt/with-temp* [Database [{db-id :id}] - Table [_ {:db_id db-id, :schema "schema1"}] - Table [_ {:db_id db-id, :schema "schema1"}]] - (perms/revoke-permissions! (perms-group/all-users) db-id) - (perms/grant-permissions! (perms-group/all-users) db-id "schema1") - ((test-users/user->client :rasta) :get 200 (format "database/%d/schemas" db-id)))) +(deftest get-schema-if-user-has-full-schema-perms + (is (= ["schema1"] + (tt/with-temp* [Database [{db-id :id}] + Table [_ {:db_id db-id, :schema "schema1"}] + Table [_ {:db_id db-id, :schema "schema1"}]] + (perms/revoke-permissions! (perms-group/all-users) db-id) + (perms/grant-permissions! (perms-group/all-users) db-id "schema1") + ((test-users/user->client :rasta) :get 200 (format "database/%d/schemas" db-id)))))) ;; ...or just table read perms... -(expect - ["schema1"] - (tt/with-temp* [Database [{db-id :id}] - Table [t1 {:db_id db-id, :schema "schema1"}] - Table [t2 {:db_id db-id, :schema "schema1"}]] - (perms/revoke-permissions! (perms-group/all-users) db-id) - (perms/grant-permissions! (perms-group/all-users) db-id "schema1" t1) - (perms/grant-permissions! (perms-group/all-users) db-id "schema1" t2) - ((test-users/user->client :rasta) :get 200 (format "database/%d/schemas" db-id)))) +(deftest get-schema-if-user-has-table-read-perms + (is (= ["schema1"] + (tt/with-temp* [Database [{db-id :id}] + Table [t1 {:db_id db-id, :schema "schema1"}] + Table [t2 {:db_id db-id, :schema "schema1"}]] + (perms/revoke-permissions! (perms-group/all-users) db-id) + (perms/grant-permissions! (perms-group/all-users) db-id "schema1" t1) + (perms/grant-permissions! (perms-group/all-users) db-id "schema1" t2) + ((test-users/user->client :rasta) :get 200 (format "database/%d/schemas" db-id)))))) ;; Multiple schemas are ordered by name -(expect - ["schema1" "schema2" "schema3"] - (tt/with-temp* [Database [{db-id :id}] - Table [_ {:db_id db-id, :schema "schema3"}] - Table [_ {:db_id db-id, :schema "schema2"}] - Table [_ {:db_id db-id, :schema "schema1"}]] - ((test-users/user->client :rasta) :get 200 (format "database/%d/schemas" db-id)))) +(deftest multiple-schemas-are-ordered-by-name + (is (= ["schema1" "schema2" "schema3"] + (tt/with-temp* [Database [{db-id :id}] + Table [_ {:db_id db-id, :schema "schema3"}] + Table [_ {:db_id db-id, :schema "schema2"}] + Table [_ {:db_id db-id, :schema "schema1"}]] + ((test-users/user->client :rasta) :get 200 (format "database/%d/schemas" db-id)))))) ;; Can we fetch the Tables in a Schema? (If we have full DB perms) -(expect - ["t1" "t3"] - (tt/with-temp* [Database [{db-id :id}] - Table [_ {:db_id db-id, :schema "schema1", :name "t1"}] - Table [_ {:db_id db-id, :schema "schema2"}] - Table [_ {:db_id db-id, :schema "schema1", :name "t3"}]] - (map :name ((test-users/user->client :rasta) :get 200 (format "database/%d/schema/%s" db-id "schema1"))))) +(deftest can-fetch-tables-in-a-schmea-with-full-db-perms + (is (= ["t1" "t3"] + (tt/with-temp* [Database [{db-id :id}] + Table [_ {:db_id db-id, :schema "schema1", :name "t1"}] + Table [_ {:db_id db-id, :schema "schema2"}] + Table [_ {:db_id db-id, :schema "schema1", :name "t3"}]] + (map :name ((test-users/user->client :rasta) :get 200 (format "database/%d/schema/%s" db-id "schema1"))))))) ;; Can we fetch the Tables in a Schema? (If we have full schema perms) -(expect - ["t1" "t3"] - (tt/with-temp* [Database [{db-id :id}] - Table [_ {:db_id db-id, :schema "schema1", :name "t1"}] - Table [_ {:db_id db-id, :schema "schema2"}] - Table [_ {:db_id db-id, :schema "schema1", :name "t3"}]] - (perms/revoke-permissions! (perms-group/all-users) db-id) - (perms/grant-permissions! (perms-group/all-users) db-id "schema1") - (map :name ((test-users/user->client :rasta) :get 200 (format "database/%d/schema/%s" db-id "schema1"))))) +(deftest can-fetch-tables-in-a-schmea-with-full-schema-perms + (is (= ["t1" "t3"] + (tt/with-temp* [Database [{db-id :id}] + Table [_ {:db_id db-id, :schema "schema1", :name "t1"}] + Table [_ {:db_id db-id, :schema "schema2"}] + Table [_ {:db_id db-id, :schema "schema1", :name "t3"}]] + (perms/revoke-permissions! (perms-group/all-users) db-id) + (perms/grant-permissions! (perms-group/all-users) db-id "schema1") + (map :name ((test-users/user->client :rasta) :get 200 (format "database/%d/schema/%s" db-id "schema1"))))))) ;; Can we fetch the Tables in a Schema? (If we have full Table perms) -(expect - ["t1" "t3"] - (tt/with-temp* [Database [{db-id :id}] - Table [t1 {:db_id db-id, :schema "schema1", :name "t1"}] - Table [_ {:db_id db-id, :schema "schema2"}] - Table [t3 {:db_id db-id, :schema "schema1", :name "t3"}]] - (perms/revoke-permissions! (perms-group/all-users) db-id) - (perms/grant-permissions! (perms-group/all-users) db-id "schema1" t1) - (perms/grant-permissions! (perms-group/all-users) db-id "schema1" t3) - (map :name ((test-users/user->client :rasta) :get 200 (format "database/%d/schema/%s" db-id "schema1"))))) +(deftest can-fetch-tables-in-a-schmea-with-full-table-perms + (is (= ["t1" "t3"] + (tt/with-temp* [Database [{db-id :id}] + Table [t1 {:db_id db-id, :schema "schema1", :name "t1"}] + Table [_ {:db_id db-id, :schema "schema2"}] + Table [t3 {:db_id db-id, :schema "schema1", :name "t3"}]] + (perms/revoke-permissions! (perms-group/all-users) db-id) + (perms/grant-permissions! (perms-group/all-users) db-id "schema1" t1) + (perms/grant-permissions! (perms-group/all-users) db-id "schema1" t3) + (map :name ((test-users/user->client :rasta) :get 200 (format "database/%d/schema/%s" db-id "schema1"))))))) ;; GET /api/database/:id/schemas should return a 403 for a user that doesn't have read permissions -(expect - "You don't have permissions to do that." - (tt/with-temp* [Database [{database-id :id}] - Table [_ {:db_id database-id, :schema "test"}]] - (perms/revoke-permissions! (perms-group/all-users) database-id) - ((test-users/user->client :rasta) :get 403 (format "database/%s/schemas" database-id)))) +(deftest return-403-when-you-aint-got-permission + (is (= "You don't have permissions to do that." + (tt/with-temp* [Database [{database-id :id}] + Table [_ {:db_id database-id, :schema "test"}]] + (perms/revoke-permissions! (perms-group/all-users) database-id) + ((test-users/user->client :rasta) :get 403 (format "database/%s/schemas" database-id)))))) ;; GET /api/database/:id/schemas should exclude schemas for which the user has no perms -(expect - ["schema-with-perms"] - (tt/with-temp* [Database [{database-id :id}] - Table [_ {:db_id database-id, :schema "schema-with-perms"}] - Table [_ {:db_id database-id, :schema "schema-without-perms"}]] - (perms/revoke-permissions! (perms-group/all-users) database-id) - (perms/grant-permissions! (perms-group/all-users) database-id "schema-with-perms") - ((test-users/user->client :rasta) :get 200 (format "database/%s/schemas" database-id)))) +(deftest exclude-schemas-when-user-aint-got-perms-for-them + (is (= ["schema-with-perms"] + (tt/with-temp* [Database [{database-id :id}] + Table [_ {:db_id database-id, :schema "schema-with-perms"}] + Table [_ {:db_id database-id, :schema "schema-without-perms"}]] + (perms/revoke-permissions! (perms-group/all-users) database-id) + (perms/grant-permissions! (perms-group/all-users) database-id "schema-with-perms") + ((test-users/user->client :rasta) :get 200 (format "database/%s/schemas" database-id)))))) ;; GET /api/database/:id/schema/:schema should return a 403 for a user that doesn't have read permissions FOR THE DB... -(expect - "You don't have permissions to do that." - (tt/with-temp* [Database [{database-id :id}] - Table [{table-id :id} {:db_id database-id, :schema "test"}]] - (perms/revoke-permissions! (perms-group/all-users) database-id) - ((test-users/user->client :rasta) :get 403 (format "database/%s/schema/%s" database-id "test")))) +(deftest return-403-when-user-doesnt-have-db-permissions + (is (= "You don't have permissions to do that." + (tt/with-temp* [Database [{database-id :id}] + Table [{table-id :id} {:db_id database-id, :schema "test"}]] + (perms/revoke-permissions! (perms-group/all-users) database-id) + ((test-users/user->client :rasta) :get 403 (format "database/%s/schema/%s" database-id "test")))))) ;; ... or for the SCHEMA -(expect - "You don't have permissions to do that." - (tt/with-temp* [Database [{database-id :id}] - Table [_ {:db_id database-id, :schema "schema-with-perms"}] - Table [_ {:db_id database-id, :schema "schema-without-perms"}]] - (perms/revoke-permissions! (perms-group/all-users) database-id) - (perms/grant-permissions! (perms-group/all-users) database-id "schema-with-perms") - ((test-users/user->client :rasta) :get 403 (format "database/%s/schema/%s" database-id "schema-without-perms")))) +(deftest return-403-when-user-doesnt-have-schema-permissions + (is (= "You don't have permissions to do that." + (tt/with-temp* [Database [{database-id :id}] + Table [_ {:db_id database-id, :schema "schema-with-perms"}] + Table [_ {:db_id database-id, :schema "schema-without-perms"}]] + (perms/revoke-permissions! (perms-group/all-users) database-id) + (perms/grant-permissions! (perms-group/all-users) database-id "schema-with-perms") + ((test-users/user->client :rasta) :get 403 (format "database/%s/schema/%s" database-id "schema-without-perms")))))) ;; Looking for a database that doesn't exist should return a 404 -(expect - "Not found." - ((test-users/user->client :crowberto) :get 404 (format "database/%s/schemas" Integer/MAX_VALUE))) +(deftest return-404-when-no-db + (is (= "Not found." + ((test-users/user->client :crowberto) :get 404 (format "database/%s/schemas" Integer/MAX_VALUE))))) ;; Check that a 404 returns if the schema isn't found -(expect - "Not found." - (tt/with-temp* [Database [{db-id :id}] - Table [{t1-id :id} {:db_id db-id, :schema "schema1"}]] - ((test-users/user->client :crowberto) :get 404 (format "database/%d/schema/%s" db-id "not schema1")))) +(deftest return-404-when-no-schema + (is (= "Not found." + (tt/with-temp* [Database [{db-id :id}] + Table [{t1-id :id} {:db_id db-id, :schema "schema1"}]] + ((test-users/user->client :crowberto) :get 404 (format "database/%d/schema/%s" db-id "not schema1")))))) ;; GET /api/database/:id/schema/:schema should exclude Tables for which the user has no perms -(expect - ["table-with-perms"] - (tt/with-temp* [Database [{database-id :id}] - Table [table-with-perms {:db_id database-id, :schema "public", :name "table-with-perms"}] - Table [_ {:db_id database-id, :schema "public", :name "table-without-perms"}]] - (perms/revoke-permissions! (perms-group/all-users) database-id) - (perms/grant-permissions! (perms-group/all-users) database-id "public" table-with-perms) - (map :name ((test-users/user->client :rasta) :get 200 (format "database/%s/schema/%s" database-id "public"))))) +(deftest db-schema-endpoint-excludes-tables-when-user-has-no-perms + (is (= ["table-with-perms"] + (tt/with-temp* [Database [{database-id :id}] + Table [table-with-perms {:db_id database-id, :schema "public", :name "table-with-perms"}] + Table [_ {:db_id database-id, :schema "public", :name "table-without-perms"}]] + (perms/revoke-permissions! (perms-group/all-users) database-id) + (perms/grant-permissions! (perms-group/all-users) database-id "public" table-with-perms) + (map :name ((test-users/user->client :rasta) :get 200 (format "database/%s/schema/%s" database-id "public"))))))) ;; GET /api/database/:id/schema/:schema should exclude inactive Tables -(expect - ["table"] - (tt/with-temp* [Database [{database-id :id}] - Table [_ {:db_id database-id, :schema "public", :name "table"}] - Table [_ {:db_id database-id, :schema "public", :name "inactive-table", :active false}]] - (map :name ((test-users/user->client :rasta) :get 200 (format "database/%s/schema/%s" database-id "public"))))) +(deftest exclude-inactive-tables + (is (= ["table"] + (tt/with-temp* [Database [{database-id :id}] + Table [_ {:db_id database-id, :schema "public", :name "table"}] + Table [_ {:db_id database-id, :schema "public", :name "inactive-table", :active false}]] + (map :name ((test-users/user->client :rasta) :get 200 (format "database/%s/schema/%s" database-id "public"))))))) diff --git a/test/metabase/api/dataset_test.clj b/test/metabase/api/dataset_test.clj index c4981b3a3de67c0e47d16e38e60b0d3f17600959..934a94fd4e2f4ac8283c3c2688d8b50241825888 100644 --- a/test/metabase/api/dataset_test.clj +++ b/test/metabase/api/dataset_test.clj @@ -6,7 +6,6 @@ [clojure.data.csv :as csv] [clojure.test :refer :all] [dk.ative.docjure.spreadsheet :as spreadsheet] - [expectations :refer [expect]] [medley.core :as m] [metabase [query-processor-test :as qp.test] @@ -60,8 +59,8 @@ (deftest basic-query-test (testing "POST /api/meta/dataset" (testing "Just a basic sanity check to make sure Query Processor endpoint is still working correctly." - (let [result ((test-users/user->client :rasta) :post 200 "dataset" (data/mbql-query checkins - {:aggregation [[:count]]}))] + (let [result ((test-users/user->client :rasta) :post 202 "dataset" (data/mbql-query checkins + {:aggregation [[:count]]}))] (is (= {:data {:rows [[1000]] :cols [(tu/obj->json->obj (qp.test/aggregate-col :count))] :native_form true @@ -70,7 +69,7 @@ :status "completed" :context "ad-hoc" :json_query (-> (data/mbql-query checkins - {:aggregation [[:count]]}) + {:aggregation [[:count]]}) (assoc-in [:query :aggregation] [["count"]]) (assoc :type "query") (merge query-defaults)) @@ -106,11 +105,11 @@ (re-find #"Syntax error in SQL statement") boolean)))) result (tu.log/suppress-output - ((test-users/user->client :rasta) :post 200 "dataset" {:database (data/id) - :type "native" - :native {:query "foobar"}}))] - (is (= {:data {:rows [] - :cols []} + ((test-users/user->client :rasta) :post 202 "dataset" {:database (data/id) + :type "native" + :native {:query "foobar"}}))] + (is (= {:data {:rows [] + :cols []} :row_count 0 :status "failed" :context "ad-hoc" @@ -152,17 +151,17 @@ (defrecord ^:private AnotherNastyClass [^String v]) -(expect - [{"Values" "values"} - {"Values" "Hello XLSX World!"} ; should use the JSON encoding implementation for object - {"Values" "{:v \"No Encoder\"}"} ; fall back to the implementation of `str` for an object if no JSON encoder exists rather than barfing - {"Values" "ABC"}] - (->> (spreadsheet/create-workbook "Results" [["values"] - [(SampleNastyClass. "Hello XLSX World!")] - [(AnotherNastyClass. "No Encoder")] - ["ABC"]]) - (spreadsheet/select-sheet "Results") - (spreadsheet/select-columns {:A "Values"}))) +(deftest export-spreadsheet + (is (= [{"Values" "values"} + {"Values" "Hello XLSX World!"} ; should use the JSON encoding implementation for object + {"Values" "{:v \"No Encoder\"}"} ; fall back to the implementation of `str` for an object if no JSON encoder exists rather than barfing + {"Values" "ABC"}] + (->> (spreadsheet/create-workbook "Results" [["values"] + [(SampleNastyClass. "Hello XLSX World!")] + [(AnotherNastyClass. "No Encoder")] + ["ABC"]]) + (spreadsheet/select-sheet "Results") + (spreadsheet/select-columns {:A "Values"}))))) (defn- parse-and-sort-csv [response] (sort-by @@ -171,93 +170,94 @@ ;; First row is the header (rest (csv/read-csv response)))) -;; Date columns should be emitted without time -(expect - [["1" "2014-04-07" "5" "12"] - ["2" "2014-09-18" "1" "31"] - ["3" "2014-09-15" "8" "56"] - ["4" "2014-03-11" "5" "4"] - ["5" "2013-05-05" "3" "49"]] - (let [result ((test-users/user->client :rasta) :post 200 "dataset/csv" :query - (json/generate-string (data/mbql-query checkins)))] - (take 5 (parse-and-sort-csv result)))) +(deftest date-columns-should-be-emitted-without-time + (is (= [["1" "2014-04-07" "5" "12"] + ["2" "2014-09-18" "1" "31"] + ["3" "2014-09-15" "8" "56"] + ["4" "2014-03-11" "5" "4"] + ["5" "2013-05-05" "3" "49"]] + (let [result ((test-users/user->client :rasta) :post 202 "dataset/csv" :query + (json/generate-string (data/mbql-query checkins)))] + (take 5 (parse-and-sort-csv result)))))) -;; Check an empty date column -(expect - [["1" "2014-04-07" "" "5" "12"] - ["2" "2014-09-18" "" "1" "31"] - ["3" "2014-09-15" "" "8" "56"] - ["4" "2014-03-11" "" "5" "4"] - ["5" "2013-05-05" "" "3" "49"]] - (data/dataset defs/test-data-with-null-date-checkins - (let [result ((test-users/user->client :rasta) :post 200 "dataset/csv" :query - (json/generate-string (data/mbql-query checkins)))] - (take 5 (parse-and-sort-csv result))))) + +(deftest check-an-empty-date-column + (is (= [["1" "2014-04-07" "" "5" "12"] + ["2" "2014-09-18" "" "1" "31"] + ["3" "2014-09-15" "" "8" "56"] + ["4" "2014-03-11" "" "5" "4"] + ["5" "2013-05-05" "" "3" "49"]] + (data/dataset defs/test-data-with-null-date-checkins + (let [result ((test-users/user->client :rasta) :post 202 "dataset/csv" :query + (json/generate-string (data/mbql-query checkins)))] + (take 5 (parse-and-sort-csv result))))))) ;; SQLite doesn't return proper date objects but strings, they just pass through the qp untouched -(expect-with-driver :sqlite - [["1" "2014-04-07" "5" "12"] - ["2" "2014-09-18" "1" "31"] - ["3" "2014-09-15" "8" "56"] - ["4" "2014-03-11" "5" "4"] - ["5" "2013-05-05" "3" "49"]] - (let [result ((test-users/user->client :rasta) :post 200 "dataset/csv" :query - (json/generate-string (data/mbql-query checkins)))] - (take 5 (parse-and-sort-csv result)))) +(expect-with-driver + :sqlite + [["1" "2014-04-07" "5" "12"] + ["2" "2014-09-18" "1" "31"] + ["3" "2014-09-15" "8" "56"] + ["4" "2014-03-11" "5" "4"] + ["5" "2013-05-05" "3" "49"]] + (let [result ((test-users/user->client :rasta) :post 202 "dataset/csv" :query + (json/generate-string (data/mbql-query checkins)))] + (take 5 (parse-and-sort-csv result)))) + -;; DateTime fields are untouched when exported -(expect - [["1" "Plato Yeshua" "2014-04-01T08:30:00Z"] - ["2" "Felipinho Asklepios" "2014-12-05T15:15:00Z"] - ["3" "Kaneonuskatew Eiran" "2014-11-06T16:15:00Z"] - ["4" "Simcha Yan" "2014-01-01T08:30:00Z"] - ["5" "Quentin Sören" "2014-10-03T17:30:00Z"]] - (let [result ((test-users/user->client :rasta) :post 200 "dataset/csv" :query - (json/generate-string (data/mbql-query users)))] - (take 5 (parse-and-sort-csv result)))) +(deftest datetime-fields-are-untouched-when-exported + (is (= [["1" "Plato Yeshua" "2014-04-01T08:30:00Z"] + ["2" "Felipinho Asklepios" "2014-12-05T15:15:00Z"] + ["3" "Kaneonuskatew Eiran" "2014-11-06T16:15:00Z"] + ["4" "Simcha Yan" "2014-01-01T08:30:00Z"] + ["5" "Quentin Sören" "2014-10-03T17:30:00Z"]] + (let [result ((test-users/user->client :rasta) :post 202 "dataset/csv" :query + (json/generate-string (data/mbql-query users)))] + (take 5 (parse-and-sort-csv result)))))) + +(deftest check-that-we-can-export-the-results-of-a-nested-query + (is (= 16 + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :native + :native {:query "SELECT * FROM USERS;"}}}] + (let [result ((test-users/user->client :rasta) :post 202 "dataset/csv" + :query (json/generate-string + {:database mbql.s/saved-questions-virtual-database-id + :type :query + :query {:source-table (str "card__" (u/get-id card))}}))] + (count (csv/read-csv result))))))) -;; Check that we can export the results of a nested query -(expect - 16 - (tt/with-temp Card [card {:dataset_query {:database (data/id) - :type :native - :native {:query "SELECT * FROM USERS;"}}}] - (let [result ((test-users/user->client :rasta) :post 200 "dataset/csv" - :query (json/generate-string - {:database mbql.s/saved-questions-virtual-database-id - :type :query - :query {:source-table (str "card__" (u/get-id card))}}))] - (count (csv/read-csv result))))) ;; POST /api/dataset/:format ;; ;; Downloading CSV/JSON/XLSX results shouldn't be subject to the default query constraints ;; -- even if the query comes in with `add-default-userland-constraints` (as will be the case if the query gets saved ;; from one that had it -- see #9831) -(expect - 101 - (with-redefs [constraints/default-query-constraints {:max-results 10, :max-results-bare-rows 10}] - (let [result ((test-users/user->client :rasta) :post 200 "dataset/csv" - :query (json/generate-string - {:database (data/id) - :type :query - :query {:source-table (data/id :venues)} - :middleware - {:add-default-userland-constraints? true - :userland-query? true}}))] - (count (csv/read-csv result))))) + +(deftest formatted-results-ignore-query-constraints + (is (= 101 + (with-redefs [constraints/default-query-constraints {:max-results 10, :max-results-bare-rows 10}] + (let [result ((test-users/user->client :rasta) :post 202 "dataset/csv" + :query (json/generate-string + {:database (data/id) + :type :query + :query {:source-table (data/id :venues)} + :middleware + {:add-default-userland-constraints? true + :userland-query? true}}))] + (count (csv/read-csv result))))))) ;; non-"download" queries should still get the default constraints ;; (this also is a sanitiy check to make sure the `with-redefs` in the test above actually works) -(expect - 10 - (with-redefs [constraints/default-query-constraints {:max-results 10, :max-results-bare-rows 10}] - (let [{row-count :row_count, :as result} - ((test-users/user->client :rasta) :post 200 "dataset" - {:database (data/id) - :type :query - :query {:source-table (data/id :venues)}})] - (or row-count result)))) +(deftest non--download--queries-should-still-get-the-default-constraints + (is (= 10 + (with-redefs [constraints/default-query-constraints {:max-results 10, :max-results-bare-rows 10}] + (let [{row-count :row_count, :as result} + ((test-users/user->client :rasta) :post 202 "dataset" + {:database (data/id) + :type :query + :query {:source-table (data/id :venues)}})] + (or row-count result)))))) ;; make sure `POST /dataset` calls check user permissions (tu/expect-schema @@ -311,7 +311,7 @@ (is (= {:requested_timezone "US/Pacific" :results_timezone "US/Pacific"} (tu/with-temporary-setting-values [report-timezone "US/Pacific"] - (let [results ((test-users/user->client :rasta) :post 200 "dataset" (data/mbql-query checkins + (let [results ((test-users/user->client :rasta) :post 202 "dataset" (data/mbql-query checkins {:aggregation [[:count]]}))] (-> results :data diff --git a/test/metabase/api/embed_test.clj b/test/metabase/api/embed_test.clj index d500460110c0cebe5bfa2f61f5a1eb0086f8ca6f..f8fe1a4fd22b8d43ebad0dc402a6871c56a6fd4a 100644 --- a/test/metabase/api/embed_test.clj +++ b/test/metabase/api/embed_test.clj @@ -4,8 +4,10 @@ [jwt :as jwt] [util :as buddy-util]] [clj-time.core :as time] + [clojure + [string :as str] + [test :refer :all]] [clojure.data.csv :as csv] - [clojure.string :as str] [crypto.random :as crypto-random] [dk.ative.docjure.spreadsheet :as spreadsheet] [expectations :refer [expect]] @@ -116,57 +118,56 @@ (defn- card-url [card & [additional-token-params]] (str "embed/card/" (card-token card additional-token-params))) -;; it should be possible to use this endpoint successfully if all the conditions are met -(expect - successful-card-info - (with-embedding-enabled-and-new-secret-key - (with-temp-card [card {:enable_embedding true}] - (dissoc-id-and-name - (http/client :get 200 (card-url card)))))) - -;; We should fail when attempting to use an expired token -(expect - #"Token is expired" - (with-embedding-enabled-and-new-secret-key - (with-temp-card [card {:enable_embedding true}] - (http/client :get 400 (card-url card {:exp (buddy-util/to-timestamp yesterday)}))))) - -;; check that the endpoint doesn't work if embedding isn't enabled -(expect - "Embedding is not enabled." - (tu/with-temporary-setting-values [enable-embedding false] - (with-new-secret-key - (with-temp-card [card] - (http/client :get 400 (card-url card)))))) -;; check that if embedding *is* enabled globally but not for the Card the request fails -(expect - "Embedding is not enabled for this object." - (with-embedding-enabled-and-new-secret-key - (with-temp-card [card] - (http/client :get 400 (card-url card))))) +(deftest it-should-be-possible-to-use-this-endpoint-successfully-if-all-the-conditions-are-met + (is (= successful-card-info + (with-embedding-enabled-and-new-secret-key + (with-temp-card [card {:enable_embedding true}] + (dissoc-id-and-name + (http/client :get 200 (card-url card)))))))) + +(deftest we-should-fail-when-attempting-to-use-an-expired-token + (is (re-find #"Token is expired" + (with-embedding-enabled-and-new-secret-key + (with-temp-card [card {:enable_embedding true}] + (http/client :get 400 (card-url card {:exp (buddy-util/to-timestamp yesterday)}))))))) + +(deftest check-that-the-endpoint-doesn-t-work-if-embedding-isn-t-enabled + (is (= "Embedding is not enabled." + (tu/with-temporary-setting-values [enable-embedding false] + (with-new-secret-key + (with-temp-card [card] + (http/client :get 400 (card-url card)))))))) + +(deftest check-that-if-embedding--is--enabled-globally-but-not-for-the-card-the-request-fails + (is (= "Embedding is not enabled for this object." + (with-embedding-enabled-and-new-secret-key + (with-temp-card [card] + (http/client :get 400 (card-url card))))))) ;; check that if embedding is enabled globally and for the object that requests fail if they are signed with the wrong ;; key -(expect - "Message seems corrupt or manipulated." - (with-embedding-enabled-and-new-secret-key - (with-temp-card [card {:enable_embedding true}] - (http/client :get 400 (with-new-secret-key (card-url card)))))) +(deftest global-embedding-requests-fail-with-wrong-key + (is (= "Message seems corrupt or manipulated." + (with-embedding-enabled-and-new-secret-key + (with-temp-card [card {:enable_embedding true}] + (http/client :get 400 (with-new-secret-key (card-url card)))))))) + ;; check that only ENABLED params that ARE NOT PRESENT IN THE JWT come back -(expect - [{:id nil, :type "date/single", :target ["variable" ["template-tag" "d"]], :name "d", :slug "d", :default nil}] - (with-embedding-enabled-and-new-secret-key - (with-temp-card [card {:enable_embedding true - :dataset_query {:database (data/id) - :type :native - :native {:template-tags {:a {:type "date", :name "a", :display_name "a"} - :b {:type "date", :name "b", :display_name "b"} - :c {:type "date", :name "c", :display_name "c"} - :d {:type "date", :name "d", :display_name "d"}}}} - :embedding_params {:a "locked", :b "disabled", :c "enabled", :d "enabled"}}] - (:parameters (http/client :get 200 (card-url card {:params {:c 100}})))))) +(deftest check-that-only-enabled-params-that-are-not-present-in-the-jwt-come-back + (is (= [{:id nil, :type "date/single", :target ["variable" ["template-tag" "d"]], :name "d", :slug "d", :default nil}] + (with-embedding-enabled-and-new-secret-key + (with-temp-card [card {:enable_embedding true + :dataset_query {:database (data/id) + :type :native + :native {:template-tags {:a {:type "date", :name "a", :display_name "a"} + :b {:type "date", :name "b", :display_name "b"} + :c {:type "date", :name "c", :display_name "c"} + :d {:type "date", :name "d", :display_name "d"}}}} + :embedding_params {:a "locked", :b "disabled", :c "enabled", :d "enabled"}}] + (:parameters (http/client :get 200 (card-url card {:params {:c 100}})))))))) + ;;; ------------------------- GET /api/embed/card/:token/query (and JSON/CSV/XLSX variants) -------------------------- @@ -194,7 +195,7 @@ (successful-query-results response-format) (with-embedding-enabled-and-new-secret-key (with-temp-card [card {:enable_embedding true}] - (http/client :get 200 (card-query-url card response-format) request-options)))) + (http/client :get 202 (card-query-url card response-format) request-options)))) ;; but if the card has an invalid query we should just get a generic "query failed" exception (rather than leaking ;; query info) @@ -208,8 +209,9 @@ :type :native :native {:query "SELECT * FROM XYZ"}}}] ;; since results are keepalive-streamed for normal queries (i.e., not CSV, JSON, or XLSX) we have to return a - ;; status code right away, so streaming responses always return 200 - (http/client :get (if (seq response-format) 400 200) (card-query-url card response-format)))))) + ;; status code right away, so streaming responses always return 202 and the actual status code is returned + ;; in the _status property of the response body + (http/client :get (if (seq response-format) 400 202) (card-query-url card response-format)))))) ;; check that the endpoint doesn't work if embedding isn't enabled (expect-for-response-formats [response-format] @@ -237,35 +239,34 @@ ;; Downloading CSV/JSON/XLSX results shouldn't be subject to the default query constraints ;; -- even if the query comes in with `add-default-userland-constraints` (as will be the case if the query gets saved ;; from one that had it -- see #9831 and #10399) -(expect - 101 - (with-redefs [constraints/default-query-constraints {:max-results 10, :max-results-bare-rows 10}] - (with-embedding-enabled-and-new-secret-key - (with-temp-card [card {:enable_embedding true - :dataset_query (assoc (data/mbql-query venues) - :middleware - {:add-default-userland-constraints? true - :userland-query? true})}] - (let [results (http/client :get 200 (card-query-url card "/csv"))] - (count (csv/read-csv results))))))) - +(deftest download-formatted-without-constraints + (is (= 101 + (with-redefs [constraints/default-query-constraints {:max-results 10, :max-results-bare-rows 10}] + (with-embedding-enabled-and-new-secret-key + (with-temp-card [card {:enable_embedding true + :dataset_query (assoc (data/mbql-query venues) + :middleware + {:add-default-userland-constraints? true + :userland-query? true})}] + (let [results (http/client :get 202 (card-query-url card "/csv"))] + (count (csv/read-csv results))))))))) ;;; LOCKED params ;; check that if embedding is enabled globally and for the object requests fail if the token is missing a `:locked` ;; parameter (expect-for-response-formats [response-format] - "You must specify a value for :abc in the JWT." - (with-embedding-enabled-and-new-secret-key - (with-temp-card [card {:enable_embedding true, :embedding_params {:abc "locked"}}] - (http/client :get 400 (card-query-url card response-format))))) + "You must specify a value for :abc in the JWT." + (with-embedding-enabled-and-new-secret-key + (with-temp-card [card {:enable_embedding true, :embedding_params {:abc "locked"}}] + (http/client :get 400 (card-query-url card response-format))))) ;; if `:locked` param is present, request should succeed (expect-for-response-formats [response-format request-options] (successful-query-results response-format) (with-embedding-enabled-and-new-secret-key (with-temp-card [card {:enable_embedding true, :embedding_params {:abc "locked"}}] - (http/client :get 200 (card-query-url card response-format {:params {:abc 100}}) request-options)))) + (http/client :get 202 (card-query-url card response-format {:params {:abc 100}}) request-options)))) ;; If `:locked` parameter is present in URL params, request should fail (expect-for-response-formats [response-format] @@ -305,14 +306,14 @@ (successful-query-results response-format) (with-embedding-enabled-and-new-secret-key (with-temp-card [card {:enable_embedding true, :embedding_params {:abc "enabled"}}] - (http/client :get 200 (card-query-url card response-format {:params {:abc "enabled"}}) request-options)))) + (http/client :get 202 (card-query-url card response-format {:params {:abc "enabled"}}) request-options)))) ;; If an `:enabled` param is present in URL params but *not* the JWT, that's ok (expect-for-response-formats [response-format request-options] (successful-query-results response-format) (with-embedding-enabled-and-new-secret-key (with-temp-card [card {:enable_embedding true, :embedding_params {:abc "enabled"}}] - (http/client :get 200 (str (card-query-url card response-format) "?abc=200") request-options)))) + (http/client :get 202 (str (card-query-url card response-format) "?abc=200") request-options)))) ;; make sure CSV (etc.) downloads take editable params into account (#6407) @@ -329,78 +330,74 @@ :enable_embedding true :embedding_params {:date :enabled}}) -(expect - "count\n107\n" - (with-embedding-enabled-and-new-secret-key - (tt/with-temp Card [card (card-with-date-field-filter)] - (http/client :get 200 (str (card-query-url card "/csv") "?date=Q1-2014"))))) +(deftest csv-reports-count + (is (= "count\n107\n" + (with-embedding-enabled-and-new-secret-key + (tt/with-temp Card [card (card-with-date-field-filter)] + (http/client :get 202 (str (card-query-url card "/csv") "?date=Q1-2014"))))))) -;; make sure it also works with the forwarded URL -(expect - "count\n107\n" - (with-embedding-enabled-and-new-secret-key - (tt/with-temp Card [card (card-with-date-field-filter)] - ;; make sure the URL doesn't include /api/ at the beginning like it normally would - (binding [http/*url-prefix* (str/replace http/*url-prefix* #"/api/$" "/")] - (tu/with-temporary-setting-values [site-url http/*url-prefix*] - (http/client :get 200 (str "embed/question/" (card-token card) ".csv?date=Q1-2014"))))))) +(deftest make-sure-it-also-works-with-the-forwarded-url + (is (= "count\n107\n" + (with-embedding-enabled-and-new-secret-key + (tt/with-temp Card [card (card-with-date-field-filter)] + ;; make sure the URL doesn't include /api/ at the beginning like it normally would + (binding [http/*url-prefix* (str/replace http/*url-prefix* #"/api/$" "/")] + (tu/with-temporary-setting-values [site-url http/*url-prefix*] + (http/client :get 202 (str "embed/question/" (card-token card) ".csv?date=Q1-2014"))))))))) ;;; ---------------------------------------- GET /api/embed/dashboard/:token ----------------------------------------- (defn- dashboard-url [dashboard & [additional-token-params]] (str "embed/dashboard/" (dash-token dashboard additional-token-params))) -;; it should be possible to call this endpoint successfully -(expect - successful-dashboard-info - (with-embedding-enabled-and-new-secret-key - (tt/with-temp Dashboard [dash {:enable_embedding true}] - (dissoc-id-and-name - (http/client :get 200 (dashboard-url dash)))))) - -;; We should fail when attempting to use an expired token -(expect - #"Token is expired" - (with-embedding-enabled-and-new-secret-key - (tt/with-temp Dashboard [dash {:enable_embedding true}] - (http/client :get 400 (dashboard-url dash {:exp (buddy-util/to-timestamp yesterday)}))))) - -;; check that the endpoint doesn't work if embedding isn't enabled -(expect - "Embedding is not enabled." - (tu/with-temporary-setting-values [enable-embedding false] - (with-new-secret-key - (tt/with-temp Dashboard [dash] - (http/client :get 400 (dashboard-url dash)))))) -;; check that if embedding *is* enabled globally but not for the Dashboard the request fails -(expect - "Embedding is not enabled for this object." - (with-embedding-enabled-and-new-secret-key - (tt/with-temp Dashboard [dash] - (http/client :get 400 (dashboard-url dash))))) +(deftest it-should-be-possible-to-call-this-endpoint-successfully + (is (= successful-dashboard-info + (with-embedding-enabled-and-new-secret-key + (tt/with-temp Dashboard [dash {:enable_embedding true}] + (dissoc-id-and-name + (http/client :get 200 (dashboard-url dash)))))))) + +(deftest we-should-fail-when-attempting-to-use-an-expired-token + (is (re-find #"Token is expired" + (with-embedding-enabled-and-new-secret-key + (tt/with-temp Dashboard [dash {:enable_embedding true}] + (http/client :get 400 (dashboard-url dash {:exp (buddy-util/to-timestamp yesterday)}))))))) + +(deftest check-that-the-dashboard-endpoint-doesn-t-work-if-embedding-isn-t-enabled + (is (= "Embedding is not enabled." + (tu/with-temporary-setting-values [enable-embedding false] + (with-new-secret-key + (tt/with-temp Dashboard [dash] + (http/client :get 400 (dashboard-url dash)))))))) + +(deftest check-that-if-embedding--is--enabled-globally-but-not-for-the-dashboard-the-request-fails + (is (= "Embedding is not enabled for this object." + (with-embedding-enabled-and-new-secret-key + (tt/with-temp Dashboard [dash] + (http/client :get 400 (dashboard-url dash))))))) ;; check that if embedding is enabled globally and for the object that requests fail if they are signed with the wrong ;; key -(expect - "Message seems corrupt or manipulated." - (with-embedding-enabled-and-new-secret-key - (tt/with-temp Dashboard [dash {:enable_embedding true}] - (http/client :get 400 (with-new-secret-key (dashboard-url dash)))))) +(deftest global-embedding-check-key + (is (= "Message seems corrupt or manipulated." + (with-embedding-enabled-and-new-secret-key + (tt/with-temp Dashboard [dash {:enable_embedding true}] + (http/client :get 400 (with-new-secret-key (dashboard-url dash)))))))) -;; check that only ENABLED params that ARE NOT PRESENT IN THE JWT come back -(expect - [{:slug "d", :name "d", :type "date"}] - (with-embedding-enabled-and-new-secret-key - (tt/with-temp Dashboard [dash {:enable_embedding true - :embedding_params {:a "locked", :b "disabled", :c "enabled", :d "enabled"} - :parameters [{:slug "a", :name "a", :type "date"} - {:slug "b", :name "b", :type "date"} - {:slug "c", :name "c", :type "date"} - {:slug "d", :name "d", :type "date"}]}] - (:parameters (http/client :get 200 (dashboard-url dash {:params {:c 100}})))))) +;; check that only ENABLED params that ARE NOT PRESENT IN THE JWT come back +(deftest only-enabled-params-that-are-not-present-in-the-jwt-come-back + (is (= [{:slug "d", :name "d", :type "date"}] + (with-embedding-enabled-and-new-secret-key + (tt/with-temp Dashboard [dash {:enable_embedding true + :embedding_params {:a "locked", :b "disabled", :c "enabled", :d "enabled"} + :parameters [{:slug "a", :name "a", :type "date"} + {:slug "b", :name "b", :type "date"} + {:slug "c", :name "c", :type "date"} + {:slug "d", :name "d", :type "date"}]}] + (:parameters (http/client :get 200 (dashboard-url dash {:params {:c 100}})))))))) ;;; ---------------------- GET /api/embed/dashboard/:token/dashcard/:dashcard-id/card/:card-id ----------------------- @@ -410,145 +407,151 @@ "/card/" (:card_id dashcard))) ;; it should be possible to run a Card successfully if you jump through the right hoops... -(expect - (successful-query-results) - (with-embedding-enabled-and-new-secret-key - (with-temp-dashcard [dashcard {:dash {:enable_embedding true}}] - (http/client :get 200 (dashcard-url dashcard))))) +(deftest it-should-be-possible-to-run-a-card-successfully-if-you-jump-through-the-right-hoops--- + (is (= (successful-query-results) + (with-embedding-enabled-and-new-secret-key + (with-temp-dashcard [dashcard {:dash {:enable_embedding true}}] + (http/client :get 202 (dashcard-url dashcard))))))) + ;; Downloading CSV/JSON/XLSX results from the dashcard endpoint shouldn't be subject to the default query constraints ;; (#10399) -(expect - 101 - (with-redefs [constraints/default-query-constraints {:max-results 10, :max-results-bare-rows 10}] - (with-embedding-enabled-and-new-secret-key - (with-temp-dashcard [dashcard {:dash {:enable_embedding true} - :card {:dataset_query (assoc (data/mbql-query venues) - :middleware - {:add-default-userland-constraints? true - :userland-query? true})}}] - (let [results (http/client :get 200 (str (dashcard-url dashcard) "/csv"))] - (count (csv/read-csv results))))))) +(deftest downloading-csv-json-xlsx-results-from-the-dashcard-endpoint-shouldn-t-be-subject-to-the-default-query-constraints + (is (= 101 + (with-redefs [constraints/default-query-constraints {:max-results 10, :max-results-bare-rows 10}] + (with-embedding-enabled-and-new-secret-key + (with-temp-dashcard [dashcard {:dash {:enable_embedding true} + :card {:dataset_query (assoc (data/mbql-query venues) + :middleware + {:add-default-userland-constraints? true + :userland-query? true})}}] + (let [results (http/client :get 202 (str (dashcard-url dashcard) "/csv"))] + (count (csv/read-csv results))))))))) ;; but if the card has an invalid query we should just get a generic "query failed" exception (rather than leaking ;; query info) -(expect - {:status "failed" - :error "An error occurred while running the query."} - (tu.log/suppress-output - (with-embedding-enabled-and-new-secret-key - (with-temp-dashcard [dashcard {:dash {:enable_embedding true} - :card {:dataset_query (data/native-query {:query "SELECT * FROM XYZ"})}}] - (http/client :get 200 (dashcard-url dashcard)))))) - -;; check that the endpoint doesn't work if embedding isn't enabled -(expect - "Embedding is not enabled." - (tu/with-temporary-setting-values [enable-embedding false] - (with-new-secret-key - (with-temp-dashcard [dashcard] - (http/client :get 400 (dashcard-url dashcard)))))) -;; check that if embedding *is* enabled globally but not for the Dashboard the request fails -(expect - "Embedding is not enabled for this object." - (with-embedding-enabled-and-new-secret-key - (with-temp-dashcard [dashcard] - (http/client :get 400 (dashcard-url dashcard))))) +(deftest generic-query-failed-exception + (is (= {:status "failed" + :error "An error occurred while running the query."} + (tu.log/suppress-output + (with-embedding-enabled-and-new-secret-key + (with-temp-dashcard [dashcard {:dash {:enable_embedding true} + :card {:dataset_query (data/native-query {:query "SELECT * FROM XYZ"})}}] + (http/client :get 202 (dashcard-url dashcard)))))))) + + +(deftest check-that-the-dashcard-endpoint-doesn-t-work-if-embedding-isn-t-enabled + (is (= "Embedding is not enabled." + (tu/with-temporary-setting-values [enable-embedding false] + (with-new-secret-key + (with-temp-dashcard [dashcard] + (http/client :get 400 (dashcard-url dashcard)))))))) + +(deftest dashcard-check-that-if-embedding--is--enabled-globally-but-not-for-the-dashboard-the-request-fails + (is (= "Embedding is not enabled for this object." + (with-embedding-enabled-and-new-secret-key + (with-temp-dashcard [dashcard] + (http/client :get 400 (dashcard-url dashcard))))))) ;; check that if embedding is enabled globally and for the object that requests fail if they are signed with the wrong ;; key -(expect - "Message seems corrupt or manipulated." - (with-embedding-enabled-and-new-secret-key - (with-temp-dashcard [dashcard {:dash {:enable_embedding true}}] - (http/client :get 400 (with-new-secret-key (dashcard-url dashcard)))))) + +(deftest dashcard-global-embedding-check-key + (is (= "Message seems corrupt or manipulated." + (with-embedding-enabled-and-new-secret-key + (with-temp-dashcard [dashcard {:dash {:enable_embedding true}}] + (http/client :get 400 (with-new-secret-key (dashcard-url dashcard)))))))) ;;; LOCKED params ;; check that if embedding is enabled globally and for the object requests fail if the token is missing a `:locked` ;; parameter -(expect - "You must specify a value for :abc in the JWT." - (with-embedding-enabled-and-new-secret-key - (with-temp-dashcard [dashcard {:dash {:enable_embedding true, :embedding_params {:abc "locked"}}}] - (http/client :get 400 (dashcard-url dashcard))))) + +(deftest check-missing-locked-param + (is (= "You must specify a value for :abc in the JWT." + (with-embedding-enabled-and-new-secret-key + (with-temp-dashcard [dashcard {:dash {:enable_embedding true, :embedding_params {:abc "locked"}}}] + (http/client :get 400 (dashcard-url dashcard))))))) ;; if `:locked` param is supplied, request should succeed -(expect - (successful-query-results) - (with-embedding-enabled-and-new-secret-key - (with-temp-dashcard [dashcard {:dash {:enable_embedding true, :embedding_params {:abc "locked"}}}] - (http/client :get 200 (dashcard-url dashcard {:params {:abc 100}}))))) +(deftest if---locked--param-is-supplied--request-should-succeed + (is (= (successful-query-results) + (with-embedding-enabled-and-new-secret-key + (with-temp-dashcard [dashcard {:dash {:enable_embedding true, :embedding_params {:abc "locked"}}}] + (http/client :get 202 (dashcard-url dashcard {:params {:abc 100}}))))))) + ;; if `:locked` parameter is present in URL params, request should fail -(expect - "You must specify a value for :abc in the JWT." - (with-embedding-enabled-and-new-secret-key - (with-temp-dashcard [dashcard {:dash {:enable_embedding true, :embedding_params {:abc "locked"}}}] - (http/client :get 400 (str (dashcard-url dashcard) "?abc=100"))))) +(deftest if---locked--parameter-is-present-in-url-params--request-should-fail + (is (= "You must specify a value for :abc in the JWT." + (with-embedding-enabled-and-new-secret-key + (with-temp-dashcard [dashcard {:dash {:enable_embedding true, :embedding_params {:abc "locked"}}}] + (http/client :get 400 (str (dashcard-url dashcard) "?abc=100"))))))) + ;;; DISABLED params ;; check that if embedding is enabled globally and for the object requests fail if they pass a `:disabled` parameter -(expect - "You're not allowed to specify a value for :abc." - (with-embedding-enabled-and-new-secret-key - (with-temp-dashcard [dashcard {:dash {:enable_embedding true, :embedding_params {:abc "disabled"}}}] - (http/client :get 400 (dashcard-url dashcard {:params {:abc 100}}))))) +(deftest check-that-if-embedding-is-enabled-globally-and-for-the-object-requests-fail-if-they-pass-a---disabled--parameter + (is (= "You're not allowed to specify a value for :abc." + (with-embedding-enabled-and-new-secret-key + (with-temp-dashcard [dashcard {:dash {:enable_embedding true, :embedding_params {:abc "disabled"}}}] + (http/client :get 400 (dashcard-url dashcard {:params {:abc 100}}))))))) ;; If a `:disabled` param is passed in the URL the request should fail -(expect - "You're not allowed to specify a value for :abc." - (with-embedding-enabled-and-new-secret-key - (with-temp-dashcard [dashcard {:dash {:enable_embedding true, :embedding_params {:abc "disabled"}}}] - (http/client :get 400 (str (dashcard-url dashcard) "?abc=200"))))) +(deftest if-a---disabled--param-is-passed-in-the-url-the-request-should-fail + (is (= "You're not allowed to specify a value for :abc." + (with-embedding-enabled-and-new-secret-key + (with-temp-dashcard [dashcard {:dash {:enable_embedding true, :embedding_params {:abc "disabled"}}}] + (http/client :get 400 (str (dashcard-url dashcard) "?abc=200"))))))) + ;;; ENABLED params ;; If `:enabled` param is present in both JWT and the URL, the request should fail -(expect - "You can't specify a value for :abc if it's already set in the JWT." - (with-embedding-enabled-and-new-secret-key - (with-temp-dashcard [dashcard {:dash {:enable_embedding true, :embedding_params {:abc "enabled"}}}] - (http/client :get 400 (str (dashcard-url dashcard {:params {:abc 100}}) "?abc=200"))))) +(deftest if---enabled--param-is-present-in-both-jwt-and-the-url--the-request-should-fail + (is (= "You can't specify a value for :abc if it's already set in the JWT." + (with-embedding-enabled-and-new-secret-key + (with-temp-dashcard [dashcard {:dash {:enable_embedding true, :embedding_params {:abc "enabled"}}}] + (http/client :get 400 (str (dashcard-url dashcard {:params {:abc 100}}) "?abc=200"))))))) ;; If an `:enabled` param is present in the JWT, that's ok -(expect - (successful-query-results) - (with-embedding-enabled-and-new-secret-key - (with-temp-dashcard [dashcard {:dash {:enable_embedding true, :embedding_params {:abc "enabled"}}}] - (http/client :get 200 (dashcard-url dashcard {:params {:abc 100}}))))) +(deftest if-an---enabled--param-is-present-in-the-jwt--that-s-ok + (is (= (successful-query-results) + (with-embedding-enabled-and-new-secret-key + (with-temp-dashcard [dashcard {:dash {:enable_embedding true, :embedding_params {:abc "enabled"}}}] + (http/client :get 202 (dashcard-url dashcard {:params {:abc 100}}))))))) ;; If an `:enabled` param is present in URL params but *not* the JWT, that's ok -(expect - (successful-query-results) - (with-embedding-enabled-and-new-secret-key - (with-temp-dashcard [dashcard {:dash {:enable_embedding true, :embedding_params {:abc "enabled"}}}] - (http/client :get 200 (str (dashcard-url dashcard) "?abc=200"))))) - +(deftest if-an---enabled--param-is-present-in-url-params-but--not--the-jwt--that-s-ok + (is (= (successful-query-results) + (with-embedding-enabled-and-new-secret-key + (with-temp-dashcard [dashcard {:dash {:enable_embedding true, :embedding_params {:abc "enabled"}}}] + (http/client :get 202 (str (dashcard-url dashcard) "?abc=200"))))))) ;;; -------------------------------------------------- Other Tests --------------------------------------------------- ;; parameters that are not in the `embedding-params` map at all should get removed by ;; `remove-locked-and-disabled-params` -(expect - {:parameters []} - (#'embed-api/remove-locked-and-disabled-params {:parameters {:slug "foo"}} {})) -;; make sure that multiline series word as expected (#4768) -(expect - "completed" - (with-embedding-enabled-and-new-secret-key - (tt/with-temp Card [series-card {:dataset_query {:database (data/id) - :type :query - :query {:source-table (data/id :venues)}}}] - (with-temp-dashcard [dashcard {:dash {:enable_embedding true}}] - (tt/with-temp DashboardCardSeries [series {:dashboardcard_id (u/get-id dashcard) - :card_id (u/get-id series-card) - :position 0}] - (:status (http/client :get 200 (str (dashcard-url (assoc dashcard :card_id (u/get-id series-card))))))))))) +(deftest emove-embedding-params + (is (= {:parameters []} + (#'embed-api/remove-locked-and-disabled-params {:parameters {:slug "foo"}} {})))) + +;; make sure that multiline series word as expected (#4768) +(deftest make-sure-that-multiline-series-word-as-expected---4768- + (is (= "completed" + (with-embedding-enabled-and-new-secret-key + (tt/with-temp Card [series-card {:dataset_query {:database (data/id) + :type :query + :query {:source-table (data/id :venues)}}}] + (with-temp-dashcard [dashcard {:dash {:enable_embedding true}}] + (tt/with-temp DashboardCardSeries [series {:dashboardcard_id (u/get-id dashcard) + :card_id (u/get-id series-card) + :position 0}] + (:status (http/client :get 202 (str (dashcard-url (assoc dashcard :card_id (u/get-id series-card))))))))))))) ;;; ------------------------------- GET /api/embed/card/:token/field/:field-id/values -------------------------------- @@ -576,36 +579,35 @@ ~@body))) ;; should be able to fetch values for a Field referenced by a public Card -(expect - {:values [["20th Century Cafe"] - ["25°"] - ["33 Taps"] - ["800 Degrees Neapolitan Pizzeria"] - ["BCD Tofu House"]] - :field_id (data/id :venues :name)} - (with-embedding-enabled-and-temp-card-referencing :venues :name [card] - (-> (http/client :get 200 (field-values-url card (data/id :venues :name))) - (update :values (partial take 5))))) +(deftest should-be-able-to-fetch-values-for-a-field-referenced-by-a-public-card + (is (= {:values [["20th Century Cafe"] + ["25°"] + ["33 Taps"] + ["800 Degrees Neapolitan Pizzeria"] + ["BCD Tofu House"]] + :field_id (data/id :venues :name)} + (with-embedding-enabled-and-temp-card-referencing :venues :name [card] + (-> (http/client :get 200 (field-values-url card (data/id :venues :name))) + (update :values (partial take 5))))))) ;; but for Fields that are not referenced we should get an Exception -(expect - "Not found." - (with-embedding-enabled-and-temp-card-referencing :venues :name [card] - (http/client :get 400 (field-values-url card (data/id :venues :price))))) +(deftest but-for-fields-that-are-not-referenced-we-should-get-an-exception + (is (= "Not found." + (with-embedding-enabled-and-temp-card-referencing :venues :name [card] + (http/client :get 400 (field-values-url card (data/id :venues :price))))))) ;; Endpoint should fail if embedding is disabled -(expect - "Embedding is not enabled." - (with-embedding-enabled-and-temp-card-referencing :venues :name [card] - (tu/with-temporary-setting-values [enable-embedding false] - (http/client :get 400 (field-values-url card (data/id :venues :name)))))) - -(expect - "Embedding is not enabled for this object." - (with-embedding-enabled-and-temp-card-referencing :venues :name [card] - (db/update! Card (u/get-id card) :enable_embedding false) - (http/client :get 400 (field-values-url card (data/id :venues :name))))) - +(deftest endpoint-should-fail-if-embedding-is-disabled + (is (= "Embedding is not enabled." + (with-embedding-enabled-and-temp-card-referencing :venues :name [card] + (tu/with-temporary-setting-values [enable-embedding false] + (http/client :get 400 (field-values-url card (data/id :venues :name)))))))) + +(deftest embedding-not-enabled-message + (is (= "Embedding is not enabled for this object." + (with-embedding-enabled-and-temp-card-referencing :venues :name [card] + (db/update! Card (u/get-id card) :enable_embedding false) + (http/client :get 400 (field-values-url card (data/id :venues :name))))))) ;;; ----------------------------- GET /api/embed/dashboard/:token/field/:field-id/values ----------------------------- @@ -630,36 +632,38 @@ ~@body))) ;; should be able to use it when everything is g2g -(expect - {:values [["20th Century Cafe"] - ["25°"] - ["33 Taps"] - ["800 Degrees Neapolitan Pizzeria"] - ["BCD Tofu House"]] - :field_id (data/id :venues :name)} - (with-embedding-enabled-and-temp-dashcard-referencing :venues :name [dashboard] - (-> (http/client :get 200 (field-values-url dashboard (data/id :venues :name))) - (update :values (partial take 5))))) +(deftest should-be-able-to-use-it-when-everything-is-g2g + (is (= {:values [["20th Century Cafe"] + ["25°"] + ["33 Taps"] + ["800 Degrees Neapolitan Pizzeria"] + ["BCD Tofu House"]] + :field_id (data/id :venues :name)} + (with-embedding-enabled-and-temp-dashcard-referencing :venues :name [dashboard] + (-> (http/client :get 200 (field-values-url dashboard (data/id :venues :name))) + (update :values (partial take 5))))))) ;; shound NOT be able to use the endpoint with a Field not referenced by the Dashboard -(expect - "Not found." - (with-embedding-enabled-and-temp-dashcard-referencing :venues :name [dashboard] - (http/client :get 400 (field-values-url dashboard (data/id :venues :price))))) +(deftest shound-not-be-able-to-use-the-endpoint-with-a-field-not-referenced-by-the-dashboard + (is (= "Not found." + (with-embedding-enabled-and-temp-dashcard-referencing :venues :name [dashboard] + (http/client :get 400 (field-values-url dashboard (data/id :venues :price))))))) ;; Endpoint should fail if embedding is disabled -(expect - "Embedding is not enabled." - (with-embedding-enabled-and-temp-dashcard-referencing :venues :name [dashboard] - (tu/with-temporary-setting-values [enable-embedding false] - (http/client :get 400 (field-values-url dashboard (data/id :venues :name)))))) +(deftest field-values-endpoint-should-fail-if-embedding-is-disabled + (is (= "Embedding is not enabled." + (with-embedding-enabled-and-temp-dashcard-referencing :venues :name [dashboard] + (tu/with-temporary-setting-values [enable-embedding false] + (http/client :get 400 (field-values-url dashboard (data/id :venues :name)))))))) + ;; Endpoint should fail if embedding is disabled for the Dashboard -(expect - "Embedding is not enabled for this object." - (with-embedding-enabled-and-temp-dashcard-referencing :venues :name [dashboard] - (db/update! Dashboard (u/get-id dashboard) :enable_embedding false) - (http/client :get 400 (field-values-url dashboard (data/id :venues :name))))) +(deftest endpoint-should-fail-if-embedding-is-disabled-for-the-dashboard + (is (= "Embedding is not enabled for this object." + (with-embedding-enabled-and-temp-dashcard-referencing :venues :name [dashboard] + (db/update! Dashboard (u/get-id dashboard) :enable_embedding false) + (http/client :get 400 (field-values-url dashboard (data/id :venues :name))))))) + ;;; ----------------------- GET /api/embed/card/:token/field/:field-id/search/:search-field-id ----------------------- diff --git a/test/metabase/api/preview_embed_test.clj b/test/metabase/api/preview_embed_test.clj index 2a0f4dd9ac32300146918d6c2aeeaa41f457bb6c..03b48179c79ed6bc404ae15251f3350cbb528e1a 100644 --- a/test/metabase/api/preview_embed_test.clj +++ b/test/metabase/api/preview_embed_test.clj @@ -79,7 +79,7 @@ (embed-test/successful-query-results) (embed-test/with-embedding-enabled-and-new-secret-key (embed-test/with-temp-card [card] - ((test-users/user->client :crowberto) :get 200 (card-query-url card))))) + ((test-users/user->client :crowberto) :get 202 (card-query-url card))))) ;; ...but if the user is not an admin this endpoint should fail (expect @@ -118,7 +118,7 @@ (embed-test/successful-query-results) (embed-test/with-embedding-enabled-and-new-secret-key (embed-test/with-temp-card [card] - ((test-users/user->client :crowberto) :get 200 (card-query-url card {:_embedding_params {:abc "locked"} + ((test-users/user->client :crowberto) :get 202 (card-query-url card {:_embedding_params {:abc "locked"} :params {:abc 100}}))))) ;; if `:locked` parameter is present in URL params, request should fail @@ -164,7 +164,7 @@ (embed-test/successful-query-results) (embed-test/with-embedding-enabled-and-new-secret-key (embed-test/with-temp-card [card] - ((test-users/user->client :crowberto) :get 200 (card-query-url card {:_embedding_params {:abc "enabled"} + ((test-users/user->client :crowberto) :get 202 (card-query-url card {:_embedding_params {:abc "enabled"} :params {:abc "enabled"}}))))) ;; If an `:enabled` param is present in URL params but *not* the JWT, that's ok @@ -172,7 +172,7 @@ (embed-test/successful-query-results) (embed-test/with-embedding-enabled-and-new-secret-key (embed-test/with-temp-card [card] - ((test-users/user->client :crowberto) :get 200 (str (card-query-url card {:_embedding_params {:abc "enabled"}}) + ((test-users/user->client :crowberto) :get 202 (str (card-query-url card {:_embedding_params {:abc "enabled"}}) "?abc=200"))))) @@ -241,7 +241,7 @@ (embed-test/successful-query-results) (embed-test/with-embedding-enabled-and-new-secret-key (embed-test/with-temp-dashcard [dashcard] - ((test-users/user->client :crowberto) :get 200 (dashcard-url dashcard))))) + ((test-users/user->client :crowberto) :get 202 (dashcard-url dashcard))))) ;; ...but if the user is not an admin this endpoint should fail (expect @@ -280,8 +280,8 @@ (embed-test/successful-query-results) (embed-test/with-embedding-enabled-and-new-secret-key (embed-test/with-temp-dashcard [dashcard] - ((test-users/user->client :crowberto) :get 200 (dashcard-url dashcard - {:_embedding_params {:abc "locked"}, :params {:abc 100}}))))) + ((test-users/user->client :crowberto) :get 202 (dashcard-url dashcard + {:_embedding_params {:abc "locked"}, :params {:abc 100}}))))) ;; If `:locked` parameter is present in URL params, request should fail (expect @@ -326,7 +326,7 @@ (embed-test/successful-query-results) (embed-test/with-embedding-enabled-and-new-secret-key (embed-test/with-temp-dashcard [dashcard] - ((test-users/user->client :crowberto) :get 200 (dashcard-url dashcard {:_embedding_params {:abc "enabled"} + ((test-users/user->client :crowberto) :get 202 (dashcard-url dashcard {:_embedding_params {:abc "enabled"} :params {:abc 100}}))))) ;; If an `:enabled` param is present in URL params but *not* the JWT, that's ok @@ -334,7 +334,7 @@ (embed-test/successful-query-results) (embed-test/with-embedding-enabled-and-new-secret-key (embed-test/with-temp-dashcard [dashcard] - ((test-users/user->client :crowberto) :get 200 (str (dashcard-url dashcard {:_embedding_params {:abc "enabled"}}) + ((test-users/user->client :crowberto) :get 202 (str (dashcard-url dashcard {:_embedding_params {:abc "enabled"}}) "?abc=200"))))) ;; Check that editable query params work correctly and keys get coverted from strings to keywords, even if they're @@ -344,10 +344,10 @@ "completed" (embed-test/with-embedding-enabled-and-new-secret-key (-> (embed-test/with-temp-dashcard [dashcard {:dash {:enable_embedding true}}] - ((test-users/user->client :crowberto) :get 200 (str (dashcard-url dashcard - {:_embedding_params {:num_birds :locked - :2nd_date_seen :enabled} - :params {:num_birds 2}}) + ((test-users/user->client :crowberto) :get 202 (str (dashcard-url dashcard + {:_embedding_params {:num_birds :locked + :2nd_date_seen :enabled} + :params {:num_birds 2}}) "?2nd_date_seen=2018-02-14"))) :status))) diff --git a/test/metabase/api/public_test.clj b/test/metabase/api/public_test.clj index 6bf500decdc5fcf1be26fffc3bdae54543c3823b..5292a5ededb743434868b3e7bf5bf317a8e31bf0 100644 --- a/test/metabase/api/public_test.clj +++ b/test/metabase/api/public_test.clj @@ -5,7 +5,6 @@ [string :as str] [test :refer :all]] [dk.ative.docjure.spreadsheet :as spreadsheet] - [expectations :refer [expect]] [metabase [http-client :as http] [query-processor-test :as qp.test] @@ -77,121 +76,120 @@ ~@body)))) - ;;; ------------------------------------------- GET /api/public/card/:uuid ------------------------------------------- -;; Check that we *cannot* fetch a PublicCard if the setting is disabled -(expect - "An error occurred." - (tu/with-temporary-setting-values [enable-public-sharing false] - (with-temp-public-card [{uuid :public_uuid}] - (http/client :get 400 (str "public/card/" uuid))))) -;; Check that we get a 400 if the PublicCard doesn't exist -(expect - "An error occurred." - (tu/with-temporary-setting-values [enable-public-sharing true] - (http/client :get 400 (str "public/card/" (UUID/randomUUID))))) +(deftest check-that-we--cannot--fetch-a-publiccard-if-the-setting-is-disabled + (is (= "An error occurred." + (tu/with-temporary-setting-values [enable-public-sharing false] + (with-temp-public-card [{uuid :public_uuid}] + (http/client :get 400 (str "public/card/" uuid))))))) -;; Check that we *cannot* fetch a PublicCard if the Card has been archived -(expect - "An error occurred." - (tu/with-temporary-setting-values [enable-public-sharing true] - (with-temp-public-card [{uuid :public_uuid} {:archived true}] - (http/client :get 400 (str "public/card/" uuid))))) -;; Check that we can fetch a PublicCard -(expect - #{:dataset_query :description :display :id :name :visualization_settings :param_values :param_fields} - (tu/with-temporary-setting-values [enable-public-sharing true] - (with-temp-public-card [{uuid :public_uuid}] - (set (keys (http/client :get 200 (str "public/card/" uuid))))))) - -;; make sure :param_values get returned as expected -(expect - {(data/id :categories :name) {:values 75 - :human_readable_values {} - :field_id (data/id :categories :name)}} - (tt/with-temp Card [card {:dataset_query - {:database (data/id) - :type :native - :native {:query (str "SELECT COUNT(*) " - "FROM venues " - "LEFT JOIN categories ON venues.category_id = categories.id " - "WHERE {{category}}") - :collection "CATEGORIES" - :template-tags {:category {:name "category" - :display-name "Category" - :type "dimension" - :dimension ["field-id" (data/id :categories :name)] - :widget-type "category" - :required true}}}}}] - (-> (:param_values (#'public-api/public-card :id (u/get-id card))) - (update-in [(data/id :categories :name) :values] count)))) +(deftest check-that-we-get-a-400-if-the-publiccard-doesn-t-exist + (is (= "An error occurred." + (tu/with-temporary-setting-values [enable-public-sharing true] + (http/client :get 400 (str "public/card/" (UUID/randomUUID))))))) + +(deftest check-that-we--cannot--fetch-a-publiccard-if-the-card-has-been-archived + (is (= "An error occurred." + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-card [{uuid :public_uuid} {:archived true}] + (http/client :get 400 (str "public/card/" uuid))))))) + + +(deftest check-that-we-can-fetch-a-publiccard + (is (= #{:dataset_query :description :display :id :name :visualization_settings :param_values :param_fields} + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-card [{uuid :public_uuid}] + (set (keys (http/client :get 200 (str "public/card/" uuid))))))))) + + + +(deftest make-sure--param-values-get-returned-as-expected + (is (= {(data/id :categories :name) {:values 75 + :human_readable_values {} + :field_id (data/id :categories :name)}} + (tt/with-temp Card [card {:dataset_query + {:database (data/id) + :type :native + :native {:query (str "SELECT COUNT(*) " + "FROM venues " + "LEFT JOIN categories ON venues.category_id = categories.id " + "WHERE {{category}}") + :collection "CATEGORIES" + :template-tags {:category {:name "category" + :display-name "Category" + :type "dimension" + :dimension ["field-id" (data/id :categories :name)] + :widget-type "category" + :required true}}}}}] + (-> (:param_values (#'public-api/public-card :id (u/get-id card))) + (update-in [(data/id :categories :name) :values] count) + (update (data/id :categories :name) #(into {} %))))))) + ;;; ------------------------- GET /api/public/card/:uuid/query (and JSON/CSV/XSLX versions) -------------------------- -;; Check that we *cannot* execute a PublicCard if the setting is disabled -(expect - "An error occurred." - (tu/with-temporary-setting-values [enable-public-sharing false] - (with-temp-public-card [{uuid :public_uuid}] - (http/client :get 400 (str "public/card/" uuid "/query"))))) +(deftest check-that-we--cannot--execute-a-publiccard-if-the-setting-is-disabled + (is (= "An error occurred." + (tu/with-temporary-setting-values [enable-public-sharing false] + (with-temp-public-card [{uuid :public_uuid}] + (http/client :get 400 (str "public/card/" uuid "/query"))))))) -;; Check that we get a 400 if the PublicCard doesn't exist -(expect - "An error occurred." - (tu/with-temporary-setting-values [enable-public-sharing true] - (http/client :get 400 (str "public/card/" (UUID/randomUUID) "/query")))) -;; Check that we *cannot* execute a PublicCard if the Card has been archived -(expect - "An error occurred." - (tu/with-temporary-setting-values [enable-public-sharing true] - (with-temp-public-card [{uuid :public_uuid} {:archived true}] - (http/client :get 400 (str "public/card/" uuid "/query"))))) -;; Check that we can exec a PublicCard -(expect - [[100]] - (tu/with-temporary-setting-values [enable-public-sharing true] - (with-temp-public-card [{uuid :public_uuid}] - (qp.test/rows (http/client :get 200 (str "public/card/" uuid "/query")))))) -;; Check that we can exec a PublicCard and get results as JSON -(expect - (tu/with-temporary-setting-values [enable-public-sharing true] - (with-temp-public-card [{uuid :public_uuid}] - (http/client :get 200 (str "public/card/" uuid "/query/json"))))) +(deftest check-that-we-get-a-400-if-the-publiccard-doesn-t-exist + (is (= "An error occurred." + (tu/with-temporary-setting-values [enable-public-sharing true] + (http/client :get 400 (str "public/card/" (UUID/randomUUID) "/query")))))) -;; Check that we can exec a PublicCard and get results as CSV -(expect - "Count\n100\n" - (tu/with-temporary-setting-values [enable-public-sharing true] - (with-temp-public-card [{uuid :public_uuid}] - (http/client :get 200 (str "public/card/" uuid "/query/csv"), :format :csv)))) -;; Check that we can exec a PublicCard and get results as XLSX -(expect - [{:col "Count"} {:col 100.0}] - (tu/with-temporary-setting-values [enable-public-sharing true] - (with-temp-public-card [{uuid :public_uuid}] - (->> (http/client :get 200 (str "public/card/" uuid "/query/xlsx") {:request-options {:as :byte-array}}) - ByteArrayInputStream. - spreadsheet/load-workbook - (spreadsheet/select-sheet "Query result") - (spreadsheet/select-columns {:A :col}))))) - -;; Check that we can exec a PublicCard with `?parameters` -(expect - [{:name "Venue ID", :slug "venue_id", :type "id", :value 2}] +(deftest check-that-we--cannot--execute-a-publiccard-if-the-card-has-been-archived + (is (= "An error occurred." + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-card [{uuid :public_uuid} {:archived true}] + (http/client :get 400 (str "public/card/" uuid "/query"))))))) + + +(deftest check-that-we-can-exec-a-publiccard + (is (= [[100]] + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-card [{uuid :public_uuid}] + (qp.test/rows (http/client :get 202 (str "public/card/" uuid "/query")))))))) + + +(deftest check-that-we-can-exec-a-publiccard-and-get-results-as-json (tu/with-temporary-setting-values [enable-public-sharing true] (with-temp-public-card [{uuid :public_uuid}] - (get-in (http/client :get 200 (str "public/card/" uuid "/query") - :parameters (json/encode [{:name "Venue ID", :slug "venue_id", :type "id", :value 2}])) - [:json_query :parameters])))) + (http/client :get 202 (str "public/card/" uuid "/query/json"))))) + +(deftest get-csv + (is (= "Count\n100\n" + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-card [{uuid :public_uuid}] + (http/client :get 202 (str "public/card/" uuid "/query/csv"), :format :csv)))))) + +(deftest check-that-we-can-exec-a-publiccard-and-get-results-as-xlsx + (is (= [{:col "Count"} {:col 100.0}] + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-card [{uuid :public_uuid}] + (->> (http/client :get 202 (str "public/card/" uuid "/query/xlsx") {:request-options {:as :byte-array}}) + ByteArrayInputStream. + spreadsheet/load-workbook + (spreadsheet/select-sheet "Query result") + (spreadsheet/select-columns {:A :col}))))))) + +(deftest check-that-we-can-exec-a-publiccard-with---parameters- + (is (= [{:name "Venue ID", :slug "venue_id", :type "id", :value 2}] + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-card [{uuid :public_uuid}] + (get-in (http/client :get 202 (str "public/card/" uuid "/query") + :parameters (json/encode [{:name "Venue ID", :slug "venue_id", :type "id", :value 2}])) + [:json_query :parameters])))))) ;; Cards with required params (defn- do-with-required-param-card [f] @@ -207,16 +205,16 @@ :required true}}}}}] (f uuid)))) -;; should be able to run a Card with a required param -(expect - [[22]] - (do-with-required-param-card - (fn [uuid] - (qp.test/rows - (http/client :get 200 (str "public/card/" uuid "/query") - :parameters (json/encode [{:type "category" - :target [:variable [:template-tag "price"]] - :value 1}])))))) + +(deftest should-be-able-to-run-a-card-with-a-required-param + (is (= [[22]] + (do-with-required-param-card + (fn [uuid] + (qp.test/rows + (http/client :get 202 (str "public/card/" uuid "/query") + :parameters (json/encode [{:type "category" + :target [:variable [:template-tag "price"]] + :value 1}])))))))) (deftest missing-required-param-error-message-test (testing (str "If you're missing a required param, the error message should get passed thru, rather than the normal " @@ -226,76 +224,76 @@ :error_type "missing-required-parameter"} (do-with-required-param-card (fn [uuid] - (http/client :get 200 (str "public/card/" uuid "/query")))))))) + (http/client :get 202 (str "public/card/" uuid "/query")))))))) + -;; make sure CSV (etc.) downloads take editable params into account (#6407) (defn- card-with-date-field-filter [] (assoc (shared-obj) - :dataset_query {:database (data/id) - :type :native - :native {:query "SELECT COUNT(*) AS \"count\" FROM CHECKINS WHERE {{date}}" - :template-tags {:date {:name "date" - :display-name "Date" - :type "dimension" - :dimension [:field-id (data/id :checkins :date)] - :widget-type "date/quarter-year"}}}})) - -(expect - "count\n107\n" - (tu/with-temporary-setting-values [enable-public-sharing true] - (tt/with-temp Card [{uuid :public_uuid} (card-with-date-field-filter)] - (http/client :get 200 (str "public/card/" uuid "/query/csv") - :parameters (json/encode [{:type :date/quarter-year - :target [:dimension [:template-tag :date]] - :value "Q1-2014"}]))))) - -;; make sure it also works with the forwarded URL -(expect - "count\n107\n" - (tu/with-temporary-setting-values [enable-public-sharing true] - (tt/with-temp Card [{uuid :public_uuid} (card-with-date-field-filter)] - ;; make sure the URL doesn't include /api/ at the beginning like it normally would - (binding [http/*url-prefix* (str/replace http/*url-prefix* #"/api/$" "/")] - (tu/with-temporary-setting-values [site-url http/*url-prefix*] - (http/client :get 200 (str "public/question/" uuid ".csv") - :parameters (json/encode [{:type :date/quarter-year - :target [:dimension [:template-tag :date]] - :value "Q1-2014"}]))))))) - -;; make sure we include all the relevant fields like `:insights` + :dataset_query {:database (data/id) + :type :native + :native {:query "SELECT COUNT(*) AS \"count\" FROM CHECKINS WHERE {{date}}" + :template-tags {:date {:name "date" + :display-name "Date" + :type "dimension" + :dimension [:field-id (data/id :checkins :date)] + :widget-type "date/quarter-year"}}}})) + + +(deftest make-sure-csv--etc---downloads-take-editable-params-into-account---6407---- + (is (= "count\n107\n" + (tu/with-temporary-setting-values [enable-public-sharing true] + (tt/with-temp Card [{uuid :public_uuid} (card-with-date-field-filter)] + (http/client :get 202 (str "public/card/" uuid "/query/csv") + :parameters (json/encode [{:type :date/quarter-year + :target [:dimension [:template-tag :date]] + :value "Q1-2014"}]))))))) + + +(deftest make-sure-it-also-works-with-the-forwarded-url + (is (= "count\n107\n" + (tu/with-temporary-setting-values [enable-public-sharing true] + (tt/with-temp Card [{uuid :public_uuid} (card-with-date-field-filter)] + ;; make sure the URL doesn't include /api/ at the beginning like it normally would + (binding [http/*url-prefix* (str/replace http/*url-prefix* #"/api/$" "/")] + (tu/with-temporary-setting-values [site-url http/*url-prefix*] + (http/client :get 202 (str "public/question/" uuid ".csv") + :parameters (json/encode [{:type :date/quarter-year + :target [:dimension [:template-tag :date]] + :value "Q1-2014"}]))))))))) + (defn- card-with-trendline [] (assoc (shared-obj) - :dataset_query {:database (data/id) - :type :query - :query {:source-table (data/id :checkins) - :breakout [[:datetime-field [:field-id (data/id :checkins :date)] :month]] - :aggregation [[:count]]}})) - -(expect - #{:cols :rows :insights :results_timezone} - (tu/with-temporary-setting-values [enable-public-sharing true] - (tt/with-temp Card [{uuid :public_uuid} (card-with-trendline)] - (-> (http/client :get 200 (str "public/card/" uuid "/query")) - :data - keys - set)))) + :dataset_query {:database (data/id) + :type :query + :query {:source-table (data/id :checkins) + :breakout [[:datetime-field [:field-id (data/id :checkins :date)] :month]] + :aggregation [[:count]]}})) + +(deftest make-sure-we-include-all-the-relevant-fields-like-insights + (is (= #{:cols :rows :insights :results_timezone} + (tu/with-temporary-setting-values [enable-public-sharing true] + (tt/with-temp Card [{uuid :public_uuid} (card-with-trendline)] + (-> (http/client :get 202 (str "public/card/" uuid "/query")) + :data + keys + set)))))) + ;;; ---------------------------------------- GET /api/public/dashboard/:uuid ----------------------------------------- -;; Check that we *cannot* fetch PublicDashboard if setting is disabled -(expect - "An error occurred." - (tu/with-temporary-setting-values [enable-public-sharing false] - (with-temp-public-dashboard [{uuid :public_uuid}] - (http/client :get 400 (str "public/dashboard/" uuid))))) -;; Check that we get a 400 if the PublicDashboard doesn't exis -(expect - "An error occurred." - (tu/with-temporary-setting-values [enable-public-sharing true] - (http/client :get 400 (str "public/dashboard/" (UUID/randomUUID))))) +(deftest check-that-we--cannot--fetch-publicdashboard-if-setting-is-disabled + (is (= "An error occurred." + (tu/with-temporary-setting-values [enable-public-sharing false] + (with-temp-public-dashboard [{uuid :public_uuid}] + (http/client :get 400 (str "public/dashboard/" uuid))))))) + +(deftest check-that-we-get-a-400-if-the-publicdashboard-doesn-t-exis + (is (= "An error occurred." + (tu/with-temporary-setting-values [enable-public-sharing true] + (http/client :get 400 (str "public/dashboard/" (UUID/randomUUID))))))) (defn- fetch-public-dashboard [{uuid :public_uuid}] (-> (http/client :get 200 (str "public/dashboard/" uuid)) @@ -303,21 +301,19 @@ (update :name boolean) (update :ordered_cards count))) -;; Check that we can fetch a PublicDashboard -(expect - {:name true, :ordered_cards 1} - (tu/with-temporary-setting-values [enable-public-sharing true] - (with-temp-public-dashboard-and-card [dash card] - (fetch-public-dashboard dash)))) -;; Check that we don't see Cards that have been archived -(expect - {:name true, :ordered_cards 0} - (tu/with-temporary-setting-values [enable-public-sharing true] - (with-temp-public-dashboard-and-card [dash card] - (db/update! Card (u/get-id card), :archived true) - (fetch-public-dashboard dash)))) +(deftest check-that-we-can-fetch-a-publicdashboard + (is (= {:name true, :ordered_cards 1} + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-dashboard-and-card [dash card] + (fetch-public-dashboard dash)))))) +(deftest check-that-we-don-t-see-cards-that-have-been-archived + (is (= {:name true, :ordered_cards 0} + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-dashboard-and-card [dash card] + (db/update! Card (u/get-id card), :archived true) + (fetch-public-dashboard dash)))))) ;;; --------------------------------- GET /api/public/dashboard/:uuid/card/:card-id ---------------------------------- @@ -325,227 +321,221 @@ (str "public/dashboard/" (:public_uuid dash) "/card/" (u/get-id card))) -;; Check that we *cannot* exec PublicCard via PublicDashboard if setting is disabled -(expect - "An error occurred." - (tu/with-temporary-setting-values [enable-public-sharing false] - (with-temp-public-dashboard-and-card [dash card] - (http/client :get 400 (dashcard-url dash card))))) -;; Check that we get a 400 if PublicDashboard doesn't exist -(expect - "An error occurred." - (tu/with-temporary-setting-values [enable-public-sharing true] - (with-temp-public-dashboard-and-card [_ card] - (http/client :get 400 (dashcard-url {:public_uuid (UUID/randomUUID)} card))))) +(deftest check-that-we--cannot--exec-publiccard-via-publicdashboard-if-setting-is-disabled + (is (= "An error occurred." + (tu/with-temporary-setting-values [enable-public-sharing false] + (with-temp-public-dashboard-and-card [dash card] + (http/client :get 400 (dashcard-url dash card))))))) + +(deftest check-that-we-get-a-400-if-publicdashboard-doesn-t-exist + (is (= "An error occurred." + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-dashboard-and-card [_ card] + (http/client :get 400 (dashcard-url {:public_uuid (UUID/randomUUID)} card))))))) + +(deftest check-that-we-get-a-400-if-publiccard-doesn-t-exist + (is (= "An error occurred." + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-dashboard-and-card [dash _] + (http/client :get 400 (dashcard-url dash Integer/MAX_VALUE))))))) + +(deftest check-that-we-get-a-400-if-the-card-does-exist-but-it-s-not-part-of-this-dashboard + (is (= "An error occurred." + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-dashboard-and-card [dash _] + (tt/with-temp Card [card] + (http/client :get 400 (dashcard-url dash card)))))))) + + +(deftest check-that-we--cannot--execute-a-publiccard-via-a-publicdashboard-if-the-card-has-been-archived + (is (= "An error occurred." + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-dashboard-and-card [dash card] + (db/update! Card (u/get-id card), :archived true) + (http/client :get 400 (dashcard-url dash card))))))) + +(deftest check-that-we-can-exec-a-publiccard-via-a-publicdashboard + (is (= [[100]] + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-dashboard-and-card [dash card] + (qp.test/rows (http/client :get 202 (dashcard-url dash card)))))))) + +(deftest check-that-we-can-exec-a-publiccard-via-a-publicdashboard-with---parameters- + (is (= [{:name "Venue ID" + :slug "venue_id" + :target ["dimension" (data/id :venues :id)] + :value [10] + :default nil + :type "id"}] + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-dashboard-and-card [dash card] + (get-in (http/client :get 202 (dashcard-url dash card) + :parameters (json/encode [{:name "Venue ID" + :slug :venue_id + :target [:dimension (data/id :venues :id)] + :value [10]}])) + [:json_query :parameters])))))) -;; Check that we get a 400 if PublicCard doesn't exist -(expect - "An error occurred." - (tu/with-temporary-setting-values [enable-public-sharing true] - (with-temp-public-dashboard-and-card [dash _] - (http/client :get 400 (dashcard-url dash Integer/MAX_VALUE))))) - -;; Check that we get a 400 if the Card does exist but it's not part of this Dashboard -(expect - "An error occurred." - (tu/with-temporary-setting-values [enable-public-sharing true] - (with-temp-public-dashboard-and-card [dash _] - (tt/with-temp Card [card] - (http/client :get 400 (dashcard-url dash card)))))) - -;; Check that we *cannot* execute a PublicCard via a PublicDashboard if the Card has been archived -(expect - "An error occurred." - (tu/with-temporary-setting-values [enable-public-sharing true] - (with-temp-public-dashboard-and-card [dash card] - (db/update! Card (u/get-id card), :archived true) - (http/client :get 400 (dashcard-url dash card))))) - -;; Check that we can exec a PublicCard via a PublicDashboard -(expect - [[100]] - (tu/with-temporary-setting-values [enable-public-sharing true] - (with-temp-public-dashboard-and-card [dash card] - (qp.test/rows (http/client :get 200 (dashcard-url dash card)))))) - -;; Check that we can exec a PublicCard via a PublicDashboard with `?parameters` -(expect - [{:name "Venue ID" - :slug "venue_id" - :target ["dimension" (data/id :venues :id)] - :value [10] - :default nil - :type "id"}] - (tu/with-temporary-setting-values [enable-public-sharing true] - (with-temp-public-dashboard-and-card [dash card] - (get-in (http/client :get 200 (dashcard-url dash card) - :parameters (json/encode [{:name "Venue ID" - :slug :venue_id - :target [:dimension (data/id :venues :id)] - :value [10]}])) - [:json_query :parameters])))) - ;; Make sure params are validated: this should pass because venue_id *is* one of the Dashboard's :parameters -(expect - [[1]] - (tu/with-temporary-setting-values [enable-public-sharing true] - (with-temp-public-dashboard-and-card [dash card] - (-> (http/client :get 200 (dashcard-url dash card) - :parameters (json/encode [{:name "Venue ID" - :slug :venue_id - :target [:dimension (data/id :venues :id)] - :value [10]}])) - qp.test/rows)))) - -;; Make sure params are validated: this should fail because venue_name is *not* one of the Dashboard's :parameters -(expect - "An error occurred." - (tu/with-temporary-setting-values [enable-public-sharing true] - (with-temp-public-dashboard-and-card [dash card] - (http/client :get 400 (dashcard-url dash card) - :parameters (json/encode [{:name "Venue Name" - :slug :venue_name - :target [:dimension (data/id :venues :name)] - :value ["PizzaHacker"]}]))))) - -;; Check that an additional Card series works as well -(expect - [[100]] - (tu/with-temporary-setting-values [enable-public-sharing true] - (with-temp-public-dashboard-and-card [dash card] - (with-temp-public-card [card-2] - (tt/with-temp DashboardCardSeries [_ {:dashboardcard_id (db/select-one-id DashboardCard - :card_id (u/get-id card) - :dashboard_id (u/get-id dash)) - :card_id (u/get-id card-2)}] - (qp.test/rows (http/client :get 200 (dashcard-url dash card-2)))))))) - -;; Make sure that parameters actually work correctly (#7212) -(expect - [[50]] - (tu/with-temporary-setting-values [enable-public-sharing true] - (tt/with-temp Card [card {:dataset_query {:database (data/id) - :type :native - :native {:query "SELECT {{num}} AS num" - :template-tags {:num {:name "num" - :display-name "Num" - :type "number" - :required true - :default "1"}}}}}] - (with-temp-public-dashboard [dash {:parameters [{:name "Num" - :slug "num" - :id "537e37b4" - :type "category"}]}] - (add-card-to-dashboard! card dash - :parameter_mappings [{:card_id (u/get-id card) - :target [:variable - [:template-tag :num]] - :parameter_id "537e37b4"}]) - (-> ((test-users/user->client :crowberto) - :get (str (dashcard-url dash card) - "?parameters=" - (json/generate-string - [{:type :category - :target [:variable [:template-tag :num]] - :value "50"}]))) - qp.test/rows))))) - -;; ...with MBQL Cards as well... -(expect - [[1]] - (tu/with-temporary-setting-values [enable-public-sharing true] - (tt/with-temp Card [card {:dataset_query {:database (data/id) - :type :query - :query {:source-table (data/id :venues) - :aggregation [:count]}}}] - (with-temp-public-dashboard [dash {:parameters [{:name "Venue ID" - :slug "venue_id" - :id "22486e00" - :type "id"}]}] - (add-card-to-dashboard! card dash - :parameter_mappings [{:parameter_id "22486e00" - :card_id (u/get-id card) - :target [:dimension - [:field-id - (data/id :venues :id)]]}]) - (-> ((test-users/user->client :crowberto) - :get (str (dashcard-url dash card) - "?parameters=" - (json/generate-string - [{:type :id - :target [:dimension [:field-id (data/id :venues :id)]] - :value "50"}]))) - qp.test/rows))))) - -;; ...and also for DateTime params -(expect - [[733]] - (tu/with-temporary-setting-values [enable-public-sharing true] - (tt/with-temp Card [card {:dataset_query {:database (data/id) - :type :query - :query {:source-table (data/id :checkins) - :aggregation [:count]}}}] - (with-temp-public-dashboard [dash {:parameters [{:name "Date Filter" - :slug "date_filter" - :id "18a036ec" - :type "date/all-options"}]}] - (add-card-to-dashboard! card dash - :parameter_mappings [{:parameter_id "18a036ec" - :card_id (u/get-id card) - :target [:dimension - [:field-id - (data/id :checkins :date)]]}]) - (-> ((test-users/user->client :crowberto) - :get (str (dashcard-url dash card) - "?parameters=" - (json/generate-string - [{:type "date/all-options" - :target [:dimension [:field-id (data/id :checkins :date)]] - :value "~2015-01-01"}]))) - qp.test/rows))))) +(deftest params-are-validated + (is (= [[1]] + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-dashboard-and-card [dash card] + (-> (http/client :get 202 (dashcard-url dash card) + :parameters (json/encode [{:name "Venue ID" + :slug :venue_id + :target [:dimension (data/id :venues :id)] + :value [10]}])) + qp.test/rows)))))) + +(deftest make-sure-params-are-validated--this-should-fail-because-venue-name-is--not--one-of-the-dashboard-s--parameters + (is (= "An error occurred." + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-dashboard-and-card [dash card] + (http/client :get 400 (dashcard-url dash card) + :parameters (json/encode [{:name "Venue Name" + :slug :venue_name + :target [:dimension (data/id :venues :name)] + :value ["PizzaHacker"]}]))))))) + +(deftest check-that-an-additional-card-series-works-as-well + (is (= [[100]] + (tu/with-temporary-setting-values [enable-public-sharing true] + (with-temp-public-dashboard-and-card [dash card] + (with-temp-public-card [card-2] + (tt/with-temp DashboardCardSeries [_ {:dashboardcard_id (db/select-one-id DashboardCard + :card_id (u/get-id card) + :dashboard_id (u/get-id dash)) + :card_id (u/get-id card-2)}] + (qp.test/rows (http/client :get 202 (dashcard-url dash card-2)))))))))) + + + +(deftest make-sure-that-parameters-actually-work-correctly---7212- + (is (= [[50]] + (tu/with-temporary-setting-values [enable-public-sharing true] + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :native + :native {:query "SELECT {{num}} AS num" + :template-tags {:num {:name "num" + :display-name "Num" + :type "number" + :required true + :default "1"}}}}}] + (with-temp-public-dashboard [dash {:parameters [{:name "Num" + :slug "num" + :id "537e37b4" + :type "category"}]}] + (add-card-to-dashboard! card dash + :parameter_mappings [{:card_id (u/get-id card) + :target [:variable + [:template-tag :num]] + :parameter_id "537e37b4"}]) + (-> ((test-users/user->client :crowberto) + :get (str (dashcard-url dash card) + "?parameters=" + (json/generate-string + [{:type :category + :target [:variable [:template-tag :num]] + :value "50"}]))) + qp.test/rows))))))) + +(deftest ---with-mbql-cards-as-well--- + (is (= [[1]] + (tu/with-temporary-setting-values [enable-public-sharing true] + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :query + :query {:source-table (data/id :venues) + :aggregation [:count]}}}] + (with-temp-public-dashboard [dash {:parameters [{:name "Venue ID" + :slug "venue_id" + :id "22486e00" + :type "id"}]}] + (add-card-to-dashboard! card dash + :parameter_mappings [{:parameter_id "22486e00" + :card_id (u/get-id card) + :target [:dimension + [:field-id + (data/id :venues :id)]]}]) + (-> ((test-users/user->client :crowberto) + :get (str (dashcard-url dash card) + "?parameters=" + (json/generate-string + [{:type :id + :target [:dimension [:field-id (data/id :venues :id)]] + :value "50"}]))) + qp.test/rows))))))) + + +(deftest ---and-also-for-datetime-params + (is (= [[733]] + (tu/with-temporary-setting-values [enable-public-sharing true] + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :query + :query {:source-table (data/id :checkins) + :aggregation [:count]}}}] + (with-temp-public-dashboard [dash {:parameters [{:name "Date Filter" + :slug "date_filter" + :id "18a036ec" + :type "date/all-options"}]}] + (add-card-to-dashboard! card dash + :parameter_mappings [{:parameter_id "18a036ec" + :card_id (u/get-id card) + :target [:dimension + [:field-id + (data/id :checkins :date)]]}]) + (-> ((test-users/user->client :crowberto) + :get (str (dashcard-url dash card) + "?parameters=" + (json/generate-string + [{:type "date/all-options" + :target [:dimension [:field-id (data/id :checkins :date)]] + :value "~2015-01-01"}]))) + qp.test/rows))))))) + ;; make sure DimensionValue params also work if they have a default value, even if some is passed in for some reason ;; as part of the query (#7253) ;; If passed in as part of the query however make sure it doesn't override what's actually in the DB -(expect - [["Wow"]] - (tu/with-temporary-setting-values [enable-public-sharing true] - (tt/with-temp Card [card {:dataset_query {:database (data/id) - :type :native - :native {:query "SELECT {{msg}} AS message" - :template-tags {:msg {:id "181da7c5" - :name "msg" - :display-name "Message" - :type "text" - :required true - :default "Wow"}}}}}] - (with-temp-public-dashboard [dash {:parameters [{:name "Message" - :slug "msg" - :id "181da7c5" - :type "category"}]}] - (add-card-to-dashboard! card dash - :parameter_mappings [{:card_id (u/get-id card) - :target [:variable [:template-tag :msg]] - :parameter_id "181da7c5"}]) - (-> ((test-users/user->client :crowberto) - :get (str (dashcard-url dash card) - "?parameters=" - (json/generate-string - [{:type :category - :target [:variable [:template-tag :msg]] - :value nil - :default "Hello"}]))) - qp.test/rows))))) +(deftest dimensionvalue-params-work + (is (= [["Wow"]] + (tu/with-temporary-setting-values [enable-public-sharing true] + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :native + :native {:query "SELECT {{msg}} AS message" + :template-tags {:msg {:id "181da7c5" + :name "msg" + :display-name "Message" + :type "text" + :required true + :default "Wow"}}}}}] + (with-temp-public-dashboard [dash {:parameters [{:name "Message" + :slug "msg" + :id "181da7c5" + :type "category"}]}] + (add-card-to-dashboard! card dash + :parameter_mappings [{:card_id (u/get-id card) + :target [:variable [:template-tag :msg]] + :parameter_id "181da7c5"}]) + (-> ((test-users/user->client :crowberto) + :get (str (dashcard-url dash card) + "?parameters=" + (json/generate-string + [{:type :category + :target [:variable [:template-tag :msg]] + :value nil + :default "Hello"}]))) + qp.test/rows))))))) + ;;; --------------------------- Check that parameter information comes back with Dashboard --------------------------- -;; double-check that the Field has FieldValues -(expect - [1 2 3 4] - (db/select-one-field :values FieldValues :field_id (data/id :venues :price))) +(deftest double-check-that-the-field-has-fieldvalues + (is (= [1 2 3 4] + (db/select-one-field :values FieldValues :field_id (data/id :venues :price))))) (defn- price-param-values [] {(keyword (str (data/id :venues :price))) {:values [1 2 3 4] @@ -563,37 +553,33 @@ (tu/with-temporary-setting-values [enable-public-sharing true] (:param_values (http/client :get 200 (str "public/dashboard/" (:public_uuid dashboard)))))) -;; Check that param info comes back for SQL Cards -(expect - (price-param-values) - (with-temp-public-dashboard-and-card [dash card dashcard] - (db/update! Card (u/get-id card) - :dataset_query {:database (data/id) - :type :native - :native {:template-tags {:price {:name "price" - :display-name "Price" - :type "dimension" - :dimension ["field-id" (data/id :venues :price)]}}}}) - (add-price-param-to-dashboard! dash) - (add-dimension-param-mapping-to-dashcard! dashcard card ["template-tag" "price"]) - (GET-param-values dash))) - -;; Check that param info comes back for MBQL Cards (field-id) -(expect - (price-param-values) - (with-temp-public-dashboard-and-card [dash card dashcard] - (add-price-param-to-dashboard! dash) - (add-dimension-param-mapping-to-dashcard! dashcard card ["field-id" (data/id :venues :price)]) - (GET-param-values dash))) - -;; Check that param info comes back for MBQL Cards (fk->) -(expect - (price-param-values) - (with-temp-public-dashboard-and-card [dash card dashcard] - (add-price-param-to-dashboard! dash) - (add-dimension-param-mapping-to-dashcard! dashcard card ["fk->" (data/id :checkins :venue_id) (data/id :venues :price)]) - (GET-param-values dash))) - +(deftest check-that-param-info-comes-back-for-sql-cards + (is (= (price-param-values) + (with-temp-public-dashboard-and-card [dash card dashcard] + (db/update! Card (u/get-id card) + :dataset_query {:database (data/id) + :type :native + :native {:template-tags {:price {:name "price" + :display-name "Price" + :type "dimension" + :dimension ["field-id" (data/id :venues :price)]}}}}) + (add-price-param-to-dashboard! dash) + (add-dimension-param-mapping-to-dashcard! dashcard card ["template-tag" "price"]) + (GET-param-values dash))))) + +(deftest check-that-param-info-comes-back-for-mbql-cards--field-id- + (is (= (price-param-values) + (with-temp-public-dashboard-and-card [dash card dashcard] + (add-price-param-to-dashboard! dash) + (add-dimension-param-mapping-to-dashcard! dashcard card ["field-id" (data/id :venues :price)]) + (GET-param-values dash))))) + +(deftest check-that-param-info-comes-back-for-mbql-cards--fk--- + (is (= (price-param-values) + (with-temp-public-dashboard-and-card [dash card dashcard] + (add-price-param-to-dashboard! dash) + (add-dimension-param-mapping-to-dashcard! dashcard card ["fk->" (data/id :checkins :venue_id) (data/id :venues :price)]) + (GET-param-values dash))))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | New FieldValues search endpoints | @@ -627,70 +613,65 @@ ;;; ------------------------------------------- card->referenced-field-ids ------------------------------------------- -(expect - #{} - (tt/with-temp Card [card (mbql-card-referencing-nothing)] - (#'public-api/card->referenced-field-ids card))) +(deftest card-referencing-nothing + (is (= #{} + (tt/with-temp Card [card (mbql-card-referencing-nothing)] + (#'public-api/card->referenced-field-ids card))))) -;; It should pick up on Fields referenced in the MBQL query itself... -(expect - #{(data/id :venues :name)} - (tt/with-temp Card [card (mbql-card-referencing-venue-name)] - (#'public-api/card->referenced-field-ids card))) - -;; ...as well as template tag "implict" params for SQL queries -(expect - #{(data/id :venues :name)} - (tt/with-temp Card [card (sql-card-referencing-venue-name)] - (#'public-api/card->referenced-field-ids card))) +(deftest it-should-pick-up-on-fields-referenced-in-the-mbql-query-itself + (is (= #{(data/id :venues :name)} + (tt/with-temp Card [card (mbql-card-referencing-venue-name)] + (#'public-api/card->referenced-field-ids card))))) +(deftest ---as-well-as-template-tag--implict--params-for-sql-queries + (is (= #{(data/id :venues :name)} + (tt/with-temp Card [card (sql-card-referencing-venue-name)] + (#'public-api/card->referenced-field-ids card))))) ;;; --------------------------------------- check-field-is-referenced-by-card ---------------------------------------- -;; Check that the check succeeds when Field is referenced -(expect + +(deftest check-that-the-check-succeeds-when-field-is-referenced (tt/with-temp Card [card (mbql-card-referencing-venue-name)] (#'public-api/check-field-is-referenced-by-card (data/id :venues :name) (u/get-id card)))) -;; check that exception is thrown if the Field isn't referenced -(expect - Exception - (tt/with-temp Card [card (mbql-card-referencing-venue-name)] - (#'public-api/check-field-is-referenced-by-card (data/id :venues :category_id) (u/get-id card)))) + +(deftest check-that-exception-is-thrown-if-the-field-isn-t-referenced + (is (thrown? Exception + (tt/with-temp Card [card (mbql-card-referencing-venue-name)] + (#'public-api/check-field-is-referenced-by-card (data/id :venues :category_id) (u/get-id card)))))) ;;; ----------------------------------------- check-search-field-is-allowed ------------------------------------------ ;; search field is allowed IF: ;; A) search-field is the same field as the other one -(expect - (#'public-api/check-search-field-is-allowed (data/id :venues :id) (data/id :venues :id))) - -(expect - Exception - (#'public-api/check-search-field-is-allowed (data/id :venues :id) (data/id :venues :category_id))) +(deftest search-field-allowed-if-same-field-as-other-one + (#'public-api/check-search-field-is-allowed (data/id :venues :id) (data/id :venues :id)) + (is (thrown? Exception + (#'public-api/check-search-field-is-allowed (data/id :venues :id) (data/id :venues :category_id))))) ;; B) there's a Dimension that lists search field as the human_readable_field for the other field -(expect - (tt/with-temp Dimension [_ {:field_id (data/id :venues :id), :human_readable_field_id (data/id :venues :category_id)}] - (#'public-api/check-search-field-is-allowed (data/id :venues :id) (data/id :venues :category_id)))) +(deftest search-field-allowed-with-dimension + (is (tt/with-temp Dimension [_ {:field_id (data/id :venues :id), :human_readable_field_id (data/id :venues :category_id)}] + (#'public-api/check-search-field-is-allowed (data/id :venues :id) (data/id :venues :category_id))))) ;; C) search-field is a Name Field belonging to the same table as the other field, which is a PK -(expect - (do ;tu/with-temp-vals-in-db Field (data/id :venues :name) {:special_type "type/Name"} - (#'public-api/check-search-field-is-allowed (data/id :venues :id) (data/id :venues :name)))) +(deftest search-field-allowed-with-name-field + (is (#'public-api/check-search-field-is-allowed (data/id :venues :id) (data/id :venues :name)))) ;; not allowed if search field isn't a NAME -(expect - Exception - (tu/with-temp-vals-in-db Field (data/id :venues :name) {:special_type "type/Latitude"} - (#'public-api/check-search-field-is-allowed (data/id :venues :id) (data/id :venues :name)))) +(deftest search-field-not-allowed-if-search-field-isnt-a-name + (is (thrown? Exception + (tu/with-temp-vals-in-db Field (data/id :venues :name) {:special_type "type/Latitude"} + (#'public-api/check-search-field-is-allowed (data/id :venues :id) (data/id :venues :name)))))) + + +(deftest not-allowed-if-search-field-belongs-to-a-different-table + (is (thrown? Exception + (tu/with-temp-vals-in-db Field (data/id :categories :name) {:special_type "type/Name"} + (#'public-api/check-search-field-is-allowed (data/id :venues :id) (data/id :categories :name)))))) -;; not allowed if search field belongs to a different TABLE -(expect - Exception - (tu/with-temp-vals-in-db Field (data/id :categories :name) {:special_type "type/Name"} - (#'public-api/check-search-field-is-allowed (data/id :venues :id) (data/id :categories :name)))) ;;; ------------------------------------- check-field-is-referenced-by-dashboard ------------------------------------- @@ -701,66 +682,65 @@ :parameter_mappings [{:card_id (u/get-id card) :target [:dimension [:field-id (data/id :venues :id)]]}]}) -;; Field is "referenced" by Dashboard if it's one of the Dashboard's params... -(expect - (tt/with-temp* [Dashboard [dashboard] - Card [card] - DashboardCard [_ (dashcard-with-param-mapping-to-venue-id dashboard card)]] - (#'public-api/check-field-is-referenced-by-dashboard (data/id :venues :id) (u/get-id dashboard)))) -(expect - Exception - (tt/with-temp* [Dashboard [dashboard] - Card [card] - DashboardCard [_ (dashcard-with-param-mapping-to-venue-id dashboard card)]] - (#'public-api/check-field-is-referenced-by-dashboard (data/id :venues :name) (u/get-id dashboard)))) +(deftest field-is--referenced--by-dashboard-if-it-s-one-of-the-dashboard-s-params--- + (is (tt/with-temp* [Dashboard [dashboard] + Card [card] + DashboardCard [_ (dashcard-with-param-mapping-to-venue-id dashboard card)]] + (#'public-api/check-field-is-referenced-by-dashboard (data/id :venues :id) (u/get-id dashboard))))) -;; ...*or* if it's a so-called "implicit" param (a Field Filter Template Tag (FFTT) in a SQL Card) -(expect - (tt/with-temp* [Dashboard [dashboard] - Card [card (sql-card-referencing-venue-name)] - DashboardCard [_ {:dashboard_id (u/get-id dashboard), :card_id (u/get-id card)}]] - (#'public-api/check-field-is-referenced-by-dashboard (data/id :venues :name) (u/get-id dashboard)))) -(expect - Exception - (tt/with-temp* [Dashboard [dashboard] - Card [card (sql-card-referencing-venue-name)] - DashboardCard [_ {:dashboard_id (u/get-id dashboard), :card_id (u/get-id card)}]] - (#'public-api/check-field-is-referenced-by-dashboard (data/id :venues :id) (u/get-id dashboard)))) +(deftest TODO-name-this-exception + (is (thrown? Exception + (tt/with-temp* [Dashboard [dashboard] + Card [card] + DashboardCard [_ (dashcard-with-param-mapping-to-venue-id dashboard card)]] + (#'public-api/check-field-is-referenced-by-dashboard (data/id :venues :name) (u/get-id dashboard)))))) +;; ...*or* if it's a so-called "implicit" param (a Field Filter Template Tag (FFTT) in a SQL Card) +(deftest implicit-param + (is (tt/with-temp* [Dashboard [dashboard] + Card [card (sql-card-referencing-venue-name)] + DashboardCard [_ {:dashboard_id (u/get-id dashboard), :card_id (u/get-id card)}]] + (#'public-api/check-field-is-referenced-by-dashboard (data/id :venues :name) (u/get-id dashboard)))) + + (is (thrown? Exception + (tt/with-temp* [Dashboard [dashboard] + Card [card (sql-card-referencing-venue-name)] + DashboardCard [_ {:dashboard_id (u/get-id dashboard), :card_id (u/get-id card)}]] + (#'public-api/check-field-is-referenced-by-dashboard (data/id :venues :id) (u/get-id dashboard)))))) ;;; ------------------------------------------- card-and-field-id->values -------------------------------------------- -;; We should be able to get values for a Field referenced by a Card -(expect - {:values [["20th Century Cafe"] - ["25°"] - ["33 Taps"] - ["800 Degrees Neapolitan Pizzeria"] - ["BCD Tofu House"]] - :field_id (data/id :venues :name)} - (tt/with-temp Card [card (mbql-card-referencing :venues :name)] - (-> (public-api/card-and-field-id->values (u/get-id card) (data/id :venues :name)) - (update :values (partial take 5))))) - -;; SQL param field references should work just as well as MBQL field referenced -(expect - {:values [["20th Century Cafe"] - ["25°"] - ["33 Taps"] - ["800 Degrees Neapolitan Pizzeria"] - ["BCD Tofu House"]] - :field_id (data/id :venues :name)} - (tt/with-temp Card [card (sql-card-referencing-venue-name)] - (-> (public-api/card-and-field-id->values (u/get-id card) (data/id :venues :name)) - (update :values (partial take 5))))) - -;; But if the Field is not referenced we should get an Exception -(expect - Exception - (tt/with-temp Card [card (mbql-card-referencing :venues :price)] - (public-api/card-and-field-id->values (u/get-id card) (data/id :venues :name)))) +(deftest we-should-be-able-to-get-values-for-a-field-referenced-by-a-card + (is (= {:values [["20th Century Cafe"] + ["25°"] + ["33 Taps"] + ["800 Degrees Neapolitan Pizzeria"] + ["BCD Tofu House"]] + :field_id (data/id :venues :name)} + (tt/with-temp Card [card (mbql-card-referencing :venues :name)] + (into {} (-> (public-api/card-and-field-id->values (u/get-id card) (data/id :venues :name)) + (update :values (partial take 5)))))))) + +(deftest sql-param-field-references-should-work-just-as-well-as-mbql-field-referenced + (is (= {:values [["20th Century Cafe"] + ["25°"] + ["33 Taps"] + ["800 Degrees Neapolitan Pizzeria"] + ["BCD Tofu House"]] + :field_id (data/id :venues :name)} + (tt/with-temp Card [card (sql-card-referencing-venue-name)] + (into {} (-> (public-api/card-and-field-id->values (u/get-id card) (data/id :venues :name)) + (update :values (partial take 5)))))))) + + + +(deftest but-if-the-field-is-not-referenced-we-should-get-an-exception + (is (thrown? Exception + (tt/with-temp Card [card (mbql-card-referencing :venues :price)] + (public-api/card-and-field-id->values (u/get-id card) (data/id :venues :name)))))) + ;;; ------------------------------- GET /api/public/card/:uuid/field/:field-id/values -------------------------------- @@ -787,32 +767,28 @@ (fn [~card-binding] ~@body))) -;; should be able to fetch values for a Field referenced by a public Card -(expect - {:values [["20th Century Cafe"] - ["25°"] - ["33 Taps"] - ["800 Degrees Neapolitan Pizzeria"] - ["BCD Tofu House"]] - :field_id (data/id :venues :name)} - (with-sharing-enabled-and-temp-card-referencing :venues :name [card] - (-> (http/client :get 200 (field-values-url card (data/id :venues :name))) - (update :values (partial take 5))))) - -;; but for Fields that are not referenced we should get an Exception -(expect - "An error occurred." - (with-sharing-enabled-and-temp-card-referencing :venues :name [card] - (http/client :get 400 (field-values-url card (data/id :venues :price))))) - -;; Endpoint should fail if public sharing is disabled -(expect - "An error occurred." - (with-sharing-enabled-and-temp-card-referencing :venues :name [card] - (tu/with-temporary-setting-values [enable-public-sharing false] - (http/client :get 400 (field-values-url card (data/id :venues :name)))))) - +(deftest should-be-able-to-fetch-values-for-a-field-referenced-by-a-public-card + (is (= {:values [["20th Century Cafe"] + ["25°"] + ["33 Taps"] + ["800 Degrees Neapolitan Pizzeria"] + ["BCD Tofu House"]] + :field_id (data/id :venues :name)} + (with-sharing-enabled-and-temp-card-referencing :venues :name [card] + (-> (http/client :get 200 (field-values-url card (data/id :venues :name))) + (update :values (partial take 5))))))) + +(deftest but-for-fields-that-are-not-referenced-we-should-get-an-exception + (is (= "An error occurred." + (with-sharing-enabled-and-temp-card-referencing :venues :name [card] + (http/client :get 400 (field-values-url card (data/id :venues :price))))))) + +(deftest field-value-endpoint-should-fail-if-public-sharing-is-disabled + (is (= "An error occurred." + (with-sharing-enabled-and-temp-card-referencing :venues :name [card] + (tu/with-temporary-setting-values [enable-public-sharing false] + (http/client :get 400 (field-values-url card (data/id :venues :name)))))))) ;;; ----------------------------- GET /api/public/dashboard/:uuid/field/:field-id/values ----------------------------- @@ -835,51 +811,52 @@ (fn [~(or dashboard-binding '_) ~(or card-binding '_) ~(or dashcard-binding '_)] ~@body))) -;; should be able to use it when everything is g2g -(expect - {:values [["20th Century Cafe"] - ["25°"] - ["33 Taps"] - ["800 Degrees Neapolitan Pizzeria"] - ["BCD Tofu House"]] - :field_id (data/id :venues :name)} - (with-sharing-enabled-and-temp-dashcard-referencing :venues :name [dashboard] - (-> (http/client :get 200 (field-values-url dashboard (data/id :venues :name))) - (update :values (partial take 5))))) - -;; shound NOT be able to use the endpoint with a Field not referenced by the Dashboard -(expect - "An error occurred." - (with-sharing-enabled-and-temp-dashcard-referencing :venues :name [dashboard] - (http/client :get 400 (field-values-url dashboard (data/id :venues :price))))) - -;; Endpoint should fail if public sharing is disabled -(expect - "An error occurred." - (with-sharing-enabled-and-temp-dashcard-referencing :venues :name [dashboard] - (tu/with-temporary-setting-values [enable-public-sharing false] - (http/client :get 400 (field-values-url dashboard (data/id :venues :name)))))) +(deftest should-be-able-to-use-it-when-everything-is-g2g + (is (= {:values [["20th Century Cafe"] + ["25°"] + ["33 Taps"] + ["800 Degrees Neapolitan Pizzeria"] + ["BCD Tofu House"]] + :field_id (data/id :venues :name)} + (with-sharing-enabled-and-temp-dashcard-referencing :venues :name [dashboard] + (-> (http/client :get 200 (field-values-url dashboard (data/id :venues :name))) + (update :values (partial take 5))))))) + +(deftest shound-not-be-able-to-use-the-endpoint-with-a-field-not-referenced-by-the-dashboard + (is (= "An error occurred." + (with-sharing-enabled-and-temp-dashcard-referencing :venues :name [dashboard] + (http/client :get 400 (field-values-url dashboard (data/id :venues :price))))))) + +(deftest endpoint-should-fail-if-public-sharing-is-disabled + (is (= "An error occurred." + (with-sharing-enabled-and-temp-dashcard-referencing :venues :name [dashboard] + (tu/with-temporary-setting-values [enable-public-sharing false] + (http/client :get 400 (field-values-url dashboard (data/id :venues :name)))))))) + ;;; ----------------------------------------------- search-card-fields ----------------------------------------------- -(expect - [[93 "33 Taps"]] - (with-sharing-enabled-and-temp-card-referencing :venues :id [card] - (do ;tu/with-temp-vals-in-db Field (data/id :venues :name) {:special_type "type/Name"} - (public-api/search-card-fields (u/get-id card) (data/id :venues :id) (data/id :venues :name) "33 T" 10)))) -;; shouldn't work if the search-field isn't allowed to be used in combination with the other Field -(expect - Exception - (with-sharing-enabled-and-temp-card-referencing :venues :id [card] - (public-api/search-card-fields (u/get-id card) (data/id :venues :id) (data/id :venues :price) "33 T" 10))) +(deftest search-card-fields + (is (= [[93 "33 Taps"]] + (with-sharing-enabled-and-temp-card-referencing :venues :id [card] + (public-api/search-card-fields (u/get-id card) (data/id :venues :id) (data/id :venues :name) "33 T" 10))))) + + + +(deftest shouldn-t-work-if-the-search-field-isn-t-allowed-to-be-used-in-combination-with-the-other-field + (is (thrown? Exception + (with-sharing-enabled-and-temp-card-referencing :venues :id [card] + (public-api/search-card-fields (u/get-id card) (data/id :venues :id) (data/id :venues :price) "33 T" 10))))) + + + +(deftest shouldn-t-work-if-the-field-isn-t-referenced-by-card + (is (thrown? Exception + (with-sharing-enabled-and-temp-card-referencing :venues :name [card] + (public-api/search-card-fields (u/get-id card) (data/id :venues :id) (data/id :venues :id) "33 T" 10))))) -;; shouldn't work if the field isn't referenced by CARD -(expect - Exception - (with-sharing-enabled-and-temp-card-referencing :venues :name [card] - (public-api/search-card-fields (u/get-id card) (data/id :venues :id) (data/id :venues :id) "33 T" 10))) ;;; ----------------------- GET /api/public/card/:uuid/field/:field-id/search/:search-field-id ----------------------- @@ -893,64 +870,61 @@ "/field/" (u/get-id field-or-id) "/search/" (u/get-id search-field-or-id))) -(expect - [[93 "33 Taps"]] - (with-sharing-enabled-and-temp-card-referencing :venues :id [card] - (http/client :get 200 (field-search-url card (data/id :venues :id) (data/id :venues :name)) - :value "33 T"))) +(deftest field-search-with-venue + (is (= [[93 "33 Taps"]] + (with-sharing-enabled-and-temp-card-referencing :venues :id [card] + (http/client :get 200 (field-search-url card (data/id :venues :id) (data/id :venues :name)) + :value "33 T"))))) + +(deftest if-search-field-isn-t-allowed-to-be-used-with-the-other-field-endpoint-should-return-exception + (is (= "An error occurred." + (with-sharing-enabled-and-temp-card-referencing :venues :id [card] + (http/client :get 400 (field-search-url card (data/id :venues :id) (data/id :venues :price)) + :value "33 T"))))) -;; if search field isn't allowed to be used with the other Field endpoint should return exception -(expect - "An error occurred." - (with-sharing-enabled-and-temp-card-referencing :venues :id [card] - (http/client :get 400 (field-search-url card (data/id :venues :id) (data/id :venues :price)) - :value "33 T"))) +(deftest search-endpoint-should-fail-if-public-sharing-is-disabled + (is (= "An error occurred." + (with-sharing-enabled-and-temp-card-referencing :venues :id [card] + (tu/with-temporary-setting-values [enable-public-sharing false] + (http/client :get 400 (field-search-url card (data/id :venues :id) (data/id :venues :name)) + :value "33 T")))))) -;; Endpoint should fail if public sharing is disabled -(expect - "An error occurred." - (with-sharing-enabled-and-temp-card-referencing :venues :id [card] - (tu/with-temporary-setting-values [enable-public-sharing false] - (http/client :get 400 (field-search-url card (data/id :venues :id) (data/id :venues :name)) - :value "33 T")))) ;;; -------------------- GET /api/public/dashboard/:uuid/field/:field-id/search/:search-field-id --------------------- -(expect - [[93 "33 Taps"]] - (with-sharing-enabled-and-temp-dashcard-referencing :venues :id [dashboard] - (http/client :get (field-search-url dashboard (data/id :venues :id) (data/id :venues :name)) - :value "33 T"))) - -;; if search field isn't allowed to be used with the other Field endpoint should return exception -(expect - "An error occurred." - (with-sharing-enabled-and-temp-dashcard-referencing :venues :id [dashboard] - (http/client :get 400 (field-search-url dashboard (data/id :venues :id) (data/id :venues :price)) - :value "33 T"))) - -;; Endpoint should fail if public sharing is disabled -(expect - "An error occurred." - (with-sharing-enabled-and-temp-dashcard-referencing :venues :id [dashboard] - (tu/with-temporary-setting-values [enable-public-sharing false] - (http/client :get 400 (field-search-url dashboard (data/id :venues :name) (data/id :venues :name)) - :value "33 T")))) + +(deftest dashboard + (is (= [[93 "33 Taps"]] + (with-sharing-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (http/client :get (field-search-url dashboard (data/id :venues :id) (data/id :venues :name)) + :value "33 T"))))) + +(deftest dashboard-if-search-field-isn-t-allowed-to-be-used-with-the-other-field-endpoint-should-return-exception + (is (= "An error occurred." + (with-sharing-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (http/client :get 400 (field-search-url dashboard (data/id :venues :id) (data/id :venues :price)) + :value "33 T"))))) + +(deftest dashboard-endpoint-should-fail-if-public-sharing-is-disabled + (is (= "An error occurred." + (with-sharing-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (tu/with-temporary-setting-values [enable-public-sharing false] + (http/client :get 400 (field-search-url dashboard (data/id :venues :name) (data/id :venues :name)) + :value "33 T")))))) ;;; --------------------------------------------- field-remapped-values ---------------------------------------------- ;; `field-remapped-values` should return remappings in the expected format when the combination of Fields is allowed. ;; It should parse the value string (it comes back from the API as a string since it is a query param) -(expect - [10 "Fred 62"] - (#'public-api/field-remapped-values (data/id :venues :id) (data/id :venues :name) "10")) -;; if the Field isn't allowed -(expect - Exception - (#'public-api/field-remapped-values (data/id :venues :id) (data/id :venues :price) "10")) +(deftest should-parse-string + (is (= [10 "Fred 62"] + (#'public-api/field-remapped-values (data/id :venues :id) (data/id :venues :name) "10")))) +(deftest if-the-field-isn-t-allowed + (is (thrown? Exception + (#'public-api/field-remapped-values (data/id :venues :id) (data/id :venues :price) "10")))) ;;; ----------------------- GET /api/public/card/:uuid/field/:field-id/remapping/:remapped-id ------------------------ @@ -963,63 +937,57 @@ "/field/" (u/get-id field-or-id) "/remapping/" (u/get-id remapped-field-or-id))) -;; we should be able to use the API endpoint and get the same results we get by calling the function above directly -(expect - [10 "Fred 62"] - (with-sharing-enabled-and-temp-card-referencing :venues :id [card] - (http/client :get 200 (field-remapping-url card (data/id :venues :id) (data/id :venues :name)) - :value "10"))) - -;; shouldn't work if Card doesn't reference the Field in question -(expect - "An error occurred." - (with-sharing-enabled-and-temp-card-referencing :venues :price [card] - (http/client :get 400 (field-remapping-url card (data/id :venues :id) (data/id :venues :name)) - :value "10"))) - -;; ...or if the remapping Field isn't allowed to be used with the other Field -(expect - "An error occurred." - (with-sharing-enabled-and-temp-card-referencing :venues :id [card] - (http/client :get 400 (field-remapping-url card (data/id :venues :id) (data/id :venues :price)) - :value "10"))) - -;; ...or if public sharing is disabled -(expect - "An error occurred." - (with-sharing-enabled-and-temp-card-referencing :venues :id [card] - (tu/with-temporary-setting-values [enable-public-sharing false] - (http/client :get 400 (field-remapping-url card (data/id :venues :id) (data/id :venues :name)) - :value "10")))) +(deftest we-should-be-able-to-use-the-api-endpoint-and-get-the-same-results-we-get-by-calling-the-function-above-directly + (is (= [10 "Fred 62"] + (with-sharing-enabled-and-temp-card-referencing :venues :id [card] + (http/client :get 200 (field-remapping-url card (data/id :venues :id) (data/id :venues :name)) + :value "10"))))) + +(deftest shouldn-t-work-if-card-doesn-t-reference-the-field-in-question + (is (= "An error occurred." + (with-sharing-enabled-and-temp-card-referencing :venues :price [card] + (http/client :get 400 (field-remapping-url card (data/id :venues :id) (data/id :venues :name)) + :value "10"))))) + + +(deftest ---or-if-the-remapping-field-isn-t-allowed-to-be-used-with-the-other-field + (is (= "An error occurred." + (with-sharing-enabled-and-temp-card-referencing :venues :id [card] + (http/client :get 400 (field-remapping-url card (data/id :venues :id) (data/id :venues :price)) + :value "10"))))) + +(deftest ---or-if-public-sharing-is-disabled + (is (= "An error occurred." + (with-sharing-enabled-and-temp-card-referencing :venues :id [card] + (tu/with-temporary-setting-values [enable-public-sharing false] + (http/client :get 400 (field-remapping-url card (data/id :venues :id) (data/id :venues :name)) + :value "10")))))) ;;; --------------------- GET /api/public/dashboard/:uuid/field/:field-id/remapping/:remapped-id --------------------- -;; we should be able to use the API endpoint and get the same results we get by calling the function above directly -(expect - [10 "Fred 62"] - (with-sharing-enabled-and-temp-dashcard-referencing :venues :id [dashboard] - (http/client :get 200 (field-remapping-url dashboard (data/id :venues :id) (data/id :venues :name)) - :value "10"))) - -;; shouldn't work if Card doesn't reference the Field in question -(expect - "An error occurred." - (with-sharing-enabled-and-temp-dashcard-referencing :venues :price [dashboard] - (http/client :get 400 (field-remapping-url dashboard (data/id :venues :id) (data/id :venues :name)) - :value "10"))) - -;; ...or if the remapping Field isn't allowed to be used with the other Field -(expect - "An error occurred." - (with-sharing-enabled-and-temp-dashcard-referencing :venues :id [dashboard] - (http/client :get 400 (field-remapping-url dashboard (data/id :venues :id) (data/id :venues :price)) - :value "10"))) - -;; ...or if public sharing is disabled -(expect - "An error occurred." - (with-sharing-enabled-and-temp-dashcard-referencing :venues :id [dashboard] - (tu/with-temporary-setting-values [enable-public-sharing false] - (http/client :get 400 (field-remapping-url dashboard (data/id :venues :id) (data/id :venues :name)) - :value "10")))) + +(deftest api-endpoint-should-return-same-results-as-function + (is (= [10 "Fred 62"] + (with-sharing-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (http/client :get 200 (field-remapping-url dashboard (data/id :venues :id) (data/id :venues :name)) + :value "10"))))) + +(deftest field-remapping-shouldn-t-work-if-card-doesn-t-reference-the-field-in-question + (is (= "An error occurred." + (with-sharing-enabled-and-temp-dashcard-referencing :venues :price [dashboard] + (http/client :get 400 (field-remapping-url dashboard (data/id :venues :id) (data/id :venues :name)) + :value "10"))))) + +(deftest remapping-or-if-the-remapping-field-isn-t-allowed-to-be-used-with-the-other-field + (is (= "An error occurred." + (with-sharing-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (http/client :get 400 (field-remapping-url dashboard (data/id :venues :id) (data/id :venues :price)) + :value "10"))))) + +(deftest remapping-or-if-public-sharing-is-disabled + (is (= "An error occurred." + (with-sharing-enabled-and-temp-dashcard-referencing :venues :id [dashboard] + (tu/with-temporary-setting-values [enable-public-sharing false] + (http/client :get 400 (field-remapping-url dashboard (data/id :venues :id) (data/id :venues :name)) + :value "10")))))) diff --git a/test/metabase/api/table_test.clj b/test/metabase/api/table_test.clj index 6ebd84328f180b1dae22050a00878940a6f31bf4..8097a34fa2ee44892d4e0db0fbe18ca7f92e8c81 100644 --- a/test/metabase/api/table_test.clj +++ b/test/metabase/api/table_test.clj @@ -451,7 +451,7 @@ :fingerprint (:latitude mutil/venue-fingerprints)})])}) (do ;; run the Card which will populate its result_metadata column - ((test-users/user->client :crowberto) :post 200 (format "card/%d/query" (u/get-id card))) + ((test-users/user->client :crowberto) :post 202 (format "card/%d/query" (u/get-id card))) ;; Now fetch the metadata for this "table" (->> card u/get-id @@ -499,7 +499,7 @@ :latest "2014-12-05T15:15:00"}}}}]}) (do ;; run the Card which will populate its result_metadata column - ((test-users/user->client :crowberto) :post 200 (format "card/%d/query" (u/get-id card))) + ((test-users/user->client :crowberto) :post 202 (format "card/%d/query" (u/get-id card))) ;; Now fetch the metadata for this "table" (->> card u/get-id @@ -679,7 +679,7 @@ :type :query :query {:source-query {:source-table (data/id :venues)}}}}] ;; run the Card which will populate its result_metadata column - ((test-users/user->client :crowberto) :post 200 (format "card/%d/query" (u/get-id card))) + ((test-users/user->client :crowberto) :post 202 (format "card/%d/query" (u/get-id card))) (let [response ((test-users/user->client :crowberto) :get 200 (format "table/card__%d/query_metadata" (u/get-id card)))] (map #(dimension-options-for-field response %) ["latitude" "longitude"])))) diff --git a/test/metabase/async/api_response_test.clj b/test/metabase/async/api_response_test.clj index 03b6605aee32dbbc7216de3f9f2a295be81f5578..66c6bf73911ce3d91cc3ddf35d8cd10179e1de37 100644 --- a/test/metabase/async/api_response_test.clj +++ b/test/metabase/async/api_response_test.clj @@ -121,9 +121,10 @@ ;; An error should be written to the output stream (expect - {:message "Input channel unexpectedly closed." - :type "class java.lang.InterruptedException" - :stacktrace true} + {:message "Input channel unexpectedly closed." + :type "class java.lang.InterruptedException" + :_status 500 + :stacktrace true} (tu.async/with-chans [input-chan] (with-response [{:keys [os os-closed-chan]} input-chan] (a/close! input-chan) @@ -176,7 +177,7 @@ ;; If the message sent to input-chan is an Exception an appropriate response should be generated (expect - {:message "Broken", :type "class java.lang.Exception", :stacktrace true} + {:message "Broken", :type "class java.lang.Exception", :stacktrace true, :_status 500} (tu.async/with-chans [input-chan] (with-response [{:keys [os os-closed-chan]} input-chan] (a/>!! input-chan (Exception. "Broken")) @@ -189,9 +190,10 @@ ;; If we write a bad API endpoint and return a channel but never write to it, the request should be canceled after ;; `absolute-max-keepalive-ms` (expect - {:message "No response after waiting 500.0 ms. Canceling request." - :type "class java.util.concurrent.TimeoutException" - :stacktrace true} + {:message "No response after waiting 500.0 ms. Canceling request." + :type "class java.util.concurrent.TimeoutException" + :_status 500 + :stacktrace true} (with-redefs [async-response/absolute-max-keepalive-ms 500] (tu.async/with-chans [input-chan] (with-response [{:keys [os os-closed-chan]} input-chan] diff --git a/test/metabase/db/fix_mysql_utf8_test.clj b/test/metabase/db/fix_mysql_utf8_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..a6cfc5d92cd13b739fe3fca4836648a3f8d0d9a5 --- /dev/null +++ b/test/metabase/db/fix_mysql_utf8_test.clj @@ -0,0 +1,111 @@ +(ns metabase.db.fix-mysql-utf8-test + (:require [clojure + [string :as str] + [test :refer :all]] + [clojure.java.jdbc :as jdbc] + [metabase + [db :as mdb] + [models :refer [Database]] + [test :as mt]] + [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn] + [toucan.db :as db])) + +(defn- create-test-db! [] + (jdbc/with-db-connection [server-conn (sql-jdbc.conn/connection-details->spec :mysql + (mt/dbdef->connection-details :mysql :server nil))] + (doseq [statement ["DROP DATABASE IF EXISTS utf8_test;" + "CREATE DATABASE utf8_test;"]] + (jdbc/execute! server-conn statement)))) + +(defn- test-db-spec [] + (sql-jdbc.conn/connection-details->spec :mysql + (mt/dbdef->connection-details :mysql :db {:database-name "utf8_test"}))) + +(defn- convert-to-charset! + "Convert a MySQL/MariaDB database to the `latin1` character set." + [jdbc-spec charset collation] + (jdbc/with-db-connection [conn jdbc-spec] + (doseq [statement [(format "ALTER DATABASE utf8_test CHARACTER SET = %s COLLATE = %s;" charset collation) + (format "ALTER TABLE metabase_database CONVERT TO CHARACTER SET %s COLLATE %s;" charset collation)]] + (jdbc/execute! jdbc-spec [statement])))) + +(defn- remove-utf8mb4-migrations! + "Remove the entries for the migrations that convert a DB to utf8mb4 from the Liquibase migration log so they can be + ran again." + [jdbc-spec] + (jdbc/execute! jdbc-spec [(format "DELETE FROM `DATABASECHANGELOG` WHERE ID IN (%s);" + (str/join "," (map #(str \' % \') + (range 107 161))))])) + +(defn- db-charset [] + (first (jdbc/query db/*db-connection* + (str "SELECT default_collation_name AS `collation`, default_character_set_name AS `character-set` " + "FROM information_schema.SCHEMATA " + "WHERE schema_name = 'utf8_test';")))) + +(defn- table-charset [] + (first (jdbc/query db/*db-connection* + (str "SELECT ccsa.collation_name AS `collation`, ccsa.character_set_name AS `character-set` " + "FROM information_schema.`TABLES` t," + " information_schema.`COLLATION_CHARACTER_SET_APPLICABILITY` ccsa " + "WHERE ccsa.collation_name = t.table_collation" + " AND t.table_schema = 'utf8_test' " + " AND t.table_name = 'metabase_database';")))) + +(defn- column-charset [] + (first (jdbc/query db/*db-connection* + (str "SELECT collation_name AS `collation`, character_set_name AS `character-set` " + "FROM information_schema.`COLUMNS` " + "WHERE table_schema = 'utf8_test'" + " AND table_name = 'metabase_database'" + "AND column_name = 'name';")))) + +(def ^:private test-unicode-str "Cam ðŒ† Saul 💩") + +(defn- insert-row! [] + (jdbc/execute! db/*db-connection* [(str "INSERT INTO metabase_database (engine, name, created_at, updated_at) " + "VALUES ('mysql', ?, current_timestamp, current_timestamp)") + test-unicode-str])) + +;; The basic idea behind this test is: +;; +;; 1. Create a new application DB; convert the DB to `latin1` or `utf8` (effectively rolling back migrations 107-160), +;; then verify that utf-8 is now broken. (This simulates the state app DBs are in before this fix) +;; +;; 2. Now run the migrations again and verify that things are fixed +(deftest utf8-test + (mt/test-driver :mysql + (testing "Migrations 107-160\n" + (doseq [{:keys [charset collation]} [{:charset "utf8", :collation "utf8_general_ci"} + {:charset "latin1", :collation "latin1_swedish_ci"}]] + ;; create a new application DB and run migrations. + (create-test-db!) + (jdbc/with-db-connection [conn-spec (test-db-spec)] + (mdb/migrate! conn-spec :up) + (testing (format "Migrating %s charset -> utf8mb4\n" charset) + ;; Roll back the DB to act as if migrations 107-160 had never been ran + (convert-to-charset! conn-spec charset collation) + (remove-utf8mb4-migrations! conn-spec) + (binding [db/*db-connection* conn-spec] + (testing (format "DB without migrations 107-160: UTF-8 shouldn't work when using the '%s' character set" charset) + (is (= {:character-set charset, :collation collation} + (db-charset) + (table-charset) + (column-charset)) + (format "Make sure we converted the DB to %s correctly" charset)) + (is (thrown? + Exception + (insert-row!)) + "Shouldn't be able to insert UTF-8 values")) + + (testing "If we run the migrations 107-160 then the DB should get converted to utf8mb4" + (mdb/migrate! conn-spec :up) + (is (= {:character-set "utf8mb4", :collation "utf8mb4_unicode_ci"} + (db-charset) + (table-charset) + (column-charset)) + "DB should be converted back to `utf8mb4` after running migrations") + (testing "We should be able to insert UTF-8 values" + (insert-row!) + (is (= test-unicode-str + (db/select-one-field :name Database :name test-unicode-str)))))))))))) diff --git a/test/metabase/http_client.clj b/test/metabase/http_client.clj index 05f4fd4fe073fbd367a03f5aec99d29a360ac0f0..4c82bf5c8543614955384a850281263d867216ea 100644 --- a/test/metabase/http_client.clj +++ b/test/metabase/http_client.clj @@ -127,7 +127,7 @@ (throw (ex-info message {:status-code actual-status-code})))) ;; all other status codes should be test assertions against the expected status code if one was specified (when expected-status-code - (t/is (= actual-status-code expected-status-code) + (t/is (= expected-status-code actual-status-code) (format "%s %s expected a status code of %d, got %d." method-name url expected-status-code actual-status-code)))) diff --git a/test/metabase/query_processor/middleware/results_metadata_test.clj b/test/metabase/query_processor/middleware/results_metadata_test.clj index fa93aa831a9bd3be14cf825dbf6c5a27dfe6b25a..21389663ae15314a617695aa6f9ac6ec4f9416d3 100644 --- a/test/metabase/query_processor/middleware/results_metadata_test.clj +++ b/test/metabase/query_processor/middleware/results_metadata_test.clj @@ -87,7 +87,7 @@ :dataset_query (native-query "SELECT * FROM VENUES") :result_metadata [{:name "NAME", :display_name "Name", :base_type "type/Text"}]}]] (perms/grant-collection-read-permissions! (group/all-users) collection) - ((users/user->client :rasta) :post 200 "dataset" {:database mbql.s/saved-questions-virtual-database-id + ((users/user->client :rasta) :post 202 "dataset" {:database mbql.s/saved-questions-virtual-database-id :type :query :query {:source-table (str "card__" (u/get-id card))}}) (card-metadata card))) diff --git a/test/metabase/query_processor_test/date_bucketing_test.clj b/test/metabase/query_processor_test/date_bucketing_test.clj index c2a97e5249320d3a1f7e9142ffc4c0008fef10b2..e08a9ec18e61d1c50d13df895e7a19a9d1ac7e0d 100644 --- a/test/metabase/query_processor_test/date_bucketing_test.clj +++ b/test/metabase/query_processor_test/date_bucketing_test.clj @@ -862,10 +862,10 @@ ;; generating Java classes here so they'll be in the DB's native timezone. Some DBs refuse to use ;; the same timezone we're running the tests from *cough* SQL Server *cough* [(u/prog1 (if (isa? driver/hierarchy driver/*driver* :sql) - (driver/date-add driver/*driver* - (sql.qp/current-datetime-fn driver/*driver*) - (* i interval-seconds) - :second) + (sql.qp/add-interval-honeysql-form driver/*driver* + (sql.qp/current-datetime-honeysql-form driver/*driver*) + (* i interval-seconds) + :second) (u.date/add :second (* i interval-seconds))) (assert <>))]))]))) diff --git a/test/metabase/query_processor_test/nested_queries_test.clj b/test/metabase/query_processor_test/nested_queries_test.clj index 6e9a61d270e85f6a332e87cfeccb9028797a8695..e59331fa620d724bc1566eacbf5397c1ab63ecb1 100644 --- a/test/metabase/query_processor_test/nested_queries_test.clj +++ b/test/metabase/query_processor_test/nested_queries_test.clj @@ -521,7 +521,7 @@ Collection [dest-card-collection]] (perms/grant-collection-read-permissions! (group/all-users) source-card-collection) (perms/grant-collection-readwrite-permissions! (group/all-users) dest-card-collection) - (save-card-via-API-with-native-source-query! 200 (data/db) source-card-collection dest-card-collection) + (save-card-via-API-with-native-source-query! 202 (data/db) source-card-collection dest-card-collection) :ok)))) ;; however, if we do *not* have read permissions for the source Card's collection we shouldn't be allowed to save the