From 4a8a2d176c8fd9700df355098b6a497ece8c2eec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cam=20Sa=C3=BCl?= <cammsaul@gmail.com> Date: Tue, 8 Nov 2016 16:14:41 -0800 Subject: [PATCH] Google Analytics Driver :bar_chart: [ci all] --- .../src/metabase/components/AccordianList.jsx | 29 +- .../components/DatabaseDetailsForm.jsx | 26 +- .../metabase/components/ListSearchField.jsx | 11 +- frontend/src/metabase/lib/card.js | 4 +- frontend/src/metabase/lib/engine.js | 53 ++ frontend/src/metabase/lib/ga-metadata.js | 776 ++++++++++++++++++ frontend/src/metabase/lib/query.js | 16 +- frontend/src/metabase/lib/schema_metadata.js | 113 +-- frontend/src/metabase/meta/types/Dataset.js | 2 +- .../src/metabase/query_builder/actions.js | 5 +- .../components/AggregationPopover.jsx | 22 +- .../components/AggregationWidget.jsx | 4 + .../query_builder/components/FieldList.jsx | 1 + .../components/NativeQueryEditor.jsx | 12 +- .../components/QueryDefinitionTooltip.jsx | 40 +- .../components/QueryModeButton.jsx | 8 +- .../components/TimeGroupingPopover.jsx | 38 +- .../src/metabase/query_builder/selectors.js | 4 +- frontend/src/metabase/services.js | 14 +- package.json | 1 + project.clj | 2 + src/metabase/db/metadata_queries.clj | 24 +- src/metabase/driver.clj | 1 + src/metabase/driver/bigquery.clj | 84 +- src/metabase/driver/druid.clj | 2 +- src/metabase/driver/generic_sql.clj | 3 +- src/metabase/driver/google.clj | 92 +++ src/metabase/driver/googleanalytics.clj | 267 ++++++ .../googleanalytics/query_processor.clj | 324 ++++++++ src/metabase/driver/h2.clj | 6 +- src/metabase/driver/mongo.clj | 17 +- src/metabase/query_processor.clj | 2 +- src/metabase/query_processor/annotate.clj | 19 +- src/metabase/query_processor/expand.clj | 2 +- src/metabase/sync_database/sync.clj | 3 +- src/metabase/util.clj | 70 +- .../metabase/driver/google_analytics_test.clj | 182 ++++ test/metabase/test/data/bigquery.clj | 69 +- yarn.lock | 4 + 39 files changed, 2058 insertions(+), 294 deletions(-) create mode 100644 frontend/src/metabase/lib/engine.js create mode 100644 frontend/src/metabase/lib/ga-metadata.js create mode 100644 src/metabase/driver/google.clj create mode 100644 src/metabase/driver/googleanalytics.clj create mode 100644 src/metabase/driver/googleanalytics/query_processor.clj create mode 100644 test/metabase/driver/google_analytics_test.clj diff --git a/frontend/src/metabase/components/AccordianList.jsx b/frontend/src/metabase/components/AccordianList.jsx index 3533fd1035b..7f4d5b90890 100644 --- a/frontend/src/metabase/components/AccordianList.jsx +++ b/frontend/src/metabase/components/AccordianList.jsx @@ -1,4 +1,6 @@ import React, { Component, PropTypes } from "react"; +import ReactDOM from "react-dom"; + import cx from "classnames"; import _ from "underscore"; @@ -36,7 +38,7 @@ export default class AccordianList extends Component { static propTypes = { id: PropTypes.string, sections: PropTypes.array.isRequired, - searchable: PropTypes.bool, + searchable: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), initiallyOpenSection: PropTypes.number, openSection: PropTypes.number, onChange: PropTypes.func, @@ -53,12 +55,20 @@ export default class AccordianList extends Component { static defaultProps = { style: {}, - searchable: false, + searchable: (section) => section.items.length > 10, alwaysTogglable: false, alwaysExpanded: false, - hideSingleSectionTitle: false + hideSingleSectionTitle: false, }; + componentDidMount() { + // when the component is mounted and an item is selected then scroll to it + const element = this.refs.selected && ReactDOM.findDOMNode(this.refs.selected); + if (element) { + element.scrollIntoView(); + } + } + toggleSection(sectionIndex) { if (this.props.onChangeSection) { if (this.props.onChangeSection(sectionIndex) === false) { @@ -160,6 +170,8 @@ export default class AccordianList extends Component { const openSection = this.getOpenSection(); const sectionIsOpen = (sectionIndex) => alwaysExpanded || openSection === sectionIndex; + const sectionIsSearchable = (sectionIndex) => + searchable && (typeof searchable !== "function" || searchable(sections[sectionIndex])); return ( <div id={id} className={this.props.className} style={{ width: '300px', ...style }}> @@ -176,7 +188,7 @@ export default class AccordianList extends Component { <div className="List-section-header px1 py1 cursor-pointer full flex align-center" onClick={() => this.toggleSection(sectionIndex)}> { this.renderSectionIcon(section, sectionIndex) } <h3 className="List-section-title">{section.name}</h3> - { section.items.length > 0 && + { sections.length > 1 && <span className="flex-align-right"> <Icon name={sectionIsOpen(sectionIndex) ? "chevronup" : "chevrondown"} size={12} /> </span> @@ -191,7 +203,7 @@ export default class AccordianList extends Component { </div> : null } - { searchable && + { sectionIsSearchable(sectionIndex) && sectionIsOpen(sectionIndex) && section.items.length > 0 && /* NOTE: much of this structure is here just to match strange stuff in 'List-item' below so things align properly */ <div className="px1 pt1"> <div style={{border: "2px solid transparent", borderRadius: "6px"}}> @@ -199,6 +211,7 @@ export default class AccordianList extends Component { onChange={(val) => this.setState({searchText: val})} searchText={this.state.searchText} placeholder={searchPlaceholder} + autoFocus /> </div> </div> @@ -210,7 +223,11 @@ export default class AccordianList extends Component { className={cx("p1", { "border-bottom scroll-y scroll-show": !alwaysExpanded })} > { section.items.filter((i) => searchText ? (i.name.toLowerCase().includes(searchText.toLowerCase())) : true ).map((item, itemIndex) => - <li key={itemIndex} className={cx("List-item flex", { 'List-item--selected': this.itemIsSelected(item, itemIndex), 'List-item--disabled': !this.itemIsClickable(item) }, this.getItemClasses(item, itemIndex))}> + <li + key={itemIndex} + ref={this.itemIsSelected(item, itemIndex) ? "selected" : null} + className={cx("List-item flex", { 'List-item--selected': this.itemIsSelected(item, itemIndex), 'List-item--disabled': !this.itemIsClickable(item) }, this.getItemClasses(item, itemIndex))} + > <a className={cx("flex-full flex align-center px1", this.itemIsClickable(item) ? "cursor-pointer" : "cursor-default")} style={{ paddingTop: "0.25rem", paddingBottom: "0.25rem" }} diff --git a/frontend/src/metabase/components/DatabaseDetailsForm.jsx b/frontend/src/metabase/components/DatabaseDetailsForm.jsx index 2e9d4fc1ef4..8a8e26de783 100644 --- a/frontend/src/metabase/components/DatabaseDetailsForm.jsx +++ b/frontend/src/metabase/components/DatabaseDetailsForm.jsx @@ -12,6 +12,16 @@ function isEmpty(str) { return (!str || 0 === str.length); } +const AUTH_URL_PREFIXES = { + bigquery: 'https://accounts.google.com/o/oauth2/auth?redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=https://www.googleapis.com/auth/bigquery&client_id=', + googleanalytics: 'https://accounts.google.com/o/oauth2/auth?access_type=offline&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=https://www.googleapis.com/auth/analytics.readonly&client_id=', +}; + +const CREDENTIALS_URL_PREFIXES = { + bigquery: 'https://console.developers.google.com/apis/credentials/oauthclient?project=', + googleanalytics: 'https://console.developers.google.com/apis/credentials/oauthclient?project=', +}; + /** * This is a form for capturing database details for a given `engine` supplied via props. * The intention is to encapsulate the entire <form> with standard MB form styling and allow a callback @@ -153,14 +163,12 @@ export default class DatabaseDetailsForm extends Component { </div> </FormField> ); - } else if (engine === 'bigquery' && field.name === 'client-id') { - const CREDENTIALS_URL_PREFIX = 'https://console.developers.google.com/apis/credentials/oauthclient?project='; - + } else if (field.name === 'client-id' && CREDENTIALS_URL_PREFIXES[engine]) { let { details } = this.state; let projectID = details && details['project-id']; var credentialsURLLink; - if (projectID) { - let credentialsURL = CREDENTIALS_URL_PREFIX + projectID; + // if (projectID) { + let credentialsURL = CREDENTIALS_URL_PREFIXES[engine] + (projectID || ""); credentialsURLLink = ( <div className="flex align-center Form-offset"> <div className="Grid-cell--top"> @@ -168,7 +176,7 @@ export default class DatabaseDetailsForm extends Component { Choose "Other" as the application type. Name it whatever you'd like. </div> </div>); - } + // } return ( <FormField key='client-id' field-name='client-id'> @@ -177,14 +185,12 @@ export default class DatabaseDetailsForm extends Component { {this.renderFieldInput(field, fieldIndex)} </FormField> ); - } else if (engine === 'bigquery' && field.name === 'auth-code') { - const AUTH_URL_PREFIX = 'https://accounts.google.com/o/oauth2/auth?redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=https://www.googleapis.com/auth/bigquery&client_id='; - + } else if (field.name === 'auth-code' && AUTH_URL_PREFIXES[engine]) { let { details } = this.state; let clientID = details && details['client-id']; var authURLLink; if (clientID) { - let authURL = AUTH_URL_PREFIX + clientID; + let authURL = AUTH_URL_PREFIXES[engine] + clientID; authURLLink = ( <div className="flex align-center Form-offset"> <div className="Grid-cell--top"> diff --git a/frontend/src/metabase/components/ListSearchField.jsx b/frontend/src/metabase/components/ListSearchField.jsx index 1488bf95a3a..0783744264a 100644 --- a/frontend/src/metabase/components/ListSearchField.jsx +++ b/frontend/src/metabase/components/ListSearchField.jsx @@ -8,18 +8,20 @@ export default class ListSearchField extends Component { static propTypes = { onChange: PropTypes.func.isRequired, placeholder: PropTypes.string, - searchText: PropTypes.string + searchText: PropTypes.string, + autoFocus: PropTypes.bool }; static defaultProps = { className: "bordered rounded text-grey-2 flex flex-full align-center", inputClassName: "p1 h4 input--borderless text-default flex-full", - placeholder: "Find a table", - searchText: "" + placeholder: "Find...", + searchText: "", + autoFocus: false }; render() { - const { className, inputClassName, onChange, placeholder, searchText } = this.props; + const { className, inputClassName, onChange, placeholder, searchText, autoFocus } = this.props; return ( <div className={className}> @@ -32,6 +34,7 @@ export default class ListSearchField extends Component { placeholder={placeholder} value={searchText} onChange={(e) => onChange(e.target.value)} + autoFocus={autoFocus} /> </div> ); diff --git a/frontend/src/metabase/lib/card.js b/frontend/src/metabase/lib/card.js index 0aa07eb8343..26a686156b0 100644 --- a/frontend/src/metabase/lib/card.js +++ b/frontend/src/metabase/lib/card.js @@ -61,13 +61,13 @@ export function isCardDirty(card, originalCard) { } } -export function isCardRunnable(card) { +export function isCardRunnable(card, tableMetadata) { if (!card) { return false; } const query = card.dataset_query; if (query.query) { - return Query.canRun(query.query); + return Query.canRun(query.query, tableMetadata); } else { return (query.database != undefined && query.native.query !== ""); } diff --git a/frontend/src/metabase/lib/engine.js b/frontend/src/metabase/lib/engine.js new file mode 100644 index 00000000000..9d42c15783c --- /dev/null +++ b/frontend/src/metabase/lib/engine.js @@ -0,0 +1,53 @@ + +export function getEngineNativeType(engine) { + switch (engine) { + case "mongo": + case "druid": + case "googleanalytics": + return "json"; + default: + return "sql"; + } +} + +export function getEngineNativeAceMode(engine) { + switch (engine) { + case "mongo": + case "druid": + case "googleanalytics": + return "ace/mode/json"; + case "mysql": + return "ace/mode/mysql"; + case "postgres": + return "ace/mode/pgsql"; + case "sqlserver": + return "ace/mode/sqlserver"; + default: + return "ace/mode/sql"; + } +} + +export function getEngineNativeRequiresTable(engine) { + return engine === "mongo"; +} + +export function formatJsonQuery(query, engine) { + if (engine === "googleanalytics") { + return formatGAQuery(query); + } else { + return JSON.stringify(query); + } +} + +const GA_ORDERED_PARAMS = ["ids", "start-date", "end-date", "metrics", "dimensions", "sort", "filters", "segment", "samplingLevel", "include-empty-rows", "start-index", "max-results"]; + +// does 3 things: removes null values, sorts the keys by the order in the documentation, and formats with 2 space indents +function formatGAQuery(query) { + const object = {}; + for (const param of GA_ORDERED_PARAMS) { + if (query[param] != null) { + object[param] = query[param]; + } + } + return JSON.stringify(object, null, 2); +} diff --git a/frontend/src/metabase/lib/ga-metadata.js b/frontend/src/metabase/lib/ga-metadata.js new file mode 100644 index 00000000000..847fbd77a79 --- /dev/null +++ b/frontend/src/metabase/lib/ga-metadata.js @@ -0,0 +1,776 @@ +export const fields = { 'ga:userType': { section: 'User', can_filter: true, can_breakout: true }, + 'ga:sessionCount': { section: 'User', can_filter: true, can_breakout: true }, + 'ga:daysSinceLastSession': { section: 'User', can_filter: true, can_breakout: true }, + 'ga:userDefinedValue': { section: 'User', can_filter: true, can_breakout: true }, + 'ga:users': { section: 'User', can_filter: true, can_breakout: false }, + 'ga:newUsers': { section: 'User', can_filter: true, can_breakout: false }, + 'ga:percentNewSessions': { section: 'User', can_filter: true, can_breakout: false }, + 'ga:1dayUsers': { section: 'User', can_filter: true, can_breakout: false }, + 'ga:7dayUsers': { section: 'User', can_filter: true, can_breakout: false }, + 'ga:14dayUsers': { section: 'User', can_filter: true, can_breakout: false }, + 'ga:30dayUsers': { section: 'User', can_filter: true, can_breakout: false }, + 'ga:sessionDurationBucket': { section: 'Session', can_filter: true, can_breakout: true }, + 'ga:sessions': { section: 'Session', can_filter: true, can_breakout: false }, + 'ga:bounces': { section: 'Session', can_filter: true, can_breakout: false }, + 'ga:bounceRate': { section: 'Session', can_filter: true, can_breakout: false }, + 'ga:sessionDuration': { section: 'Session', can_filter: true, can_breakout: false }, + 'ga:avgSessionDuration': { section: 'Session', can_filter: true, can_breakout: false }, + 'ga:referralPath': { section: 'Traffic Sources', can_filter: true, can_breakout: true }, + 'ga:fullReferrer': { section: 'Traffic Sources', can_filter: true, can_breakout: true }, + 'ga:campaign': { section: 'Traffic Sources', can_filter: true, can_breakout: true }, + 'ga:source': { section: 'Traffic Sources', can_filter: true, can_breakout: true }, + 'ga:medium': { section: 'Traffic Sources', can_filter: true, can_breakout: true }, + 'ga:sourceMedium': { section: 'Traffic Sources', can_filter: true, can_breakout: true }, + 'ga:keyword': { section: 'Traffic Sources', can_filter: true, can_breakout: true }, + 'ga:adContent': { section: 'Traffic Sources', can_filter: true, can_breakout: true }, + 'ga:socialNetwork': { section: 'Traffic Sources', can_filter: true, can_breakout: true }, + 'ga:hasSocialSourceReferral': { section: 'Traffic Sources', can_filter: true, can_breakout: true }, + 'ga:organicSearches': { section: 'Traffic Sources', can_filter: true, can_breakout: false }, + 'ga:adGroup': { section: 'Adwords', can_filter: true, can_breakout: true }, + 'ga:adSlot': { section: 'Adwords', can_filter: true, can_breakout: true }, + 'ga:adDistributionNetwork': { section: 'Adwords', can_filter: true, can_breakout: true }, + 'ga:adMatchType': { section: 'Adwords', can_filter: true, can_breakout: true }, + 'ga:adKeywordMatchType': { section: 'Adwords', can_filter: true, can_breakout: true }, + 'ga:adMatchedQuery': { section: 'Adwords', can_filter: true, can_breakout: true }, + 'ga:adPlacementDomain': { section: 'Adwords', can_filter: true, can_breakout: true }, + 'ga:adPlacementUrl': { section: 'Adwords', can_filter: true, can_breakout: true }, + 'ga:adFormat': { section: 'Adwords', can_filter: true, can_breakout: true }, + 'ga:adTargetingType': { section: 'Adwords', can_filter: true, can_breakout: true }, + 'ga:adTargetingOption': { section: 'Adwords', can_filter: true, can_breakout: true }, + 'ga:adDisplayUrl': { section: 'Adwords', can_filter: true, can_breakout: true }, + 'ga:adDestinationUrl': { section: 'Adwords', can_filter: true, can_breakout: true }, + 'ga:adwordsCustomerID': { section: 'Adwords', can_filter: true, can_breakout: true }, + 'ga:adwordsCampaignID': { section: 'Adwords', can_filter: true, can_breakout: true }, + 'ga:adwordsAdGroupID': { section: 'Adwords', can_filter: true, can_breakout: true }, + 'ga:adwordsCreativeID': { section: 'Adwords', can_filter: true, can_breakout: true }, + 'ga:adwordsCriteriaID': { section: 'Adwords', can_filter: true, can_breakout: true }, + 'ga:impressions': { section: 'Adwords', can_filter: true, can_breakout: false }, + 'ga:adClicks': { section: 'Adwords', can_filter: true, can_breakout: false }, + 'ga:adCost': { section: 'Adwords', can_filter: true, can_breakout: false }, + 'ga:CPM': { section: 'Adwords', can_filter: true, can_breakout: false }, + 'ga:CPC': { section: 'Adwords', can_filter: true, can_breakout: false }, + 'ga:CTR': { section: 'Adwords', can_filter: true, can_breakout: false }, + 'ga:costPerTransaction': { section: 'Adwords', can_filter: true, can_breakout: false }, + 'ga:costPerGoalConversion': { section: 'Adwords', can_filter: true, can_breakout: false }, + 'ga:costPerConversion': { section: 'Adwords', can_filter: true, can_breakout: false }, + 'ga:RPC': { section: 'Adwords', can_filter: true, can_breakout: false }, + 'ga:ROAS': { section: 'Adwords', can_filter: true, can_breakout: false }, + 'ga:adQueryWordCount': { section: 'Adwords', can_filter: true, can_breakout: true }, + 'ga:goalCompletionLocation': { section: 'Goal Conversions', can_filter: true, can_breakout: true }, + 'ga:goalPreviousStep1': { section: 'Goal Conversions', can_filter: true, can_breakout: true }, + 'ga:goalPreviousStep2': { section: 'Goal Conversions', can_filter: true, can_breakout: true }, + 'ga:goalPreviousStep3': { section: 'Goal Conversions', can_filter: true, can_breakout: true }, + 'ga:goalXXStarts': { section: 'Goal Conversions', can_filter: true, can_breakout: false }, + 'ga:goalStartsAll': { section: 'Goal Conversions', can_filter: true, can_breakout: false }, + 'ga:goalXXCompletions': { section: 'Goal Conversions', can_filter: true, can_breakout: false }, + 'ga:goalCompletionsAll': { section: 'Goal Conversions', can_filter: true, can_breakout: false }, + 'ga:goalXXValue': { section: 'Goal Conversions', can_filter: true, can_breakout: false }, + 'ga:goalValueAll': { section: 'Goal Conversions', can_filter: true, can_breakout: false }, + 'ga:goalValuePerSession': { section: 'Goal Conversions', can_filter: true, can_breakout: false }, + 'ga:goalXXConversionRate': { section: 'Goal Conversions', can_filter: true, can_breakout: false }, + 'ga:goalConversionRateAll': { section: 'Goal Conversions', can_filter: true, can_breakout: false }, + 'ga:goalXXAbandons': { section: 'Goal Conversions', can_filter: true, can_breakout: false }, + 'ga:goalAbandonsAll': { section: 'Goal Conversions', can_filter: true, can_breakout: false }, + 'ga:goalXXAbandonRate': { section: 'Goal Conversions', can_filter: true, can_breakout: false }, + 'ga:goalAbandonRateAll': { section: 'Goal Conversions', can_filter: true, can_breakout: false }, + 'ga:browser': { section: 'Platform or Device', can_filter: true, can_breakout: true }, + 'ga:browserVersion': { section: 'Platform or Device', can_filter: true, can_breakout: true }, + 'ga:operatingSystem': { section: 'Platform or Device', can_filter: true, can_breakout: true }, + 'ga:operatingSystemVersion': { section: 'Platform or Device', can_filter: true, can_breakout: true }, + 'ga:mobileDeviceBranding': { section: 'Platform or Device', can_filter: true, can_breakout: true }, + 'ga:mobileDeviceModel': { section: 'Platform or Device', can_filter: true, can_breakout: true }, + 'ga:mobileInputSelector': { section: 'Platform or Device', can_filter: true, can_breakout: true }, + 'ga:mobileDeviceInfo': { section: 'Platform or Device', can_filter: true, can_breakout: true }, + 'ga:mobileDeviceMarketingName': { section: 'Platform or Device', can_filter: true, can_breakout: true }, + 'ga:deviceCategory': { section: 'Platform or Device', can_filter: true, can_breakout: true }, + 'ga:continent': { section: 'Geo Network', can_filter: true, can_breakout: true }, + 'ga:subContinent': { section: 'Geo Network', can_filter: true, can_breakout: true }, + 'ga:country': { section: 'Geo Network', can_filter: true, can_breakout: true }, + 'ga:region': { section: 'Geo Network', can_filter: true, can_breakout: true }, + 'ga:metro': { section: 'Geo Network', can_filter: true, can_breakout: true }, + 'ga:city': { section: 'Geo Network', can_filter: true, can_breakout: true }, + 'ga:latitude': { section: 'Geo Network', can_filter: true, can_breakout: true }, + 'ga:longitude': { section: 'Geo Network', can_filter: true, can_breakout: true }, + 'ga:networkDomain': { section: 'Geo Network', can_filter: true, can_breakout: true }, + 'ga:networkLocation': { section: 'Geo Network', can_filter: true, can_breakout: true }, + 'ga:flashVersion': { section: 'System', can_filter: true, can_breakout: true }, + 'ga:javaEnabled': { section: 'System', can_filter: true, can_breakout: true }, + 'ga:language': { section: 'System', can_filter: true, can_breakout: true }, + 'ga:screenColors': { section: 'System', can_filter: true, can_breakout: true }, + 'ga:sourcePropertyDisplayName': { section: 'System', can_filter: true, can_breakout: true }, + 'ga:sourcePropertyTrackingId': { section: 'System', can_filter: true, can_breakout: true }, + 'ga:screenResolution': { section: 'System', can_filter: true, can_breakout: true }, + 'ga:hostname': { section: 'Page Tracking', can_filter: true, can_breakout: true }, + 'ga:pagePath': { section: 'Page Tracking', can_filter: true, can_breakout: true }, + 'ga:pagePathLevel1': { section: 'Page Tracking', can_filter: true, can_breakout: true }, + 'ga:pagePathLevel2': { section: 'Page Tracking', can_filter: true, can_breakout: true }, + 'ga:pagePathLevel3': { section: 'Page Tracking', can_filter: true, can_breakout: true }, + 'ga:pagePathLevel4': { section: 'Page Tracking', can_filter: true, can_breakout: true }, + 'ga:pageTitle': { section: 'Page Tracking', can_filter: true, can_breakout: true }, + 'ga:landingPagePath': { section: 'Page Tracking', can_filter: true, can_breakout: true }, + 'ga:secondPagePath': { section: 'Page Tracking', can_filter: true, can_breakout: true }, + 'ga:exitPagePath': { section: 'Page Tracking', can_filter: true, can_breakout: true }, + 'ga:previousPagePath': { section: 'Page Tracking', can_filter: true, can_breakout: true }, + 'ga:pageDepth': { section: 'Page Tracking', can_filter: true, can_breakout: true }, + 'ga:pageValue': { section: 'Page Tracking', can_filter: true, can_breakout: false }, + 'ga:entrances': { section: 'Page Tracking', can_filter: true, can_breakout: false }, + 'ga:entranceRate': { section: 'Page Tracking', can_filter: true, can_breakout: false }, + 'ga:pageviews': { section: 'Page Tracking', can_filter: true, can_breakout: false }, + 'ga:pageviewsPerSession': { section: 'Page Tracking', can_filter: true, can_breakout: false }, + 'ga:contentGroupUniqueViewsXX': { section: 'Content Grouping', can_filter: true, can_breakout: false }, + 'ga:uniquePageviews': { section: 'Page Tracking', can_filter: true, can_breakout: false }, + 'ga:timeOnPage': { section: 'Page Tracking', can_filter: true, can_breakout: false }, + 'ga:avgTimeOnPage': { section: 'Page Tracking', can_filter: true, can_breakout: false }, + 'ga:exits': { section: 'Page Tracking', can_filter: true, can_breakout: false }, + 'ga:exitRate': { section: 'Page Tracking', can_filter: true, can_breakout: false }, + 'ga:searchUsed': { section: 'Internal Search', can_filter: true, can_breakout: true }, + 'ga:searchKeyword': { section: 'Internal Search', can_filter: true, can_breakout: true }, + 'ga:searchKeywordRefinement': { section: 'Internal Search', can_filter: true, can_breakout: true }, + 'ga:searchCategory': { section: 'Internal Search', can_filter: true, can_breakout: true }, + 'ga:searchStartPage': { section: 'Internal Search', can_filter: true, can_breakout: true }, + 'ga:searchDestinationPage': { section: 'Internal Search', can_filter: true, can_breakout: true }, + 'ga:searchAfterDestinationPage': { section: 'Internal Search', can_filter: true, can_breakout: true }, + 'ga:searchResultViews': { section: 'Internal Search', can_filter: true, can_breakout: false }, + 'ga:searchUniques': { section: 'Internal Search', can_filter: true, can_breakout: false }, + 'ga:avgSearchResultViews': { section: 'Internal Search', can_filter: true, can_breakout: false }, + 'ga:searchSessions': { section: 'Internal Search', can_filter: true, can_breakout: false }, + 'ga:percentSessionsWithSearch': { section: 'Internal Search', can_filter: true, can_breakout: false }, + 'ga:searchDepth': { section: 'Internal Search', can_filter: true, can_breakout: false }, + 'ga:avgSearchDepth': { section: 'Internal Search', can_filter: true, can_breakout: false }, + 'ga:searchRefinements': { section: 'Internal Search', can_filter: true, can_breakout: false }, + 'ga:percentSearchRefinements': { section: 'Internal Search', can_filter: true, can_breakout: false }, + 'ga:searchDuration': { section: 'Internal Search', can_filter: true, can_breakout: false }, + 'ga:avgSearchDuration': { section: 'Internal Search', can_filter: true, can_breakout: false }, + 'ga:searchExits': { section: 'Internal Search', can_filter: true, can_breakout: false }, + 'ga:searchExitRate': { section: 'Internal Search', can_filter: true, can_breakout: false }, + 'ga:searchGoalXXConversionRate': { section: 'Internal Search', can_filter: true, can_breakout: false }, + 'ga:searchGoalConversionRateAll': { section: 'Internal Search', can_filter: true, can_breakout: false }, + 'ga:goalValueAllPerSearch': { section: 'Internal Search', can_filter: true, can_breakout: false }, + 'ga:pageLoadTime': { section: 'Site Speed', can_filter: true, can_breakout: false }, + 'ga:pageLoadSample': { section: 'Site Speed', can_filter: true, can_breakout: false }, + 'ga:avgPageLoadTime': { section: 'Site Speed', can_filter: true, can_breakout: false }, + 'ga:domainLookupTime': { section: 'Site Speed', can_filter: true, can_breakout: false }, + 'ga:avgDomainLookupTime': { section: 'Site Speed', can_filter: true, can_breakout: false }, + 'ga:pageDownloadTime': { section: 'Site Speed', can_filter: true, can_breakout: false }, + 'ga:avgPageDownloadTime': { section: 'Site Speed', can_filter: true, can_breakout: false }, + 'ga:redirectionTime': { section: 'Site Speed', can_filter: true, can_breakout: false }, + 'ga:avgRedirectionTime': { section: 'Site Speed', can_filter: true, can_breakout: false }, + 'ga:serverConnectionTime': { section: 'Site Speed', can_filter: true, can_breakout: false }, + 'ga:avgServerConnectionTime': { section: 'Site Speed', can_filter: true, can_breakout: false }, + 'ga:serverResponseTime': { section: 'Site Speed', can_filter: true, can_breakout: false }, + 'ga:avgServerResponseTime': { section: 'Site Speed', can_filter: true, can_breakout: false }, + 'ga:speedMetricsSample': { section: 'Site Speed', can_filter: true, can_breakout: false }, + 'ga:domInteractiveTime': { section: 'Site Speed', can_filter: true, can_breakout: false }, + 'ga:avgDomInteractiveTime': { section: 'Site Speed', can_filter: true, can_breakout: false }, + 'ga:domContentLoadedTime': { section: 'Site Speed', can_filter: true, can_breakout: false }, + 'ga:avgDomContentLoadedTime': { section: 'Site Speed', can_filter: true, can_breakout: false }, + 'ga:domLatencyMetricsSample': { section: 'Site Speed', can_filter: true, can_breakout: false }, + 'ga:appInstallerId': { section: 'App Tracking', can_filter: true, can_breakout: true }, + 'ga:appVersion': { section: 'App Tracking', can_filter: true, can_breakout: true }, + 'ga:appName': { section: 'App Tracking', can_filter: true, can_breakout: true }, + 'ga:appId': { section: 'App Tracking', can_filter: true, can_breakout: true }, + 'ga:screenName': { section: 'App Tracking', can_filter: true, can_breakout: true }, + 'ga:screenDepth': { section: 'App Tracking', can_filter: true, can_breakout: true }, + 'ga:landingScreenName': { section: 'App Tracking', can_filter: true, can_breakout: true }, + 'ga:exitScreenName': { section: 'App Tracking', can_filter: true, can_breakout: true }, + 'ga:screenviews': { section: 'App Tracking', can_filter: true, can_breakout: false }, + 'ga:uniqueScreenviews': { section: 'App Tracking', can_filter: true, can_breakout: false }, + 'ga:screenviewsPerSession': { section: 'App Tracking', can_filter: true, can_breakout: false }, + 'ga:timeOnScreen': { section: 'App Tracking', can_filter: true, can_breakout: false }, + 'ga:avgScreenviewDuration': { section: 'App Tracking', can_filter: true, can_breakout: false }, + 'ga:eventCategory': { section: 'Event Tracking', can_filter: true, can_breakout: true }, + 'ga:eventAction': { section: 'Event Tracking', can_filter: true, can_breakout: true }, + 'ga:eventLabel': { section: 'Event Tracking', can_filter: true, can_breakout: true }, + 'ga:totalEvents': { section: 'Event Tracking', can_filter: true, can_breakout: false }, + 'ga:uniqueDimensionCombinations': { section: 'Session', can_filter: true, can_breakout: false }, + 'ga:eventValue': { section: 'Event Tracking', can_filter: true, can_breakout: false }, + 'ga:avgEventValue': { section: 'Event Tracking', can_filter: true, can_breakout: false }, + 'ga:sessionsWithEvent': { section: 'Event Tracking', can_filter: true, can_breakout: false }, + 'ga:eventsPerSessionWithEvent': { section: 'Event Tracking', can_filter: true, can_breakout: false }, + 'ga:transactionId': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:affiliation': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:sessionsToTransaction': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:daysToTransaction': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:productSku': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:productName': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:productCategory': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:currencyCode': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:transactions': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:transactionsPerSession': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:transactionRevenue': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:revenuePerTransaction': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:transactionRevenuePerSession': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:transactionShipping': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:transactionTax': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:totalValue': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:itemQuantity': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:uniquePurchases': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:revenuePerItem': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:itemRevenue': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:itemsPerPurchase': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:localTransactionRevenue': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:localTransactionShipping': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:localTransactionTax': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:localItemRevenue': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:socialInteractionNetwork': { section: 'Social Interactions', can_filter: true, can_breakout: true }, + 'ga:socialInteractionAction': { section: 'Social Interactions', can_filter: true, can_breakout: true }, + 'ga:socialInteractionNetworkAction': { section: 'Social Interactions', can_filter: true, can_breakout: true }, + 'ga:socialInteractionTarget': { section: 'Social Interactions', can_filter: true, can_breakout: true }, + 'ga:socialEngagementType': { section: 'Social Interactions', can_filter: true, can_breakout: true }, + 'ga:socialInteractions': { section: 'Social Interactions', can_filter: true, can_breakout: false }, + 'ga:uniqueSocialInteractions': { section: 'Social Interactions', can_filter: true, can_breakout: false }, + 'ga:socialInteractionsPerSession': { section: 'Social Interactions', can_filter: true, can_breakout: false }, + 'ga:userTimingCategory': { section: 'User Timings', can_filter: true, can_breakout: true }, + 'ga:userTimingLabel': { section: 'User Timings', can_filter: true, can_breakout: true }, + 'ga:userTimingVariable': { section: 'User Timings', can_filter: true, can_breakout: true }, + 'ga:userTimingValue': { section: 'User Timings', can_filter: true, can_breakout: false }, + 'ga:userTimingSample': { section: 'User Timings', can_filter: true, can_breakout: false }, + 'ga:avgUserTimingValue': { section: 'User Timings', can_filter: true, can_breakout: false }, + 'ga:exceptionDescription': { section: 'Exceptions', can_filter: true, can_breakout: true }, + 'ga:exceptions': { section: 'Exceptions', can_filter: true, can_breakout: false }, + 'ga:exceptionsPerScreenview': { section: 'Exceptions', can_filter: true, can_breakout: false }, + 'ga:fatalExceptions': { section: 'Exceptions', can_filter: true, can_breakout: false }, + 'ga:fatalExceptionsPerScreenview': { section: 'Exceptions', can_filter: true, can_breakout: false }, + 'ga:experimentId': { section: 'Content Experiments', can_filter: true, can_breakout: true }, + 'ga:experimentVariant': { section: 'Content Experiments', can_filter: true, can_breakout: true }, + 'ga:dimensionXX': { section: 'Custom Variables or Columns', can_filter: true, can_breakout: true }, + 'ga:customVarNameXX': { section: 'Custom Variables or Columns', can_filter: true, can_breakout: true }, + 'ga:metricXX': { section: 'Custom Variables or Columns', can_filter: true, can_breakout: false }, + 'ga:customVarValueXX': { section: 'Custom Variables or Columns', can_filter: true, can_breakout: true }, + 'ga:date': { section: 'Time', can_filter: true, can_breakout: true }, + 'ga:year': { section: 'Time', can_filter: true, can_breakout: true }, + 'ga:month': { section: 'Time', can_filter: true, can_breakout: true }, + 'ga:week': { section: 'Time', can_filter: true, can_breakout: true }, + 'ga:day': { section: 'Time', can_filter: true, can_breakout: true }, + 'ga:hour': { section: 'Time', can_filter: true, can_breakout: true }, + 'ga:minute': { section: 'Time', can_filter: true, can_breakout: true }, + 'ga:nthMonth': { section: 'Time', can_filter: true, can_breakout: true }, + 'ga:nthWeek': { section: 'Time', can_filter: true, can_breakout: true }, + 'ga:nthDay': { section: 'Time', can_filter: true, can_breakout: true }, + 'ga:nthMinute': { section: 'Time', can_filter: true, can_breakout: true }, + 'ga:dayOfWeek': { section: 'Time', can_filter: true, can_breakout: true }, + 'ga:dayOfWeekName': { section: 'Time', can_filter: true, can_breakout: true }, + 'ga:dateHour': { section: 'Time', can_filter: true, can_breakout: true }, + 'ga:yearMonth': { section: 'Time', can_filter: true, can_breakout: true }, + 'ga:yearWeek': { section: 'Time', can_filter: true, can_breakout: true }, + 'ga:isoWeek': { section: 'Time', can_filter: true, can_breakout: true }, + 'ga:isoYear': { section: 'Time', can_filter: true, can_breakout: true }, + 'ga:isoYearIsoWeek': { section: 'Time', can_filter: true, can_breakout: true }, + // 'ga:dcmClickAd': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmClickAdId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmClickAdType': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmClickAdTypeId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmClickAdvertiser': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmClickAdvertiserId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmClickCampaign': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmClickCampaignId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmClickCreativeId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmClickCreative': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmClickRenderingId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmClickCreativeType': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmClickCreativeTypeId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmClickCreativeVersion': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmClickSite': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmClickSiteId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmClickSitePlacement': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmClickSitePlacementId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmClickSpotId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmFloodlightActivity': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmFloodlightActivityAndGroup': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmFloodlightActivityGroup': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmFloodlightActivityGroupId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmFloodlightActivityId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmFloodlightAdvertiserId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmFloodlightSpotId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmLastEventAd': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmLastEventAdId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmLastEventAdType': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmLastEventAdTypeId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmLastEventAdvertiser': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmLastEventAdvertiserId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmLastEventAttributionType': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmLastEventCampaign': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmLastEventCampaignId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmLastEventCreativeId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmLastEventCreative': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmLastEventRenderingId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmLastEventCreativeType': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmLastEventCreativeTypeId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmLastEventCreativeVersion': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmLastEventSite': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmLastEventSiteId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmLastEventSitePlacement': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmLastEventSitePlacementId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmLastEventSpotId': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: true }, + // 'ga:dcmFloodlightQuantity': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: false }, + // 'ga:dcmFloodlightRevenue': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: false }, + 'ga:landingContentGroupXX': { section: 'Content Grouping', can_filter: true, can_breakout: true }, + 'ga:previousContentGroupXX': { section: 'Content Grouping', can_filter: true, can_breakout: true }, + 'ga:contentGroupXX': { section: 'Content Grouping', can_filter: true, can_breakout: true }, + 'ga:userAgeBracket': { section: 'Audience', can_filter: true, can_breakout: true }, + 'ga:userGender': { section: 'Audience', can_filter: true, can_breakout: true }, + 'ga:interestOtherCategory': { section: 'Audience', can_filter: true, can_breakout: true }, + 'ga:interestAffinityCategory': { section: 'Audience', can_filter: true, can_breakout: true }, + 'ga:interestInMarketCategory': { section: 'Audience', can_filter: true, can_breakout: true }, + 'ga:adsenseRevenue': { section: 'Adsense', can_filter: true, can_breakout: false }, + 'ga:adsenseAdUnitsViewed': { section: 'Adsense', can_filter: true, can_breakout: false }, + 'ga:adsenseAdsViewed': { section: 'Adsense', can_filter: true, can_breakout: false }, + 'ga:adsenseAdsClicks': { section: 'Adsense', can_filter: true, can_breakout: false }, + 'ga:adsensePageImpressions': { section: 'Adsense', can_filter: true, can_breakout: false }, + 'ga:adsenseCTR': { section: 'Adsense', can_filter: true, can_breakout: false }, + 'ga:adsenseECPM': { section: 'Adsense', can_filter: true, can_breakout: false }, + 'ga:adsenseExits': { section: 'Adsense', can_filter: true, can_breakout: false }, + 'ga:adsenseViewableImpressionPercent': { section: 'Adsense', can_filter: true, can_breakout: false }, + 'ga:adsenseCoverage': { section: 'Adsense', can_filter: true, can_breakout: false }, + // 'ga:adxImpressions': { section: 'Ad Exchange', can_filter: true, can_breakout: false }, + // 'ga:adxCoverage': { section: 'Ad Exchange', can_filter: true, can_breakout: false }, + // 'ga:adxMonetizedPageviews': { section: 'Ad Exchange', can_filter: true, can_breakout: false }, + // 'ga:adxImpressionsPerSession': { section: 'Ad Exchange', can_filter: true, can_breakout: false }, + // 'ga:adxViewableImpressionsPercent': { section: 'Ad Exchange', can_filter: true, can_breakout: false }, + // 'ga:adxClicks': { section: 'Ad Exchange', can_filter: true, can_breakout: false }, + // 'ga:adxCTR': { section: 'Ad Exchange', can_filter: true, can_breakout: false }, + // 'ga:adxRevenue': { section: 'Ad Exchange', can_filter: true, can_breakout: false }, + // 'ga:adxRevenuePer1000Sessions': { section: 'Ad Exchange', can_filter: true, can_breakout: false }, + // 'ga:adxECPM': { section: 'Ad Exchange', can_filter: true, can_breakout: false }, + // 'ga:dfpImpressions': { section: 'DoubleClick for Publishers', can_filter: true, can_breakout: false }, + // 'ga:dfpCoverage': { section: 'DoubleClick for Publishers', can_filter: true, can_breakout: false }, + // 'ga:dfpMonetizedPageviews': { section: 'DoubleClick for Publishers', can_filter: true, can_breakout: false }, + // 'ga:dfpImpressionsPerSession': { section: 'DoubleClick for Publishers', can_filter: true, can_breakout: false }, + // 'ga:dfpViewableImpressionsPercent': { section: 'DoubleClick for Publishers', can_filter: true, can_breakout: false }, + // 'ga:dfpClicks': { section: 'DoubleClick for Publishers', can_filter: true, can_breakout: false }, + // 'ga:dfpCTR': { section: 'DoubleClick for Publishers', can_filter: true, can_breakout: false }, + // 'ga:dfpRevenue': { section: 'DoubleClick for Publishers', can_filter: true, can_breakout: false }, + // 'ga:dfpRevenuePer1000Sessions': { section: 'DoubleClick for Publishers', can_filter: true, can_breakout: false }, + // 'ga:dfpECPM': { section: 'DoubleClick for Publishers', can_filter: true, can_breakout: false }, + // 'ga:backfillImpressions': { section: 'DoubleClick for Publishers Backfill', can_filter: true, can_breakout: false }, + // 'ga:backfillCoverage': { section: 'DoubleClick for Publishers Backfill', can_filter: true, can_breakout: false }, + // 'ga:backfillMonetizedPageviews': { section: 'DoubleClick for Publishers Backfill', can_filter: true, can_breakout: false }, + // 'ga:backfillImpressionsPerSession': { section: 'DoubleClick for Publishers Backfill', can_filter: true, can_breakout: false }, + // 'ga:backfillViewableImpressionsPercent': { section: 'DoubleClick for Publishers Backfill', can_filter: true, can_breakout: false }, + // 'ga:backfillClicks': { section: 'DoubleClick for Publishers Backfill', can_filter: true, can_breakout: false }, + // 'ga:backfillCTR': { section: 'DoubleClick for Publishers Backfill', can_filter: true, can_breakout: false }, + // 'ga:backfillRevenue': { section: 'DoubleClick for Publishers Backfill', can_filter: true, can_breakout: false }, + // 'ga:backfillRevenuePer1000Sessions': { section: 'DoubleClick for Publishers Backfill', can_filter: true, can_breakout: false }, + // 'ga:backfillECPM': { section: 'DoubleClick for Publishers Backfill', can_filter: true, can_breakout: false }, + 'ga:acquisitionCampaign': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: true }, + 'ga:acquisitionMedium': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: true }, + 'ga:acquisitionSource': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: true }, + 'ga:acquisitionSourceMedium': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: true }, + 'ga:acquisitionTrafficChannel': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: true }, + 'ga:browserSize': { section: 'Platform or Device', can_filter: true, can_breakout: true }, + 'ga:campaignCode': { section: 'Traffic Sources', can_filter: true, can_breakout: true }, + 'ga:channelGrouping': { section: 'Channel Grouping', can_filter: true, can_breakout: true }, + 'ga:checkoutOptions': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:cityId': { section: 'Geo Network', can_filter: true, can_breakout: true }, + 'ga:cohort': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: true }, + 'ga:cohortNthDay': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: true }, + 'ga:cohortNthMonth': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: true }, + 'ga:cohortNthWeek': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: true }, + 'ga:continentId': { section: 'Geo Network', can_filter: true, can_breakout: true }, + 'ga:correlationModelId': { section: 'Related Products', can_filter: true, can_breakout: true }, + 'ga:countryIsoCode': { section: 'Geo Network', can_filter: true, can_breakout: true }, + 'ga:dataSource': { section: 'Platform or Device', can_filter: true, can_breakout: true }, + // 'ga:dbmClickAdvertiser': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmClickAdvertiserId': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmClickCreativeId': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmClickExchange': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmClickExchangeId': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmClickInsertionOrder': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmClickInsertionOrderId': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmClickLineItem': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmClickLineItemId': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmClickSite': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmClickSiteId': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmLastEventAdvertiser': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmLastEventAdvertiserId': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmLastEventCreativeId': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmLastEventExchange': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmLastEventExchangeId': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmLastEventInsertionOrder': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmLastEventInsertionOrderId': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmLastEventLineItem': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmLastEventLineItemId': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmLastEventSite': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dbmLastEventSiteId': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: true }, + // 'ga:dsAdGroup': { section: 'DoubleClick Search', can_filter: true, can_breakout: true }, + // 'ga:dsAdGroupId': { section: 'DoubleClick Search', can_filter: true, can_breakout: true }, + // 'ga:dsAdvertiser': { section: 'DoubleClick Search', can_filter: true, can_breakout: true }, + // 'ga:dsAdvertiserId': { section: 'DoubleClick Search', can_filter: true, can_breakout: true }, + // 'ga:dsAgency': { section: 'DoubleClick Search', can_filter: true, can_breakout: true }, + // 'ga:dsAgencyId': { section: 'DoubleClick Search', can_filter: true, can_breakout: true }, + // 'ga:dsCampaign': { section: 'DoubleClick Search', can_filter: true, can_breakout: true }, + // 'ga:dsCampaignId': { section: 'DoubleClick Search', can_filter: true, can_breakout: true }, + // 'ga:dsEngineAccount': { section: 'DoubleClick Search', can_filter: true, can_breakout: true }, + // 'ga:dsEngineAccountId': { section: 'DoubleClick Search', can_filter: true, can_breakout: true }, + // 'ga:dsKeyword': { section: 'DoubleClick Search', can_filter: true, can_breakout: true }, + // 'ga:dsKeywordId': { section: 'DoubleClick Search', can_filter: true, can_breakout: true }, + 'ga:internalPromotionCreative': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:internalPromotionId': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:internalPromotionName': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:internalPromotionPosition': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:isTrueViewVideoAd': { section: 'Adwords', can_filter: true, can_breakout: true }, + 'ga:metroId': { section: 'Geo Network', can_filter: true, can_breakout: true }, + 'ga:nthHour': { section: 'Time', can_filter: true, can_breakout: true }, + 'ga:orderCouponCode': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:productBrand': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:productCategoryHierarchy': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:productCategoryLevelXX': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:productCouponCode': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:productListName': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:productListPosition': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:productVariant': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:queryProductId': { section: 'Related Products', can_filter: true, can_breakout: true }, + 'ga:queryProductName': { section: 'Related Products', can_filter: true, can_breakout: true }, + 'ga:queryProductVariation': { section: 'Related Products', can_filter: true, can_breakout: true }, + 'ga:regionId': { section: 'Geo Network', can_filter: true, can_breakout: true }, + 'ga:regionIsoCode': { section: 'Geo Network', can_filter: true, can_breakout: true }, + 'ga:relatedProductId': { section: 'Related Products', can_filter: true, can_breakout: true }, + 'ga:relatedProductName': { section: 'Related Products', can_filter: true, can_breakout: true }, + 'ga:relatedProductVariation': { section: 'Related Products', can_filter: true, can_breakout: true }, + 'ga:shoppingStage': { section: 'Ecommerce', can_filter: true, can_breakout: true }, + 'ga:subContinentCode': { section: 'Geo Network', can_filter: true, can_breakout: true }, + 'ga:buyToDetailRate': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:calcMetric_<NAME>': { section: 'Custom Variables or Columns', can_filter: true, can_breakout: false }, + 'ga:cartToDetailRate': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:cohortActiveUsers': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: false }, + 'ga:cohortAppviewsPerUser': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: false }, + 'ga:cohortAppviewsPerUserWithLifetimeCriteria': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: false }, + 'ga:cohortGoalCompletionsPerUser': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: false }, + 'ga:cohortGoalCompletionsPerUserWithLifetimeCriteria': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: false }, + 'ga:cohortPageviewsPerUser': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: false }, + 'ga:cohortPageviewsPerUserWithLifetimeCriteria': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: false }, + 'ga:cohortRetentionRate': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: false }, + 'ga:cohortRevenuePerUser': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: false }, + 'ga:cohortRevenuePerUserWithLifetimeCriteria': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: false }, + 'ga:cohortSessionDurationPerUser': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: false }, + 'ga:cohortSessionDurationPerUserWithLifetimeCriteria': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: false }, + 'ga:cohortSessionsPerUser': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: false }, + 'ga:cohortSessionsPerUserWithLifetimeCriteria': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: false }, + 'ga:cohortTotalUsers': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: false }, + 'ga:cohortTotalUsersWithLifetimeCriteria': { section: 'Lifetime Value and Cohorts', can_filter: true, can_breakout: false }, + 'ga:correlationScore': { section: 'Related Products', can_filter: true, can_breakout: false }, + // 'ga:dbmCPA': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: false }, + // 'ga:dbmCPC': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: false }, + // 'ga:dbmCPM': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: false }, + // 'ga:dbmCTR': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: false }, + // 'ga:dbmClicks': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: false }, + // 'ga:dbmConversions': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: false }, + // 'ga:dbmCost': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: false }, + // 'ga:dbmImpressions': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: false }, + // 'ga:dbmROAS': { section: 'DoubleClick Bid Manager', can_filter: true, can_breakout: false }, + // 'ga:dcmCPC': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: false }, + // 'ga:dcmCTR': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: false }, + // 'ga:dcmClicks': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: false }, + // 'ga:dcmCost': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: false }, + // 'ga:dcmImpressions': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: false }, + // 'ga:dcmROAS': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: false }, + // 'ga:dcmRPC': { section: 'DoubleClick Campaign Manager', can_filter: true, can_breakout: false }, + // 'ga:dsCPC': { section: 'DoubleClick Search', can_filter: true, can_breakout: false }, + // 'ga:dsCTR': { section: 'DoubleClick Search', can_filter: true, can_breakout: false }, + // 'ga:dsClicks': { section: 'DoubleClick Search', can_filter: true, can_breakout: false }, + // 'ga:dsCost': { section: 'DoubleClick Search', can_filter: true, can_breakout: false }, + // 'ga:dsImpressions': { section: 'DoubleClick Search', can_filter: true, can_breakout: false }, + // 'ga:dsProfit': { section: 'DoubleClick Search', can_filter: true, can_breakout: false }, + // 'ga:dsReturnOnAdSpend': { section: 'DoubleClick Search', can_filter: true, can_breakout: false }, + // 'ga:dsRevenuePerClick': { section: 'DoubleClick Search', can_filter: true, can_breakout: false }, + 'ga:hits': { section: 'Session', can_filter: true, can_breakout: false }, + 'ga:internalPromotionCTR': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:internalPromotionClicks': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:internalPromotionViews': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:localProductRefundAmount': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:localRefundAmount': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:productAddsToCart': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:productCheckouts': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:productDetailViews': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:productListCTR': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:productListClicks': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:productListViews': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:productRefundAmount': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:productRefunds': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:productRemovesFromCart': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:productRevenuePerPurchase': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:quantityAddedToCart': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:quantityCheckedOut': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:quantityRefunded': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:quantityRemovedFromCart': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:queryProductQuantity': { section: 'Related Products', can_filter: true, can_breakout: false }, + 'ga:refundAmount': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:relatedProductQuantity': { section: 'Related Products', can_filter: true, can_breakout: false }, + 'ga:revenuePerUser': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:sessionsPerUser': { section: 'User', can_filter: true, can_breakout: false }, + 'ga:totalRefunds': { section: 'Ecommerce', can_filter: true, can_breakout: false }, + 'ga:transactionsPerUser': { section: 'Ecommerce', can_filter: true, can_breakout: false } }; + +export const metrics = [ { id: 'ga:users', name: 'Users', description: 'The total number of users for the requested time period.', section: 'User', is_active: true }, + { id: 'ga:newUsers', name: 'New Users', description: 'The number of users whose session was marked as a first-time session.', section: 'User', is_active: true }, + { id: 'ga:percentNewSessions', name: '% New Sessions', description: 'The percentage of sessions by users who had never visited the property before.', section: 'User', is_active: true }, + { id: 'ga:1dayUsers', name: '1 Day Active Users', description: 'Total number of 1-day active users for each day in the requested time period. At least one of ga:nthDay, ga:date, or ga:day must be specified as a dimension to query this metric. For a given date, the returned value will be the total number of unique users for the 1-day period ending on the given date.', section: 'User', is_active: true }, + { id: 'ga:7dayUsers', name: '7 Day Active Users', description: 'Total number of 7-day active users for each day in the requested time period. At least one of ga:nthDay, ga:date, or ga:day must be specified as a dimension to query this metric. For a given date, the returned value will be the total number of unique users for the 7-day period ending on the given date.', section: 'User', is_active: true }, + { id: 'ga:14dayUsers', name: '14 Day Active Users', description: 'Total number of 14-day active users for each day in the requested time period. At least one of ga:nthDay, ga:date, or ga:day must be specified as a dimension to query this metric. For a given date, the returned value will be the total number of unique users for the 14-day period ending on the given date.', section: 'User', is_active: true }, + { id: 'ga:30dayUsers', name: '30 Day Active Users', description: 'Total number of 30-day active users for each day in the requested time period. At least one of ga:nthDay, ga:date, or ga:day must be specified as a dimension to query this metric. For a given date, the returned value will be the total number of unique users for the 30-day period ending on the given date.', section: 'User', is_active: true }, + { id: 'ga:sessions', name: 'Sessions', description: 'The total number of sessions.', section: 'Session', is_active: true }, + { id: 'ga:bounces', name: 'Bounces', description: 'The total number of single page (or single interaction hit) sessions for the property.', section: 'Session', is_active: true }, + { id: 'ga:bounceRate', name: 'Bounce Rate', description: 'The percentage of single-page session (i.e., session in which the person left the property from the first page).', section: 'Session', is_active: true }, + { id: 'ga:sessionDuration', name: 'Session Duration', description: 'Total duration (in seconds) of users\' sessions.', section: 'Session', is_active: true }, + { id: 'ga:avgSessionDuration', name: 'Avg. Session Duration', description: 'The average duration (in seconds) of users\' sessions.', section: 'Session', is_active: true }, + { id: 'ga:organicSearches', name: 'Organic Searches', description: 'The number of organic searches happened in a session. This metric is search engine agnostic.', section: 'Traffic Sources', is_active: true }, + { id: 'ga:impressions', name: 'Impressions', description: 'Total number of campaign impressions.', section: 'Adwords', is_active: true }, + { id: 'ga:adClicks', name: 'Clicks', description: 'Total number of times users have clicked on an ad to reach the property.', section: 'Adwords', is_active: true }, + { id: 'ga:adCost', name: 'Cost', description: 'Derived cost for the advertising campaign. Its currency is the one you set in the AdWords account.', section: 'Adwords', is_active: true }, + { id: 'ga:CPM', name: 'CPM', description: 'Cost per thousand impressions.', section: 'Adwords', is_active: true }, + { id: 'ga:CPC', name: 'CPC', description: 'Cost to advertiser per click.', section: 'Adwords', is_active: true }, + { id: 'ga:CTR', name: 'CTR', description: 'Click-through-rate for the ad. This is equal to the number of clicks divided by the number of impressions for the ad (e.g., how many times users clicked on one of the ads where that ad appeared).', section: 'Adwords', is_active: true }, + { id: 'ga:costPerTransaction', name: 'Cost per Transaction', description: 'The cost per transaction for the property.', section: 'Adwords', is_active: true }, + { id: 'ga:costPerGoalConversion', name: 'Cost per Goal Conversion', description: 'The cost per goal conversion for the property.', section: 'Adwords', is_active: true }, + { id: 'ga:costPerConversion', name: 'Cost per Conversion', description: 'The cost per conversion (including ecommerce and goal conversions) for the property.', section: 'Adwords', is_active: true }, + { id: 'ga:RPC', name: 'RPC', description: 'RPC or revenue-per-click, the average revenue (from ecommerce sales and/or goal value) you received for each click on one of the search ads.', section: 'Adwords', is_active: true }, + { id: 'ga:ROAS', name: 'ROAS', description: 'Return On Ad Spend (ROAS) is the total transaction revenue and goal value divided by derived advertising cost.', section: 'Adwords', is_active: true }, + { id: 'ga:goalXXStarts', name: 'Goal XX Starts', description: 'The total number of starts for the requested goal number.', section: 'Goal Conversions', is_active: true }, + { id: 'ga:goalStartsAll', name: 'Goal Starts', description: 'Total number of starts for all goals defined in the profile.', section: 'Goal Conversions', is_active: true }, + { id: 'ga:goalXXCompletions', name: 'Goal XX Completions', description: 'The total number of completions for the requested goal number.', section: 'Goal Conversions', is_active: true }, + { id: 'ga:goalCompletionsAll', name: 'Goal Completions', description: 'Total number of completions for all goals defined in the profile.', section: 'Goal Conversions', is_active: true }, + { id: 'ga:goalXXValue', name: 'Goal XX Value', description: 'The total numeric value for the requested goal number.', section: 'Goal Conversions', is_active: true }, + { id: 'ga:goalValueAll', name: 'Goal Value', description: 'Total numeric value for all goals defined in the profile.', section: 'Goal Conversions', is_active: true }, + { id: 'ga:goalValuePerSession', name: 'Per Session Goal Value', description: 'The average goal value of a session.', section: 'Goal Conversions', is_active: true }, + { id: 'ga:goalXXConversionRate', name: 'Goal XX Conversion Rate', description: 'Percentage of sessions resulting in a conversion to the requested goal number.', section: 'Goal Conversions', is_active: true }, + { id: 'ga:goalConversionRateAll', name: 'Goal Conversion Rate', description: 'The percentage of sessions which resulted in a conversion to at least one of the goals.', section: 'Goal Conversions', is_active: true }, + { id: 'ga:goalXXAbandons', name: 'Goal XX Abandoned Funnels', description: 'The number of times users started conversion activity on the requested goal number without actually completing it.', section: 'Goal Conversions', is_active: true }, + { id: 'ga:goalAbandonsAll', name: 'Abandoned Funnels', description: 'The overall number of times users started goals without actually completing them.', section: 'Goal Conversions', is_active: true }, + { id: 'ga:goalXXAbandonRate', name: 'Goal XX Abandonment Rate', description: 'The rate at which the requested goal number was abandoned.', section: 'Goal Conversions', is_active: true }, + { id: 'ga:goalAbandonRateAll', name: 'Total Abandonment Rate', description: 'Goal abandonment rate.', section: 'Goal Conversions', is_active: true }, + { id: 'ga:pageValue', name: 'Page Value', description: 'The average value of this page or set of pages, which is equal to (ga:transactionRevenue + ga:goalValueAll) / ga:uniquePageviews.', section: 'Page Tracking', is_active: true }, + { id: 'ga:entrances', name: 'Entrances', description: 'The number of entrances to the property measured as the first pageview in a session, typically used with landingPagePath.', section: 'Page Tracking', is_active: true }, + { id: 'ga:entranceRate', name: 'Entrances / Pageviews', description: 'The percentage of pageviews in which this page was the entrance.', section: 'Page Tracking', is_active: true }, + { id: 'ga:pageviews', name: 'Pageviews', description: 'The total number of pageviews for the property.', section: 'Page Tracking', is_active: true }, + { id: 'ga:pageviewsPerSession', name: 'Pages / Session', description: 'The average number of pages viewed during a session, including repeated views of a single page.', section: 'Page Tracking', is_active: true }, + { id: 'ga:contentGroupUniqueViewsXX', name: 'Unique Views XX', description: 'The number of unique content group views. Content group views in different sessions are counted as unique content group views. Both the pagePath and pageTitle are used to determine content group view uniqueness.', section: 'Content Grouping', is_active: true }, + { id: 'ga:uniquePageviews', name: 'Unique Pageviews', description: 'Unique Pageviews is the number of sessions during which the specified page was viewed at least once. A unique pageview is counted for each page URL + page title combination.', section: 'Page Tracking', is_active: true }, + { id: 'ga:timeOnPage', name: 'Time on Page', description: 'Time (in seconds) users spent on a particular page, calculated by subtracting the initial view time for a particular page from the initial view time for a subsequent page. This metric does not apply to exit pages of the property.', section: 'Page Tracking', is_active: true }, + { id: 'ga:avgTimeOnPage', name: 'Avg. Time on Page', description: 'The average time users spent viewing this page or a set of pages.', section: 'Page Tracking', is_active: true }, + { id: 'ga:exits', name: 'Exits', description: 'The number of exits from the property.', section: 'Page Tracking', is_active: true }, + { id: 'ga:exitRate', name: '% Exit', description: 'The percentage of exits from the property that occurred out of the total pageviews.', section: 'Page Tracking', is_active: true }, + { id: 'ga:searchResultViews', name: 'Results Pageviews', description: 'The number of times a search result page was viewed.', section: 'Internal Search', is_active: true }, + { id: 'ga:searchUniques', name: 'Total Unique Searches', description: 'Total number of unique keywords from internal searches within a session. For example, if "shoes" was searched for 3 times in a session, it would be counted only once.', section: 'Internal Search', is_active: true }, + { id: 'ga:avgSearchResultViews', name: 'Results Pageviews / Search', description: 'The average number of times people viewed a page as a result of a search.', section: 'Internal Search', is_active: true }, + { id: 'ga:searchSessions', name: 'Sessions with Search', description: 'The total number of sessions that included an internal search.', section: 'Internal Search', is_active: true }, + { id: 'ga:percentSessionsWithSearch', name: '% Sessions with Search', description: 'The percentage of sessions with search.', section: 'Internal Search', is_active: true }, + { id: 'ga:searchDepth', name: 'Search Depth', description: 'The total number of subsequent page views made after a use of the site\'s internal search feature.', section: 'Internal Search', is_active: true }, + { id: 'ga:avgSearchDepth', name: 'Average Search Depth', description: 'The average number of pages people viewed after performing a search.', section: 'Internal Search', is_active: true }, + { id: 'ga:searchRefinements', name: 'Search Refinements', description: 'The total number of times a refinement (transition) occurs between internal keywords search within a session. For example, if the sequence of keywords is "shoes", "shoes", "pants", "pants", this metric will be one because the transition between "shoes" and "pants" is different.', section: 'Internal Search', is_active: true }, + { id: 'ga:percentSearchRefinements', name: '% Search Refinements', description: 'The percentage of the number of times a refinement (i.e., transition) occurs between internal keywords search within a session.', section: 'Internal Search', is_active: true }, + { id: 'ga:searchDuration', name: 'Time after Search', description: 'The session duration when the site\'s internal search feature is used.', section: 'Internal Search', is_active: true }, + { id: 'ga:avgSearchDuration', name: 'Time after Search', description: 'The average time (in seconds) users, after searching, spent on the property.', section: 'Internal Search', is_active: true }, + { id: 'ga:searchExits', name: 'Search Exits', description: 'The number of exits on the site that occurred following a search result from the site\'s internal search feature.', section: 'Internal Search', is_active: true }, + { id: 'ga:searchExitRate', name: '% Search Exits', description: 'The percentage of searches that resulted in an immediate exit from the property.', section: 'Internal Search', is_active: true }, + { id: 'ga:searchGoalXXConversionRate', name: 'Site Search Goal XX Conversion Rate', description: 'The percentage of search sessions (i.e., sessions that included at least one search) which resulted in a conversion to the requested goal number.', section: 'Internal Search', is_active: true }, + { id: 'ga:searchGoalConversionRateAll', name: 'Site Search Goal Conversion Rate', description: 'The percentage of search sessions (i.e., sessions that included at least one search) which resulted in a conversion to at least one of the goals.', section: 'Internal Search', is_active: true }, + { id: 'ga:goalValueAllPerSearch', name: 'Per Search Goal Value', description: 'The average goal value of a search.', section: 'Internal Search', is_active: true }, + { id: 'ga:pageLoadTime', name: 'Page Load Time (ms)', description: 'Total time (in milliseconds), from pageview initiation (e.g., a click on a page link) to page load completion in the browser, the pages in the sample set take to load.', section: 'Site Speed', is_active: true }, + { id: 'ga:pageLoadSample', name: 'Page Load Sample', description: 'The sample set (or count) of pageviews used to calculate the average page load time.', section: 'Site Speed', is_active: true }, + { id: 'ga:avgPageLoadTime', name: 'Avg. Page Load Time (sec)', description: 'The average time (in seconds) pages from the sample set take to load, from initiation of the pageview (e.g., a click on a page link) to load completion in the browser.', section: 'Site Speed', is_active: true }, + { id: 'ga:domainLookupTime', name: 'Domain Lookup Time (ms)', description: 'The total time (in milliseconds) all samples spent in DNS lookup for this page.', section: 'Site Speed', is_active: true }, + { id: 'ga:avgDomainLookupTime', name: 'Avg. Domain Lookup Time (sec)', description: 'The average time (in seconds) spent in DNS lookup for this page.', section: 'Site Speed', is_active: true }, + { id: 'ga:pageDownloadTime', name: 'Page Download Time (ms)', description: 'The total time (in milliseconds) to download this page among all samples.', section: 'Site Speed', is_active: true }, + { id: 'ga:avgPageDownloadTime', name: 'Avg. Page Download Time (sec)', description: 'The average time (in seconds) to download this page.', section: 'Site Speed', is_active: true }, + { id: 'ga:redirectionTime', name: 'Redirection Time (ms)', description: 'The total time (in milliseconds) all samples spent in redirects before fetching this page. If there are no redirects, this is 0.', section: 'Site Speed', is_active: true }, + { id: 'ga:avgRedirectionTime', name: 'Avg. Redirection Time (sec)', description: 'The average time (in seconds) spent in redirects before fetching this page. If there are no redirects, this is 0.', section: 'Site Speed', is_active: true }, + { id: 'ga:serverConnectionTime', name: 'Server Connection Time (ms)', description: 'Total time (in milliseconds) all samples spent in establishing a TCP connection to this page.', section: 'Site Speed', is_active: true }, + { id: 'ga:avgServerConnectionTime', name: 'Avg. Server Connection Time (sec)', description: 'The average time (in seconds) spent in establishing a TCP connection to this page.', section: 'Site Speed', is_active: true }, + { id: 'ga:serverResponseTime', name: 'Server Response Time (ms)', description: 'The total time (in milliseconds) the site\'s server takes to respond to users\' requests among all samples; this includes the network time from users\' locations to the server.', section: 'Site Speed', is_active: true }, + { id: 'ga:avgServerResponseTime', name: 'Avg. Server Response Time (sec)', description: 'The average time (in seconds) the site\'s server takes to respond to users\' requests; this includes the network time from users\' locations to the server.', section: 'Site Speed', is_active: true }, + { id: 'ga:speedMetricsSample', name: 'Speed Metrics Sample', description: 'The sample set (or count) of pageviews used to calculate the averages of site speed metrics. This metric is used in all site speed average calculations, including avgDomainLookupTime, avgPageDownloadTime, avgRedirectionTime, avgServerConnectionTime, and avgServerResponseTime.', section: 'Site Speed', is_active: true }, + { id: 'ga:domInteractiveTime', name: 'Document Interactive Time (ms)', description: 'The time (in milliseconds), including the network time from users\' locations to the site\'s server, the browser takes to parse the document (DOMInteractive). At this time, users can interact with the Document Object Model even though it is not fully loaded.', section: 'Site Speed', is_active: true }, + { id: 'ga:avgDomInteractiveTime', name: 'Avg. Document Interactive Time (sec)', description: 'The average time (in seconds), including the network time from users\' locations to the site\'s server, the browser takes to parse the document and execute deferred and parser-inserted scripts.', section: 'Site Speed', is_active: true }, + { id: 'ga:domContentLoadedTime', name: 'Document Content Loaded Time (ms)', description: 'The time (in milliseconds), including the network time from users\' locations to the site\'s server, the browser takes to parse the document and execute deferred and parser-inserted scripts (DOMContentLoaded). When parsing of the document is finished, the Document Object Model (DOM) is ready, but the referenced style sheets, images, and subframes may not be finished loading. This is often the starting point of Javascript framework execution, e.g., JQuery\'s onready() callback.', section: 'Site Speed', is_active: true }, + { id: 'ga:avgDomContentLoadedTime', name: 'Avg. Document Content Loaded Time (sec)', description: 'The average time (in seconds) the browser takes to parse the document.', section: 'Site Speed', is_active: true }, + { id: 'ga:domLatencyMetricsSample', name: 'DOM Latency Metrics Sample', description: 'Sample set (or count) of pageviews used to calculate the averages for site speed DOM metrics. This metric is used to calculate ga:avgDomContentLoadedTime and ga:avgDomInteractiveTime.', section: 'Site Speed', is_active: true }, + { id: 'ga:screenviews', name: 'Screen Views', description: 'The total number of screenviews.', section: 'App Tracking', is_active: true }, + { id: 'ga:uniqueScreenviews', name: 'Unique Screen Views', description: 'The number of unique screen views. Screen views in different sessions are counted as separate screen views.', section: 'App Tracking', is_active: true }, + { id: 'ga:screenviewsPerSession', name: 'Screens / Session', description: 'The average number of screenviews per session.', section: 'App Tracking', is_active: true }, + { id: 'ga:timeOnScreen', name: 'Time on Screen', description: 'The time spent viewing the current screen.', section: 'App Tracking', is_active: true }, + { id: 'ga:avgScreenviewDuration', name: 'Avg. Time on Screen', description: 'Average time (in seconds) users spent on a screen.', section: 'App Tracking', is_active: true }, + { id: 'ga:totalEvents', name: 'Total Events', description: 'The total number of events for the profile, across all categories.', section: 'Event Tracking', is_active: true }, + { id: 'ga:uniqueDimensionCombinations', name: 'Unique Dimension Combinations', description: 'Unique Dimension Combinations counts the number of unique dimension-value combinations for each dimension in a report. This lets you build combined (concatenated) dimensions post-processing, which allows for more flexible reporting without having to update your tracking implementation or use additional custom-dimension slots. For more information, see https://support.google.com/analytics/answer/7084499.', section: 'Session', is_active: true }, + { id: 'ga:eventValue', name: 'Event Value', description: 'Total value of events for the profile.', section: 'Event Tracking', is_active: true }, + { id: 'ga:avgEventValue', name: 'Avg. Value', description: 'The average value of an event.', section: 'Event Tracking', is_active: true }, + { id: 'ga:sessionsWithEvent', name: 'Sessions with Event', description: 'The total number of sessions with events.', section: 'Event Tracking', is_active: true }, + { id: 'ga:eventsPerSessionWithEvent', name: 'Events / Session with Event', description: 'The average number of events per session with event.', section: 'Event Tracking', is_active: true }, + { id: 'ga:transactions', name: 'Transactions', description: 'The total number of transactions.', section: 'Ecommerce', is_active: true }, + { id: 'ga:transactionsPerSession', name: 'Ecommerce Conversion Rate', description: 'The average number of transactions in a session.', section: 'Ecommerce', is_active: true }, + { id: 'ga:transactionRevenue', name: 'Revenue', description: 'The total sale revenue (excluding shipping and tax) of the transaction.', section: 'Ecommerce', is_active: true }, + { id: 'ga:revenuePerTransaction', name: 'Average Order Value', description: 'The average revenue of an ecommerce transaction.', section: 'Ecommerce', is_active: true }, + { id: 'ga:transactionRevenuePerSession', name: 'Per Session Value', description: 'Average transaction revenue for a session.', section: 'Ecommerce', is_active: true }, + { id: 'ga:transactionShipping', name: 'Shipping', description: 'The total cost of shipping.', section: 'Ecommerce', is_active: true }, + { id: 'ga:transactionTax', name: 'Tax', description: 'Total tax for the transaction.', section: 'Ecommerce', is_active: true }, + { id: 'ga:totalValue', name: 'Total Value', description: 'Total value for the property (including total revenue and total goal value).', section: 'Ecommerce', is_active: true }, + { id: 'ga:itemQuantity', name: 'Quantity', description: 'Total number of items purchased. For example, if users purchase 2 frisbees and 5 tennis balls, this will be 7.', section: 'Ecommerce', is_active: true }, + { id: 'ga:uniquePurchases', name: 'Unique Purchases', description: 'The number of product sets purchased. For example, if users purchase 2 frisbees and 5 tennis balls from the site, this will be 2.', section: 'Ecommerce', is_active: true }, + { id: 'ga:revenuePerItem', name: 'Average Price', description: 'The average revenue per item.', section: 'Ecommerce', is_active: true }, + { id: 'ga:itemRevenue', name: 'Product Revenue', description: 'The total revenue from purchased product items.', section: 'Ecommerce', is_active: true }, + { id: 'ga:itemsPerPurchase', name: 'Average QTY', description: 'The average quantity of this item (or group of items) sold per purchase.', section: 'Ecommerce', is_active: true }, + { id: 'ga:localTransactionRevenue', name: 'Local Revenue', description: 'Transaction revenue in local currency.', section: 'Ecommerce', is_active: true }, + { id: 'ga:localTransactionShipping', name: 'Local Shipping', description: 'Transaction shipping cost in local currency.', section: 'Ecommerce', is_active: true }, + { id: 'ga:localTransactionTax', name: 'Local Tax', description: 'Transaction tax in local currency.', section: 'Ecommerce', is_active: true }, + { id: 'ga:localItemRevenue', name: 'Local Product Revenue', description: 'Product revenue in local currency.', section: 'Ecommerce', is_active: true }, + { id: 'ga:socialInteractions', name: 'Social Actions', description: 'The total number of social interactions.', section: 'Social Interactions', is_active: true }, + { id: 'ga:uniqueSocialInteractions', name: 'Unique Social Actions', description: 'The number of sessions during which the specified social action(s) occurred at least once. This is based on the the unique combination of socialInteractionNetwork, socialInteractionAction, and socialInteractionTarget.', section: 'Social Interactions', is_active: true }, + { id: 'ga:socialInteractionsPerSession', name: 'Actions Per Social Session', description: 'The number of social interactions per session.', section: 'Social Interactions', is_active: true }, + { id: 'ga:userTimingValue', name: 'User Timing (ms)', description: 'Total number of milliseconds for user timing.', section: 'User Timings', is_active: true }, + { id: 'ga:userTimingSample', name: 'User Timing Sample', description: 'The number of hits sent for a particular userTimingCategory, userTimingLabel, or userTimingVariable.', section: 'User Timings', is_active: true }, + { id: 'ga:avgUserTimingValue', name: 'Avg. User Timing (sec)', description: 'The average elapsed time.', section: 'User Timings', is_active: true }, + { id: 'ga:exceptions', name: 'Exceptions', description: 'The number of exceptions sent to Google Analytics.', section: 'Exceptions', is_active: true }, + { id: 'ga:exceptionsPerScreenview', name: 'Exceptions / Screen', description: 'The number of exceptions thrown divided by the number of screenviews.', section: 'Exceptions', is_active: true }, + { id: 'ga:fatalExceptions', name: 'Crashes', description: 'The number of exceptions where isFatal is set to true.', section: 'Exceptions', is_active: true }, + { id: 'ga:fatalExceptionsPerScreenview', name: 'Crashes / Screen', description: 'The number of fatal exceptions thrown divided by the number of screenviews.', section: 'Exceptions', is_active: true }, + { id: 'ga:metricXX', name: 'Custom Metric XX Value', description: 'The value of the requested custom metric, where XX refers to the number or index of the custom metric. Note that the data type of ga:metricXX can be INTEGER, CURRENCY, or TIME.', section: 'Custom Variables or Columns', is_active: true }, + // { id: 'ga:dcmFloodlightQuantity', name: 'DFA Conversions', description: 'The number of DCM Floodlight conversions (Analytics 360 only).', section: 'DoubleClick Campaign Manager', is_active: true }, + // { id: 'ga:dcmFloodlightRevenue', name: 'DFA Revenue', description: 'DCM Floodlight revenue (Analytics 360 only).', section: 'DoubleClick Campaign Manager', is_active: true }, + { id: 'ga:adsenseRevenue', name: 'AdSense Revenue', description: 'The total revenue from AdSense ads.', section: 'Adsense', is_active: true }, + { id: 'ga:adsenseAdUnitsViewed', name: 'AdSense Ad Units Viewed', description: 'The number of AdSense ad units viewed. An ad unit is a set of ads displayed as a result of one piece of the AdSense ad code. For details, see https://support.google.com/adsense/answer/32715?hl=en.', section: 'Adsense', is_active: true }, + { id: 'ga:adsenseAdsViewed', name: 'AdSense Impressions', description: 'The number of AdSense ads viewed. Multiple ads can be displayed within an ad Unit.', section: 'Adsense', is_active: true }, + { id: 'ga:adsenseAdsClicks', name: 'AdSense Ads Clicked', description: 'The number of times AdSense ads on the site were clicked.', section: 'Adsense', is_active: true }, + { id: 'ga:adsensePageImpressions', name: 'AdSense Page Impressions', description: 'The number of pageviews during which an AdSense ad was displayed. A page impression can have multiple ad Units.', section: 'Adsense', is_active: true }, + { id: 'ga:adsenseCTR', name: 'AdSense CTR', description: 'The percentage of page impressions resulted in a click on an AdSense ad.', section: 'Adsense', is_active: true }, + { id: 'ga:adsenseECPM', name: 'AdSense eCPM', description: 'The estimated cost per thousand page impressions. It is the AdSense Revenue per 1,000 page impressions.', section: 'Adsense', is_active: true }, + { id: 'ga:adsenseExits', name: 'AdSense Exits', description: 'The number of sessions ended due to a user clicking on an AdSense ad.', section: 'Adsense', is_active: true }, + { id: 'ga:adsenseViewableImpressionPercent', name: 'AdSense Viewable Impression %', description: 'The percentage of viewable impressions.', section: 'Adsense', is_active: true }, + { id: 'ga:adsenseCoverage', name: 'AdSense Coverage', description: 'The percentage of ad requests that returned at least one ad.', section: 'Adsense', is_active: true }, + // { id: 'ga:adxImpressions', name: 'AdX Impressions', description: 'An Ad Exchange ad impression is reported whenever an individual ad is displayed on the website. For example, if a page with two ad units is viewed once, we\'ll display two impressions.', section: 'Ad Exchange', is_active: true }, + // { id: 'ga:adxCoverage', name: 'AdX Coverage', description: 'Coverage is the percentage of ad requests that returned at least one ad. Generally, coverage can help identify sites where the Ad Exchange account isn\'t able to provide targeted ads. (Ad Impressions / Total Ad Requests) * 100', section: 'Ad Exchange', is_active: true }, + // { id: 'ga:adxMonetizedPageviews', name: 'AdX Monetized Pageviews', description: 'This measures the total number of pageviews on the property that were shown with an ad from the linked Ad Exchange account. Note that a single page can have multiple ad units.', section: 'Ad Exchange', is_active: true }, + // { id: 'ga:adxImpressionsPerSession', name: 'AdX Impressions / Session', description: 'The ratio of Ad Exchange ad impressions to Analytics sessions (Ad Impressions / Analytics Sessions).', section: 'Ad Exchange', is_active: true }, + // { id: 'ga:adxViewableImpressionsPercent', name: 'AdX Viewable Impressions %', description: 'The percentage of viewable ad impressions. An impression is considered a viewable impression when it has appeared within users\' browsers and has the opportunity to be seen.', section: 'Ad Exchange', is_active: true }, + // { id: 'ga:adxClicks', name: 'AdX Clicks', description: 'The number of times AdX ads were clicked on the site.', section: 'Ad Exchange', is_active: true }, + // { id: 'ga:adxCTR', name: 'AdX CTR', description: 'The percentage of pageviews that resulted in a click on an Ad Exchange ad.', section: 'Ad Exchange', is_active: true }, + // { id: 'ga:adxRevenue', name: 'AdX Revenue', description: 'The total estimated revenue from Ad Exchange ads.', section: 'Ad Exchange', is_active: true }, + // { id: 'ga:adxRevenuePer1000Sessions', name: 'AdX Revenue / 1000 Sessions', description: 'The total estimated revenue from Ad Exchange ads per 1,000 Analytics sessions. Note that this metric is based on sessions to the site, not on ad impressions.', section: 'Ad Exchange', is_active: true }, + // { id: 'ga:adxECPM', name: 'AdX eCPM', description: 'The effective cost per thousand pageviews. It is the Ad Exchange revenue per 1,000 pageviews.', section: 'Ad Exchange', is_active: true }, + // { id: 'ga:dfpImpressions', name: 'DFP Impressions', description: 'A DFP ad impression is reported whenever an individual ad is displayed on the website. For example, if a page with two ad units is viewed once, we\'ll display two impressions (DFP linking enabled and Hide DFP Revenue not enabled).', section: 'DoubleClick for Publishers', is_active: true }, + // { id: 'ga:dfpCoverage', name: 'DFP Coverage', description: 'Coverage is the percentage of ad requests that returned at least one ad. Generally, coverage can help identify sites where the DFP account isn\'t able to provide targeted ads. (Ad Impressions / Total Ad Requests) * 100 (DFP linking enabled and Hide DFP Revenue not enabled).', section: 'DoubleClick for Publishers', is_active: true }, + // { id: 'ga:dfpMonetizedPageviews', name: 'DFP Monetized Pageviews', description: 'This measures the total number of pageviews on the property that were shown with an ad from the linked DFP account. Note that a single page can have multiple ad units (DFP linking enabled and Hide DFP Revenue not enabled).', section: 'DoubleClick for Publishers', is_active: true }, + // { id: 'ga:dfpImpressionsPerSession', name: 'DFP Impressions / Session', description: 'The ratio of DFP ad impressions to Analytics sessions (Ad Impressions / Analytics Sessions) (DFP linking enabled and Hide DFP Revenue not enabled).', section: 'DoubleClick for Publishers', is_active: true }, + // { id: 'ga:dfpViewableImpressionsPercent', name: 'DFP Viewable Impressions %', description: 'The percentage of viewable ad impressions. An impression is considered a viewable impression when it has appeared within users\' browsers and has the opportunity to be seen (DFP linking enabled and Hide DFP Revenue not enabled).', section: 'DoubleClick for Publishers', is_active: true }, + // { id: 'ga:dfpClicks', name: 'DFP Clicks', description: 'The number of times DFP ads were clicked on the site (DFP linking enabled and Hide DFP Revenue not enabled).', section: 'DoubleClick for Publishers', is_active: true }, + // { id: 'ga:dfpCTR', name: 'DFP CTR', description: 'The percentage of pageviews that resulted in a click on an DFP ad (DFP linking enabled and Hide DFP Revenue not enabled).', section: 'DoubleClick for Publishers', is_active: true }, + // { id: 'ga:dfpRevenue', name: 'DFP Revenue', description: 'DFP revenue is an estimate of the total ad revenue based on served impressions (DFP linking enabled and Hide DFP Revenue not enabled).', section: 'DoubleClick for Publishers', is_active: true }, + // { id: 'ga:dfpRevenuePer1000Sessions', name: 'DFP Revenue / 1000 Sessions', description: 'The total estimated revenue from DFP ads per 1,000 Analytics sessions. Note that this metric is based on sessions to the site, not on ad impressions (DFP linking enabled and Hide DFP Revenue not enabled).', section: 'DoubleClick for Publishers', is_active: true }, + // { id: 'ga:dfpECPM', name: 'DFP eCPM', description: 'The effective cost per thousand pageviews. It is the DFP revenue per 1,000 pageviews (DFP linking enabled and Hide DFP Revenue not enabled).', section: 'DoubleClick for Publishers', is_active: true }, + // { id: 'ga:backfillImpressions', name: 'DFP Backfill Impressions', description: 'Backfill Impressions is the sum of all AdSense or Ad Exchance ad impressions served as backfill through DFP. An ad impression is reported whenever an individual ad is displayed on the website. For example, if a page with two ad units is viewed once, we\'ll display two impressions (DFP linking enabled and Hide DFP Revenue not enabled).', section: 'DoubleClick for Publishers Backfill', is_active: true }, + // { id: 'ga:backfillCoverage', name: 'DFP Backfill Coverage', description: 'Backfill Coverage is the percentage of backfill ad requests that returned at least one ad. Generally, coverage can help identify sites where the publisher account isn\'t able to provide targeted ads. (Ad Impressions / Total Ad Requests) * 100. If both AdSense and Ad Exchange are providing backfill ads, this metric is the weighted average of the two backfill accounts (DFP linking enabled and Hide DFP Revenue not enabled).', section: 'DoubleClick for Publishers Backfill', is_active: true }, + // { id: 'ga:backfillMonetizedPageviews', name: 'DFP Backfill Monetized Pageviews', description: 'This measures the total number of pageviews on the property that were shown with at least one ad from the linked backfill account(s). Note that a single monetized pageview can include multiple ad impressions (DFP linking enabled and Hide DFP Revenue not enabled).', section: 'DoubleClick for Publishers Backfill', is_active: true }, + // { id: 'ga:backfillImpressionsPerSession', name: 'DFP Backfill Impressions / Session', description: 'The ratio of backfill ad impressions to Analytics sessions (Ad Impressions / Analytics Sessions). If both AdSense and Ad Exchange are providing backfill ads, this metric is the sum of the two backfill accounts (DFP linking enabled and Hide DFP Revenue not enabled).', section: 'DoubleClick for Publishers Backfill', is_active: true }, + // { id: 'ga:backfillViewableImpressionsPercent', name: 'DFP Backfill Viewable Impressions %', description: 'The percentage of backfill ad impressions that were viewable. An impression is considered a viewable impression when it has appeared within the users\' browsers and had the opportunity to be seen. If AdSense and Ad Exchange are both providing backfill ads, this metric is the weighted average of the two backfill accounts (DFP linking enabled and Hide DFP Revenue not enabled).', section: 'DoubleClick for Publishers Backfill', is_active: true }, + // { id: 'ga:backfillClicks', name: 'DFP Backfill Clicks', description: 'The number of times backfill ads were clicked on the site. If AdSense and Ad Exchange are both providing backfill ads, this metric is the sum of the two backfill accounts (DFP linking enabled and Hide DFP Revenue not enabled).', section: 'DoubleClick for Publishers Backfill', is_active: true }, + // { id: 'ga:backfillCTR', name: 'DFP Backfill CTR', description: 'The percentage of backfill impressions that resulted in a click on an ad. If AdSense and Ad Exchange are both providing backfill ads, this metric is the weighted average of the two backfill accounts (DFP linking enabled and Hide DFP Revenue not enabled).', section: 'DoubleClick for Publishers Backfill', is_active: true }, + // { id: 'ga:backfillRevenue', name: 'DFP Backfill Revenue', description: 'The total estimated revenue from backfill ads. If AdSense and Ad Exchange are both providing backfill ads, this metric is the sum of the two backfill accounts (DFP linking enabled and Hide DFP Revenue not enabled).', section: 'DoubleClick for Publishers Backfill', is_active: true }, + // { id: 'ga:backfillRevenuePer1000Sessions', name: 'DFP Backfill Revenue / 1000 Sessions', description: 'The total estimated revenue from backfill ads per 1,000 Analytics sessions. Note that this metric is based on sessions to the site and not ad impressions. If both AdSense and Ad Exchange are providing backfill ads, this metric is the sum of the two backfill accounts (DFP linking enabled and Hide DFP Revenue not enabled).', section: 'DoubleClick for Publishers Backfill', is_active: true }, + // { id: 'ga:backfillECPM', name: 'DFP Backfill eCPM', description: 'The effective cost per thousand pageviews. It is the DFP Backfill Revenue per 1,000 pageviews. If both AdSense and Ad Exchange are providing backfill ads, this metric is the sum of the two backfill accounts (DFP linking enabled and Hide DFP Revenue not enabled).', section: 'DoubleClick for Publishers Backfill', is_active: true }, + { id: 'ga:buyToDetailRate', name: 'Buy-to-Detail Rate', description: 'Unique purchases divided by views of product detail pages (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:calcMetric_<NAME>', name: 'Calculated Metric', description: 'The value of the requested calculated metric, where <NAME> refers to the user-defined name of the calculated metric. Note that the data type of ga:calcMetric_<NAME> can be FLOAT, INTEGER, CURRENCY, TIME, or PERCENT. For details, see https://support.google.com/analytics/answer/6121409.', section: 'Custom Variables or Columns', is_active: true }, + { id: 'ga:cartToDetailRate', name: 'Cart-to-Detail Rate', description: 'Product adds divided by views of product details (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:cohortActiveUsers', name: 'Users', description: 'This metric is relevant in the context of ga:cohortNthDay/ga:cohortNthWeek/ga:cohortNthMonth. It indicates the number of users in the cohort who are active in the time window corresponding to the cohort nth day/week/month. For example, for ga:cohortNthWeek = 1, number of users (in the cohort) who are active in week 1. If a request doesn\'t have any of ga:cohortNthDay/ga:cohortNthWeek/ga:cohortNthMonth, this metric will have the same value as ga:cohortTotalUsers.', section: 'Lifetime Value and Cohorts', is_active: true }, + { id: 'ga:cohortAppviewsPerUser', name: 'Appviews per User', description: 'App views per user for a cohort.', section: 'Lifetime Value and Cohorts', is_active: true }, + { id: 'ga:cohortAppviewsPerUserWithLifetimeCriteria', name: 'Appviews Per User (LTV)', description: 'App views per user for the acquisition dimension for a cohort.', section: 'Lifetime Value and Cohorts', is_active: true }, + { id: 'ga:cohortGoalCompletionsPerUser', name: 'Goal Completions per User', description: 'Goal completions per user for the acquisition dimension for a cohort.', section: 'Lifetime Value and Cohorts', is_active: true }, + { id: 'ga:cohortGoalCompletionsPerUserWithLifetimeCriteria', name: 'Goal Completions Per User (LTV)', description: 'Goal completions per user for a cohort.', section: 'Lifetime Value and Cohorts', is_active: true }, + { id: 'ga:cohortPageviewsPerUser', name: 'Pageviews per User', description: 'Pageviews per user for a cohort.', section: 'Lifetime Value and Cohorts', is_active: true }, + { id: 'ga:cohortPageviewsPerUserWithLifetimeCriteria', name: 'Pageviews Per User (LTV)', description: 'Pageviews per user for the acquisition dimension for a cohort.', section: 'Lifetime Value and Cohorts', is_active: true }, + { id: 'ga:cohortRetentionRate', name: 'User Retention', description: 'Cohort retention rate.', section: 'Lifetime Value and Cohorts', is_active: true }, + { id: 'ga:cohortRevenuePerUser', name: 'Revenue per User', description: 'Revenue per user for a cohort.', section: 'Lifetime Value and Cohorts', is_active: true }, + { id: 'ga:cohortRevenuePerUserWithLifetimeCriteria', name: 'Revenue Per User (LTV)', description: 'Revenue per user for the acquisition dimension for a cohort.', section: 'Lifetime Value and Cohorts', is_active: true }, + { id: 'ga:cohortSessionDurationPerUser', name: 'Session Duration per User', description: 'Session duration per user for a cohort.', section: 'Lifetime Value and Cohorts', is_active: true }, + { id: 'ga:cohortSessionDurationPerUserWithLifetimeCriteria', name: 'Session Duration Per User (LTV)', description: 'Session duration per user for the acquisition dimension for a cohort.', section: 'Lifetime Value and Cohorts', is_active: true }, + { id: 'ga:cohortSessionsPerUser', name: 'Sessions per User', description: 'Sessions per user for a cohort.', section: 'Lifetime Value and Cohorts', is_active: true }, + { id: 'ga:cohortSessionsPerUserWithLifetimeCriteria', name: 'Sessions Per User (LTV)', description: 'Sessions per user for the acquisition dimension for a cohort.', section: 'Lifetime Value and Cohorts', is_active: true }, + { id: 'ga:cohortTotalUsers', name: 'Total Users', description: 'Number of users belonging to the cohort, also known as cohort size.', section: 'Lifetime Value and Cohorts', is_active: true }, + { id: 'ga:cohortTotalUsersWithLifetimeCriteria', name: 'Users', description: 'This is relevant in the context of a request which has the dimensions ga:acquisitionTrafficChannel/ga:acquisitionSource/ga:acquisitionMedium/ga:acquisitionCampaign. It represents the number of users in the cohorts who are acquired through the current channel/source/medium/campaign. For example, for ga:acquisitionTrafficChannel=Direct, it represents the number users in the cohort, who were acquired directly. If none of these mentioned dimensions are present, then its value is equal to ga:cohortTotalUsers.', section: 'Lifetime Value and Cohorts', is_active: true }, + { id: 'ga:correlationScore', name: 'Correlation Score', description: 'Correlation Score for related products.', section: 'Related Products', is_active: true }, + // { id: 'ga:dbmCPA', name: 'DBM eCPA', description: 'DBM Revenue eCPA (Analytics 360 only, requires integration with DBM).', section: 'DoubleClick Bid Manager', is_active: true }, + // { id: 'ga:dbmCPC', name: 'DBM eCPC', description: 'DBM Revenue eCPC (Analytics 360 only, requires integration with DBM).', section: 'DoubleClick Bid Manager', is_active: true }, + // { id: 'ga:dbmCPM', name: 'DBM eCPM', description: 'DBM Revenue eCPM (Analytics 360 only, requires integration with DBM).', section: 'DoubleClick Bid Manager', is_active: true }, + // { id: 'ga:dbmCTR', name: 'DBM CTR', description: 'DBM CTR (Analytics 360 only, requires integration with DBM).', section: 'DoubleClick Bid Manager', is_active: true }, + // { id: 'ga:dbmClicks', name: 'DBM Clicks', description: 'DBM Total Clicks (Analytics 360 only, requires integration with DBM).', section: 'DoubleClick Bid Manager', is_active: true }, + // { id: 'ga:dbmConversions', name: 'DBM Conversions', description: 'DBM Total Conversions (Analytics 360 only, requires integration with DBM).', section: 'DoubleClick Bid Manager', is_active: true }, + // { id: 'ga:dbmCost', name: 'DBM Cost', description: 'DBM Cost (Analytics 360 only, requires integration with DBM).', section: 'DoubleClick Bid Manager', is_active: true }, + // { id: 'ga:dbmImpressions', name: 'DBM Impressions', description: 'DBM Total Impressions (Analytics 360 only, requires integration with DBM).', section: 'DoubleClick Bid Manager', is_active: true }, + // { id: 'ga:dbmROAS', name: 'DBM ROAS', description: 'DBM ROAS (Analytics 360 only, requires integration with DBM).', section: 'DoubleClick Bid Manager', is_active: true }, + // { id: 'ga:dcmCPC', name: 'DFA CPC', description: 'DCM Cost Per Click (Analytics 360 only).', section: 'DoubleClick Campaign Manager', is_active: true }, + // { id: 'ga:dcmCTR', name: 'DFA CTR', description: 'DCM Click Through Rate (Analytics 360 only).', section: 'DoubleClick Campaign Manager', is_active: true }, + // { id: 'ga:dcmClicks', name: 'DFA Clicks', description: 'DCM Total Clicks (Analytics 360 only).', section: 'DoubleClick Campaign Manager', is_active: true }, + // { id: 'ga:dcmCost', name: 'DFA Cost', description: 'DCM Total Cost (Analytics 360 only).', section: 'DoubleClick Campaign Manager', is_active: true }, + // { id: 'ga:dcmImpressions', name: 'DFA Impressions', description: 'DCM Total Impressions (Analytics 360 only).', section: 'DoubleClick Campaign Manager', is_active: true }, + // { id: 'ga:dcmROAS', name: 'DFA ROAS', description: 'DCM Return On Ad Spend (ROAS) (Analytics 360 only).', section: 'DoubleClick Campaign Manager', is_active: true }, + // { id: 'ga:dcmRPC', name: 'DFA RPC', description: 'DCM Revenue Per Click (Analytics 360 only).', section: 'DoubleClick Campaign Manager', is_active: true }, + // { id: 'ga:dsCPC', name: 'DS CPC', description: 'DS Cost to advertiser per click (Analytics 360 only, requires integration with DS).', section: 'DoubleClick Search', is_active: true }, + // { id: 'ga:dsCTR', name: 'DS CTR', description: 'DS Click Through Rate (Analytics 360 only, requires integration with DS).', section: 'DoubleClick Search', is_active: true }, + // { id: 'ga:dsClicks', name: 'DS Clicks', description: 'DS Clicks (Analytics 360 only, requires integration with DS).', section: 'DoubleClick Search', is_active: true }, + // { id: 'ga:dsCost', name: 'DS Cost', description: 'DS Cost (Analytics 360 only, requires integration with DS).', section: 'DoubleClick Search', is_active: true }, + // { id: 'ga:dsImpressions', name: 'DS Impressions', description: 'DS Impressions (Analytics 360 only, requires integration with DS).', section: 'DoubleClick Search', is_active: true }, + // { id: 'ga:dsProfit', name: 'DS Profit', description: 'DS Profie (Analytics 360 only, requires integration with DS).', section: 'DoubleClick Search', is_active: true }, + // { id: 'ga:dsReturnOnAdSpend', name: 'DS ROAS', description: 'DS Return On Ad Spend (Analytics 360 only, requires integration with DS).', section: 'DoubleClick Search', is_active: true }, + // { id: 'ga:dsRevenuePerClick', name: 'DS RPC', description: 'DS Revenue Per Click (Analytics 360 only, requires integration with DS).', section: 'DoubleClick Search', is_active: true }, + { id: 'ga:hits', name: 'Hits', description: 'Total number of hits for the view (profile). This metric sums all hit types, including pageview, custom event, ecommerce, and other types. Because this metric is based on the view (profile), not on the property, it is not the same as the property\'s hit volume.', section: 'Session', is_active: true }, + { id: 'ga:internalPromotionCTR', name: 'Internal Promotion CTR', description: 'The rate at which users clicked through to view the internal promotion (ga:internalPromotionClicks / ga:internalPromotionViews) - (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:internalPromotionClicks', name: 'Internal Promotion Clicks', description: 'The number of clicks on an internal promotion (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:internalPromotionViews', name: 'Internal Promotion Views', description: 'The number of views of an internal promotion (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:localProductRefundAmount', name: 'Local Product Refund Amount', description: 'Refund amount in local currency for a given product (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:localRefundAmount', name: 'Local Refund Amount', description: 'Total refund amount in local currency for the transaction (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:productAddsToCart', name: 'Product Adds To Cart', description: 'Number of times the product was added to the shopping cart (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:productCheckouts', name: 'Product Checkouts', description: 'Number of times the product was included in the check-out process (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:productDetailViews', name: 'Product Detail Views', description: 'Number of times users viewed the product-detail page (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:productListCTR', name: 'Product List CTR', description: 'The rate at which users clicked through on the product in a product list (ga:productListClicks / ga:productListViews) - (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:productListClicks', name: 'Product List Clicks', description: 'Number of times users clicked the product when it appeared in the product list (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:productListViews', name: 'Product List Views', description: 'Number of times the product appeared in a product list (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:productRefundAmount', name: 'Product Refund Amount', description: 'Total refund amount associated with the product (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:productRefunds', name: 'Product Refunds', description: 'Number of times a refund was issued for the product (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:productRemovesFromCart', name: 'Product Removes From Cart', description: 'Number of times the product was removed from the shopping cart (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:productRevenuePerPurchase', name: 'Product Revenue per Purchase', description: 'Average product revenue per purchase (commonly used with Product Coupon Code) (ga:itemRevenue / ga:uniquePurchases) - (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:quantityAddedToCart', name: 'Quantity Added To Cart', description: 'Number of product units added to the shopping cart (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:quantityCheckedOut', name: 'Quantity Checked Out', description: 'Number of product units included in check out (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:quantityRefunded', name: 'Quantity Refunded', description: 'Number of product units refunded (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:quantityRemovedFromCart', name: 'Quantity Removed From Cart', description: 'Number of product units removed from a shopping cart (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:queryProductQuantity', name: 'Queried Product Quantity', description: 'Quantity of the product being queried.', section: 'Related Products', is_active: true }, + { id: 'ga:refundAmount', name: 'Refund Amount', description: 'Currency amount refunded for a transaction (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:relatedProductQuantity', name: 'Related Product Quantity', description: 'Quantity of the related product.', section: 'Related Products', is_active: true }, + { id: 'ga:revenuePerUser', name: 'Revenue per User', description: 'The total sale revenue (excluding shipping and tax) of the transaction divided by the total number of users.', section: 'Ecommerce', is_active: true }, + { id: 'ga:sessionsPerUser', name: 'Number of Sessions per User', description: 'The total number of sessions divided by the total number of users.', section: 'User', is_active: true }, + { id: 'ga:totalRefunds', name: 'Refunds', description: 'Number of refunds that have been issued (Enhanced Ecommerce).', section: 'Ecommerce', is_active: true }, + { id: 'ga:transactionsPerUser', name: 'Transactions per User', description: 'Total number of transactions divided by total number of users.', section: 'Ecommerce', is_active: true } ]; + +export const segments = [ { id: 'gaid::-1', name: 'All Users', description: '', is_active: true }, + { id: 'gaid::-2', name: 'New Users', description: 'sessions::condition::ga:userType==New Visitor', is_active: true }, + { id: 'gaid::-3', name: 'Returning Users', description: 'sessions::condition::ga:userType==Returning Visitor', is_active: true }, + { id: 'gaid::-4', name: 'Paid Traffic', description: 'sessions::condition::ga:medium=~^(cpc|ppc|cpa|cpm|cpv|cpp)$', is_active: true }, + { id: 'gaid::-5', name: 'Organic Traffic', description: 'sessions::condition::ga:medium==organic', is_active: true }, + { id: 'gaid::-6', name: 'Search Traffic', description: 'sessions::condition::ga:medium=~^(cpc|ppc|cpa|cpm|cpv|cpp|organic)$', is_active: true }, + { id: 'gaid::-7', name: 'Direct Traffic', description: 'sessions::condition::ga:medium==(none)', is_active: true }, + { id: 'gaid::-8', name: 'Referral Traffic', description: 'sessions::condition::ga:medium==referral', is_active: true }, + { id: 'gaid::-9', name: 'Sessions with Conversions', description: 'sessions::condition::ga:goalCompletionsAll>0', is_active: true }, + { id: 'gaid::-10', name: 'Sessions with Transactions', description: 'sessions::condition::ga:transactions>0', is_active: true }, + { id: 'gaid::-11', name: 'Mobile and Tablet Traffic', description: 'sessions::condition::ga:deviceCategory==mobile,ga:deviceCategory==tablet', is_active: true }, + { id: 'gaid::-12', name: 'Non-bounce Sessions', description: 'sessions::condition::ga:bounces==0', is_active: true }, + { id: 'gaid::-13', name: 'Tablet Traffic', description: 'sessions::condition::ga:deviceCategory==tablet', is_active: true }, + { id: 'gaid::-14', name: 'Mobile Traffic', description: 'sessions::condition::ga:deviceCategory==mobile', is_active: true }, + { id: 'gaid::-15', name: 'Tablet and Desktop Traffic', description: 'sessions::condition::ga:deviceCategory==tablet,ga:deviceCategory==desktop', is_active: true }, + { id: 'gaid::-16', name: 'Android Traffic', description: 'sessions::condition::ga:operatingSystem==Android', is_active: true }, + { id: 'gaid::-17', name: 'iOS Traffic', description: 'sessions::condition::ga:operatingSystem=~^(iOS|iPad|iPhone|iPod)$', is_active: true }, + { id: 'gaid::-18', name: 'Other Traffic (Neither iOS nor Android)', description: 'sessions::condition::ga:operatingSystem!~^(Android|iOS|iPad|iPhone|iPod)$', is_active: true }, + { id: 'gaid::-19', name: 'Bounced Sessions', description: 'sessions::condition::ga:bounces>0', is_active: true }, + { id: 'gaid::-100', name: 'Single Session Users', description: 'users::condition::ga:sessions==1', is_active: true }, + { id: 'gaid::-101', name: 'Multi-session Users', description: 'users::condition::ga:sessions>1', is_active: true }, + { id: 'gaid::-102', name: 'Converters', description: 'users::condition::ga:goalCompletionsAll>0,ga:transactions>0', is_active: true }, + { id: 'gaid::-103', name: 'Non-Converters', description: 'users::condition::ga:goalCompletionsAll==0;ga:transactions==0', is_active: true }, + { id: 'gaid::-104', name: 'Made a Purchase', description: 'users::condition::ga:transactions>0', is_active: true }, + { id: 'gaid::-105', name: 'Performed Site Search', description: 'users::sequence::ga:searchKeyword!~^$|^\\(not set\\)$', is_active: true } ]; + +fields["ga:date"].grouping_options = [ + "hour", + "day", + "week", + "month", + "year", + "hour-of-day", + "day-of-week", + "week-of-year", + "month-of-year" +]; diff --git a/frontend/src/metabase/lib/query.js b/frontend/src/metabase/lib/query.js index 5ff9b6c8ecb..6a577a3d32b 100644 --- a/frontend/src/metabase/lib/query.js +++ b/frontend/src/metabase/lib/query.js @@ -79,8 +79,16 @@ var Query = { return dataset_query && dataset_query.type === "native"; }, - canRun(query) { - return query && query.source_table != undefined && Query.hasValidAggregation(query); + canRun(query, tableMetadata) { + if (!query || query.source_table == null || !Query.hasValidAggregation(query)) { + return false; + } + // check that the table supports this aggregation, if we have tableMetadata + let agg = query.aggregation && query.aggregation[0] || "rows"; + if (!mbqlCompare(agg, "metric") && tableMetadata && !_.findWhere(tableMetadata.aggregation_options, { short: agg })) { + return false; + } + return true; }, cleanQuery(query) { @@ -196,9 +204,9 @@ var Query = { }, canSortByAggregateField(query) { - var SORTABLE_AGGREGATION_TYPES = new Set(["avg", "count", "distinct", "stddev", "sum", "min", "max"]); + var SORTABLE_AGGREGATION_TYPES = new Set(["avg", "count", "distinct", "stddev", "sum", "min", "max", "metric"]); - return Query.hasValidBreakout(query) && SORTABLE_AGGREGATION_TYPES.has(query.aggregation[0]); + return Query.hasValidBreakout(query) && SORTABLE_AGGREGATION_TYPES.has(query.aggregation && query.aggregation[0].toLowerCase()); }, addDimension(query) { diff --git a/frontend/src/metabase/lib/schema_metadata.js b/frontend/src/metabase/lib/schema_metadata.js index 4f547cc600a..0665fd12841 100644 --- a/frontend/src/metabase/lib/schema_metadata.js +++ b/frontend/src/metabase/lib/schema_metadata.js @@ -357,86 +357,95 @@ function dimensionFields(fields) { } var Aggregators = [{ - "name": "Raw data", - "short": "rows", - "description": "Just a table with the rows in the answer, no additional operations.", - "validFieldsFilters": [], - "requiresField": false + name: "Raw data", + short: "rows", + description: "Just a table with the rows in the answer, no additional operations.", + validFieldsFilters: [], + requiresField: false, + requiredDriverFeature: "basic-aggregations" }, { - "name": "Count of rows", - "short": "count", - "description": "Total number of rows in the answer.", - "validFieldsFilters": [], - "requiresField": false + name: "Count of rows", + short: "count", + description: "Total number of rows in the answer.", + validFieldsFilters: [], + requiresField: false, + requiredDriverFeature: "basic-aggregations" }, { - "name": "Sum of ...", - "short": "sum", - "description": "Sum of all the values of a column.", - "validFieldsFilters": [summableFields], - "requiresField": true + name: "Sum of ...", + short: "sum", + description: "Sum of all the values of a column.", + validFieldsFilters: [summableFields], + requiresField: true, + requiredDriverFeature: "basic-aggregations" }, { - "name": "Average of ...", - "short": "avg", - "description": "Average of all the values of a column", - "validFieldsFilters": [summableFields], - "requiresField": true + name: "Average of ...", + short: "avg", + description: "Average of all the values of a column", + validFieldsFilters: [summableFields], + requiresField: true, + requiredDriverFeature: "basic-aggregations" }, { - "name": "Number of distinct values of ...", - "short": "distinct", - "description": "Number of unique values of a column among all the rows in the answer.", - "validFieldsFilters": [allFields], - "requiresField": true + name: "Number of distinct values of ...", + short: "distinct", + description: "Number of unique values of a column among all the rows in the answer.", + validFieldsFilters: [allFields], + requiresField: true, + requiredDriverFeature: "basic-aggregations" }, { - "name": "Cumulative sum of ...", - "short": "cum_sum", - "description": "Additive sum of all the values of a column.\ne.x. total revenue over time.", - "validFieldsFilters": [summableFields], - "requiresField": true + name: "Cumulative sum of ...", + short: "cum_sum", + description: "Additive sum of all the values of a column.\ne.x. total revenue over time.", + validFieldsFilters: [summableFields], + requiresField: true, + requiredDriverFeature: "basic-aggregations" }, { - "name": "Cumulative count of rows", - "short": "cum_count", - "description": "Additive count of the number of rows.\ne.x. total number of sales over time.", - "validFieldsFilters": [], - "requiresField": false + name: "Cumulative count of rows", + short: "cum_count", + description: "Additive count of the number of rows.\ne.x. total number of sales over time.", + validFieldsFilters: [], + requiresField: false, + requiredDriverFeature: "basic-aggregations" }, { - "name": "Standard deviation of ...", - "short": "stddev", - "description": "Number which expresses how much the values of a column vary among all rows in the answer.", - "validFieldsFilters": [summableFields], - "requiresField": true, - "requiredDriverFeature": "standard-deviation-aggregations" + name: "Standard deviation of ...", + short: "stddev", + description: "Number which expresses how much the values of a column vary among all rows in the answer.", + validFieldsFilters: [summableFields], + requiresField: true, + requiredDriverFeature: "standard-deviation-aggregations" }, { name: "Minimum of ...", short: "min", description: "Minimum value of a column", validFieldsFilters: [summableFields], requiresField: true, + requiredDriverFeature: "basic-aggregations" }, { name: "Maximum of ...", short: "max", description: "Maximum value of a column", validFieldsFilters: [summableFields], requiresField: true, + requiredDriverFeature: "basic-aggregations" }]; var BreakoutAggregator = { - "name": "Break out by dimension", - "short": "breakout", - "validFieldsFilters": [dimensionFields] + name: "Break out by dimension", + short: "breakout", + validFieldsFilters: [dimensionFields] }; function populateFields(aggregator, fields) { return { - 'name': aggregator.name, - 'short': aggregator.short, - 'description': aggregator.description || '', - 'advanced': aggregator.advanced || false, - 'fields': _.map(aggregator.validFieldsFilters, function(validFieldsFilterFn) { + name: aggregator.name, + short: aggregator.short, + description: aggregator.description || '', + advanced: aggregator.advanced || false, + fields: _.map(aggregator.validFieldsFilters, function(validFieldsFilterFn) { return validFieldsFilterFn(fields); }), - 'validFieldsFilters': aggregator.validFieldsFilters, - "requiresField": aggregator.requiresField, - "requiredDriverFeature": aggregator.requiredDriverFeature + validFieldsFilters: aggregator.validFieldsFilters, + requiresField: aggregator.requiresField, + requiredDriverFeature: aggregator.requiredDriverFeature }; } diff --git a/frontend/src/metabase/meta/types/Dataset.js b/frontend/src/metabase/meta/types/Dataset.js index 75756e97e55..9e821479af4 100644 --- a/frontend/src/metabase/meta/types/Dataset.js +++ b/frontend/src/metabase/meta/types/Dataset.js @@ -6,7 +6,7 @@ export type ColumnName = string; export type Column = { name: ColumnName, display_name: string, - base_type: string + base_type: string, } export type ISO8601Times = string; diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js index 5399e38bd03..80ee419bf5f 100644 --- a/frontend/src/metabase/query_builder/actions.js +++ b/frontend/src/metabase/query_builder/actions.js @@ -16,6 +16,7 @@ import { createQuery } from "metabase/lib/query"; import { loadTableAndForeignKeys } from "metabase/lib/table"; import { isPK, isFK } from "metabase/lib/types"; import Utils from "metabase/lib/utils"; +import { getEngineNativeType, formatJsonQuery } from "metabase/lib/engine"; import { defer } from "metabase/lib/promise"; import { applyParameters } from "metabase/meta/Card"; @@ -569,8 +570,8 @@ export const setQueryMode = createThunkAction(SET_QUERY_MODE, (type) => { let nativeQuery = _.pick(queryResult.data.native_form, "query", "collection"); // when the driver requires JSON we need to stringify it because it's been parsed already - if (_.contains(["mongo", "druid"], tableMetadata.db.engine)) { - nativeQuery.query = JSON.stringify(queryResult.data.native_form.query); + if (getEngineNativeType(tableMetadata.db.engine) === "json") { + nativeQuery.query = formatJsonQuery(queryResult.data.native_form.query, tableMetadata.db.engine); } else { nativeQuery.query = formatSQL(nativeQuery.query); } diff --git a/frontend/src/metabase/query_builder/components/AggregationPopover.jsx b/frontend/src/metabase/query_builder/components/AggregationPopover.jsx index df47699163d..a56f3ba6516 100644 --- a/frontend/src/metabase/query_builder/components/AggregationPopover.jsx +++ b/frontend/src/metabase/query_builder/components/AggregationPopover.jsx @@ -113,15 +113,19 @@ export default class AggregationPopover extends Component { selectedAggregation = _.findWhere(availableAggregations, { short: AggregationClause.getOperator(aggregation) }); } - let sections = [{ - name: "Metabasics", - items: availableAggregations.map(aggregation => ({ - name: aggregation.name, - value: [aggregation.short].concat(aggregation.fields.map(field => null)), - aggregation: aggregation - })), - icon: "table2" - }]; + let sections = []; + + if (availableAggregations.length > 0) { + sections.push({ + name: "Metabasics", + items: availableAggregations.map(aggregation => ({ + name: aggregation.name, + value: [aggregation.short].concat(aggregation.fields.map(field => null)), + aggregation: aggregation + })), + icon: "table2" + }); + } // we only want to consider active metrics, with the ONE exception that if the currently selected aggregation is a // retired metric then we include it in the list to maintain continuity diff --git a/frontend/src/metabase/query_builder/components/AggregationWidget.jsx b/frontend/src/metabase/query_builder/components/AggregationWidget.jsx index c8369eb0e2d..f8f44f9e5e6 100644 --- a/frontend/src/metabase/query_builder/components/AggregationWidget.jsx +++ b/frontend/src/metabase/query_builder/components/AggregationWidget.jsx @@ -48,6 +48,10 @@ export default class AggregationWidget extends Component { const fieldId = AggregationClause.getField(aggregation); let selectedAggregation = getAggregator(AggregationClause.getOperator(aggregation)); + if (!_.findWhere(tableMetadata.aggregation_options, { short: selectedAggregation.short })) { + // if this table doesn't support the selected aggregation, prompt the user to select a different one + selectedAggregation = null; + } return ( <div id="Query-section-aggregation" onClick={this.open} className="Query-section Query-section-aggregation cursor-pointer"> <span className="View-section-aggregation QueryOption py1 pl1">{selectedAggregation ? selectedAggregation.name.replace(" of ...", "") : "Choose an aggregation"}</span> diff --git a/frontend/src/metabase/query_builder/components/FieldList.jsx b/frontend/src/metabase/query_builder/components/FieldList.jsx index 4b8770a7cac..96b301e9333 100644 --- a/frontend/src/metabase/query_builder/components/FieldList.jsx +++ b/frontend/src/metabase/query_builder/components/FieldList.jsx @@ -114,6 +114,7 @@ export default class FieldList extends Component { field={field} value={item.value} onFieldChange={this.props.onFieldChange} + groupingOptions={item.field.grouping_options} /> </PopoverWithTrigger> } diff --git a/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx b/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx index 80f58ab63e9..93cb749de4a 100644 --- a/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx +++ b/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx @@ -6,6 +6,8 @@ import ReactDOM from "react-dom"; import "./NativeQueryEditor.css"; +import { getEngineNativeAceMode, getEngineNativeType, getEngineNativeRequiresTable } from "metabase/lib/engine"; + import { SQLBehaviour } from "metabase/lib/ace/sql_behaviour"; import _ from "underscore"; @@ -26,13 +28,9 @@ function getModeInfo(query, databases) { engine = database ? database.engine : null; return { - mode: engine === 'druid' || engine === 'mongo' ? 'ace/mode/json' : - engine === 'mysql' ? 'ace/mode/mysql' : - engine === 'postgres' ? 'ace/mode/pgsql' : - engine === 'sqlserver' ? 'ace/mode/sqlserver' : - 'ace/mode/sql', - description: engine === 'druid' || engine === 'mongo' ? 'JSON' : 'SQL', - requiresTable: engine === 'mongo', + mode: getEngineNativeAceMode(engine), + description: getEngineNativeType(engine).toUpperCase(), + requiresTable: getEngineNativeRequiresTable(engine), database: database }; } diff --git a/frontend/src/metabase/query_builder/components/QueryDefinitionTooltip.jsx b/frontend/src/metabase/query_builder/components/QueryDefinitionTooltip.jsx index ac645707af3..34a66a47c2e 100644 --- a/frontend/src/metabase/query_builder/components/QueryDefinitionTooltip.jsx +++ b/frontend/src/metabase/query_builder/components/QueryDefinitionTooltip.jsx @@ -20,24 +20,32 @@ export default class QueryDefinitionTooltip extends Component { return ( <div className="p2" style={{width: 250}}> - <div className="mb2"> - { type && type === "metric" && !object.is_active ? "This metric has been retired. It's no longer available for use." : object.description } + <div> + { type && type === "metric" && !object.is_active ? + "This metric has been retired. It's no longer available for use." + : + object.description + } </div> - <FieldSet legend="Definition" border="border-light"> - <div className="TooltipFilterList"> - { object.definition.aggregation && - <AggregationWidget - aggregation={object.definition.aggregation} - tableMetadata={tableMetadata} - /> - } - <FilterList - filters={Query.getFilters(object.definition)} - tableMetadata={tableMetadata} - maxDisplayValues={Infinity} - /> + { object.definition && + <div className="mt2"> + <FieldSet legend="Definition" border="border-light"> + <div className="TooltipFilterList"> + { object.definition.aggregation && + <AggregationWidget + aggregation={object.definition.aggregation} + tableMetadata={tableMetadata} + /> + } + <FilterList + filters={Query.getFilters(object.definition)} + tableMetadata={tableMetadata} + maxDisplayValues={Infinity} + /> + </div> + </FieldSet> </div> - </FieldSet> + } </div> ); } diff --git a/frontend/src/metabase/query_builder/components/QueryModeButton.jsx b/frontend/src/metabase/query_builder/components/QueryModeButton.jsx index f35e02c20f8..7e9f9ab0045 100644 --- a/frontend/src/metabase/query_builder/components/QueryModeButton.jsx +++ b/frontend/src/metabase/query_builder/components/QueryModeButton.jsx @@ -1,8 +1,8 @@ import React, { Component, PropTypes } from "react"; import cx from "classnames"; -import _ from "underscore"; import { formatSQL, capitalize } from "metabase/lib/formatting"; +import { getEngineNativeType, formatJsonQuery } from "metabase/lib/engine"; import Icon from "metabase/components/Icon.jsx"; import Modal from "metabase/components/Modal.jsx"; import Tooltip from "metabase/components/Tooltip.jsx"; @@ -37,7 +37,7 @@ export default class QueryModeButton extends Component { var targetType = (mode === "query") ? "native" : "query"; const engine = tableMetadata && tableMetadata.db.engine; - const nativeQueryName = _.contains(["mongo", "druid"], engine) ? "native query" : "SQL"; + const nativeQueryName = getEngineNativeType(engine) === "sql" ? "SQL" : "native query"; // maybe switch up the icon based on mode? let onClick = null; @@ -67,8 +67,8 @@ export default class QueryModeButton extends Component { <pre className="mb3 p2 sql-code"> {nativeForm && nativeForm.query && ( - _.contains(["mongo", "druid"], engine) ? - JSON.stringify(nativeForm.query) + getEngineNativeType(engine) === "json" ? + formatJsonQuery(nativeForm.query, engine) : formatSQL(nativeForm.query) )} diff --git a/frontend/src/metabase/query_builder/components/TimeGroupingPopover.jsx b/frontend/src/metabase/query_builder/components/TimeGroupingPopover.jsx index 3217d3c4ff5..52ccaf716d3 100644 --- a/frontend/src/metabase/query_builder/components/TimeGroupingPopover.jsx +++ b/frontend/src/metabase/query_builder/components/TimeGroupingPopover.jsx @@ -5,8 +5,8 @@ import { parseFieldBucketing, formatBucketing } from "metabase/lib/query_time"; import cx from "classnames"; const BUCKETINGS = [ - // "default", - // "minute", + "default", + "minute", "hour", "day", "week", @@ -14,11 +14,11 @@ const BUCKETINGS = [ "quarter", "year", null, - // "minute-of-hour", + "minute-of-hour", "hour-of-day", "day-of-week", - // "day-of-month", - // "day-of-year", + "day-of-month", + "day-of-year", "week-of-year", "month-of-year", "quarter-of-year", @@ -36,17 +36,39 @@ export default class TimeGroupingPopover extends Component { onFieldChange: PropTypes.func.isRequired }; + static defaultProps = { + groupingOptions: [ + // "default", + // "minute", + "hour", + "day", + "week", + "month", + "quarter", + "year", + // "minute-of-hour", + "hour-of-day", + "day-of-week", + // "day-of-month", + // "day-of-year", + "week-of-year", + "month-of-year", + "quarter-of-year", + ] + } + setField(bucketing) { this.props.onFieldChange(["datetime_field", this.props.value, "as", bucketing]); } render() { - let { field } = this.props; + const { field } = this.props; + const enabledOptions = new Set(this.props.groupingOptions); return ( - <div className="p2" style={{width:"250px"}}> + <div className="px2 pt2 pb1" style={{width:"250px"}}> <h3 className="List-section-header mx2">Group time by</h3> <ul className="py1"> - { BUCKETINGS.map((bucketing, bucketingIndex) => + { BUCKETINGS.filter(o => o == null || enabledOptions.has(o)).map((bucketing, bucketingIndex) => bucketing == null ? <hr key={bucketingIndex} style={{ "border": "none" }}/> : diff --git a/frontend/src/metabase/query_builder/selectors.js b/frontend/src/metabase/query_builder/selectors.js index 6b7c0477bd5..69101176023 100644 --- a/frontend/src/metabase/query_builder/selectors.js +++ b/frontend/src/metabase/query_builder/selectors.js @@ -160,6 +160,6 @@ export const getFullDatasetQuery = createSelector( ) export const getIsRunnable = createSelector( - [card], - (card) => isCardRunnable(card) + [card, tableMetadata], + (card, tableMetadata) => isCardRunnable(card, tableMetadata) ) diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js index 80e92dccc1f..7b9896edd06 100644 --- a/frontend/src/metabase/services.js +++ b/frontend/src/metabase/services.js @@ -2,6 +2,9 @@ import { GET, PUT, POST, DELETE } from "metabase/lib/api"; +// $FlowFixMe: Flow doesn't understand webpack loader syntax +import getGAMetadata from "promise?global!metabase/lib/ga-metadata"; + export const ActivityApi = { list: GET("/api/activity"), recent_views: GET("/api/activity/recent_views"), @@ -60,7 +63,16 @@ export const MetabaseApi = { // table_fields: GET("/api/table/:tableId/fields"), table_fks: GET("/api/table/:tableId/fks"), // table_reorder_fields: POST("/api/table/:tableId/reorder"), - table_query_metadata: GET("/api/table/:tableId/query_metadata"), + table_query_metadata: GET("/api/table/:tableId/query_metadata", async (table) => { + // HACK: inject GA metadata that we don't have intergrated on the backend yet + if (table && table.db && table.db.engine === "googleanalytics") { + let GA = await getGAMetadata(); + table.fields = table.fields.map(f => ({ ...f, ...GA.fields[f.name] })); + table.metrics.push(...GA.metrics); + table.segments.push(...GA.segments); + } + return table; + }), // table_sync_metadata: POST("/api/table/:tableId/sync"), // field_get: GET("/api/field/:fieldId"), // field_summary: GET("/api/field/:fieldId/summary"), diff --git a/package.json b/package.json index 0e854239f5a..4d2df392c9d 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "postcss-import": "^8.0.2", "postcss-loader": "^0.8.1", "postcss-url": "^5.1.1", + "promise-loader": "^1.0.0", "react-addons-test-utils": "^15.3.1", "react-hot-loader": "^1.3.0", "sauce-connect-launcher": "^0.15.1", diff --git a/project.clj b/project.clj index e67b3289061..e71c323d897 100644 --- a/project.clj +++ b/project.clj @@ -40,6 +40,8 @@ net.sourceforge.nekohtml/nekohtml ring/ring-core]] [com.draines/postal "2.0.1"] ; SMTP library + [com.google.apis/google-api-services-analytics ; Google Analytics Java Client Library + "v3-rev135-1.22.0"] [com.google.apis/google-api-services-bigquery ; Google BigQuery Java Client Library "v2-rev324-1.22.0"] [com.h2database/h2 "1.4.192"] ; embedded SQL database diff --git a/src/metabase/db/metadata_queries.clj b/src/metabase/db/metadata_queries.clj index 47b904a0cbf..039cae19288 100644 --- a/src/metabase/db/metadata_queries.clj +++ b/src/metabase/db/metadata_queries.clj @@ -1,8 +1,10 @@ (ns metabase.db.metadata-queries "Predefined MBQL queries for getting metadata about an external database." - (:require [metabase.models.field :as field] + (:require [metabase.db :as db] + [metabase.models.table :refer [Table]] [metabase.query-processor :as qp] - [metabase.query-processor.expand :as ql])) + [metabase.query-processor.expand :as ql] + [metabase.util :as u])) (defn- qp-query [db-id query] (-> (qp/process-query @@ -12,11 +14,11 @@ :data :rows)) -(defn- field-query [field query] - (let [table (field/table field)] - (qp-query (:db_id table) - (ql/query (merge query) - (ql/source-table (:id table)))))) +(defn- field-query [{table-id :table_id} query] + {:pre [(integer? table-id)]} + (qp-query (db/select-one-field :db_id Table, :id table-id) + (ql/query (merge query) + (ql/source-table table-id)))) (defn table-row-count "Fetch the row count of TABLE via the query processor." @@ -31,16 +33,16 @@ "Return the distinct values of FIELD. This is used to create a `FieldValues` object for `:type/Category` Fields." ([field] - (field-distinct-values field @(resolve 'metabase.sync-database.analyze/low-cardinality-threshold))) - ([{field-id :id :as field} max-results] + (field-distinct-values field @(resolve 'metabase.sync-database.analyze/low-cardinality-threshold))) + ([field max-results] {:pre [(integer? max-results)]} (mapv first (field-query field (-> {} - (ql/breakout (ql/field-id field-id)) + (ql/breakout (ql/field-id (u/get-id field))) (ql/limit max-results)))))) (defn field-distinct-count "Return the distinct count of FIELD." - [{field-id :id :as field} & [limit]] + [{field-id :id, :as field} & [limit]] (-> (field-query field (-> {} (ql/aggregation (ql/distinct (ql/field-id field-id))) (ql/limit limit))) diff --git a/src/metabase/driver.clj b/src/metabase/driver.clj index 798d445e6a2..81f1a0219b1 100644 --- a/src/metabase/driver.clj +++ b/src/metabase/driver.clj @@ -131,6 +131,7 @@ * `:foreign-keys` - Does this database support foreign key relationships? * `:nested-fields` - Does this database support nested fields (e.g. Mongo)? * `:set-timezone` - Does this driver support setting a timezone for the query? + * `:basic-aggregations` - Does the driver support *basic* aggregations like `:count` and `:sum`? (Currently, everything besides standard deviation is considered \"basic\"; only GA doesn't support this). * `:standard-deviation-aggregations` - Does this driver support [standard deviation aggregations](https://github.com/metabase/metabase/wiki/Query-Language-'98#stddev-aggregation)? * `:expressions` - Does this driver support [expressions](https://github.com/metabase/metabase/wiki/Query-Language-'98#expressions) (e.g. adding the values of 2 columns together)? * `:dynamic-schema` - Does this Database have no fixed definitions of schemas? (e.g. Mongo) diff --git a/src/metabase/driver/bigquery.clj b/src/metabase/driver/bigquery.clj index 5616fce79f8..30373c2386e 100644 --- a/src/metabase/driver/bigquery.clj +++ b/src/metabase/driver/bigquery.clj @@ -8,6 +8,7 @@ [metabase.config :as config] [metabase.db :as db] [metabase.driver :as driver] + [metabase.driver.google :as google] [metabase.driver.generic-sql :as sql] [metabase.driver.generic-sql.query-processor :as sqlqp] (metabase.models [database :refer [Database]] @@ -30,77 +31,21 @@ (com.google.api.services.bigquery.model Table TableCell TableFieldSchema TableList TableList$Tables TableReference TableRow TableSchema QueryRequest QueryResponse) (metabase.query_processor.interface DateTimeValue Value))) -(def ^:private ^HttpTransport http-transport (GoogleNetHttpTransport/newTrustedTransport)) -(def ^:private ^JsonFactory json-factory (JacksonFactory/getDefaultInstance)) -(def ^:private ^:const ^String redirect-uri "urn:ietf:wg:oauth:2.0:oob") +;;; ------------------------------------------------------------ Client ------------------------------------------------------------ -(defn- execute-no-auto-retry - "`execute` REQUEST, and catch any `GoogleJsonResponseException` is - throws, converting them to `ExceptionInfo` and rethrowing them." - [^AbstractGoogleClientRequest request] - (try (.execute request) - (catch GoogleJsonResponseException e - (let [^GoogleJsonError error (.getDetails e)] - (throw (ex-info (or (.getMessage error) - (.getStatusMessage e)) - (into {} error))))))) +(defn- ^Bigquery credential->client [^GoogleCredential credential] + (.build (doto (Bigquery$Builder. google/http-transport google/json-factory credential) + (.setApplicationName google/application-name)))) -(defn- execute - "`execute` REQUEST, and catch any `GoogleJsonResponseException` is - throws, converting them to `ExceptionInfo` and rethrowing them. +(def ^:private ^{:arglists '([database])} ^GoogleCredential database->credential + (partial google/database->credential (Collections/singleton BigqueryScopes/BIGQUERY))) - This automatically retries any failed requests up to 2 times." - [^AbstractGoogleClientRequest request] - (u/auto-retry 2 - (execute-no-auto-retry request))) +(def ^:private ^{:arglists '([database])} ^Bigquery database->client + (comp credential->client database->credential)) -;; This specific format was request by Google themselves -- see #2627 -(def ^:private ^:const ^String application-name - (let [{:keys [tag hash branch]} config/mb-version-info] - (format "Metabase/%s (GPN:Metabse; %s %s)" tag hash branch))) -(defn- ^Bigquery credential->client [^GoogleCredential credential] - (.build (doto (Bigquery$Builder. http-transport json-factory credential) - (.setApplicationName application-name)))) - -(defn- fetch-access-and-refresh-tokens* [^String client-id, ^String client-secret, ^String auth-code] - {:pre [(seq client-id) (seq client-secret) (seq auth-code)] - :post [(seq (:access-token %)) (seq (:refresh-token %))]} - (log/info (u/format-color 'magenta "Fetching BigQuery access/refresh tokens with auth-code '%s'..." auth-code)) - (let [^GoogleAuthorizationCodeFlow flow (.build (doto (GoogleAuthorizationCodeFlow$Builder. http-transport json-factory client-id client-secret (Collections/singleton BigqueryScopes/BIGQUERY)) - (.setAccessType "offline"))) - ^GoogleTokenResponse response (.execute (doto (.newTokenRequest flow auth-code) ; don't use `execute` here because this is a *different* type of Google request - (.setRedirectUri redirect-uri)))] - {:access-token (.getAccessToken response), :refresh-token (.getRefreshToken response)})) - -;; Memoize this function because you're only allowed to redeem an auth-code once. This way we can redeem it the first time when `can-connect?` checks to see if the DB details are -;; viable; then the second time we go to redeem it we can save the access token and refresh token with the newly created `Database` <3 -(def ^:private ^{:arglists '([client-id client-secret auth-code])} fetch-access-and-refresh-tokens (memoize fetch-access-and-refresh-tokens*)) - -(defn- database->credential - "Get a `GoogleCredential` for a `DatabaseInstance`." - {:arglists '([database])} - ^GoogleCredential [{{:keys [^String client-id, ^String client-secret, ^String auth-code, ^String access-token, ^String refresh-token], :as details} :details, id :id, :as db}] - {:pre [(seq client-id) (seq client-secret) (or (seq auth-code) - (and (seq access-token) (seq refresh-token)))]} - (if-not (and (seq access-token) - (seq refresh-token)) - ;; If Database doesn't have access/refresh tokens fetch them and try again - (let [details (-> (merge details (fetch-access-and-refresh-tokens client-id client-secret auth-code)) - (dissoc :auth-code))] - (when id - (db/update! Database id, :details details)) - (recur (assoc db :details details))) - ;; Otherwise return credential as normal - (doto (.build (doto (GoogleCredential$Builder.) - (.setClientSecrets client-id client-secret) - (.setJsonFactory json-factory) - (.setTransport http-transport))) - (.setAccessToken access-token) - (.setRefreshToken refresh-token)))) - -(def ^:private ^{:arglists '([database])} ^Bigquery database->client (comp credential->client database->credential)) +;;; ------------------------------------------------------------ Etc. ------------------------------------------------------------ (defn- ^TableList list-tables "Fetch a page of Tables. By default, fetches the first page; page size is 50. For cases when more than 50 Tables are present, you may @@ -113,8 +58,8 @@ ([^Bigquery client, ^String project-id, ^String dataset-id, ^String page-token-or-nil] {:pre [client (seq project-id) (seq dataset-id)]} - (execute (u/prog1 (.list (.tables client) project-id dataset-id) - (.setPageToken <> page-token-or-nil))))) + (google/execute (u/prog1 (.list (.tables client) project-id dataset-id) + (.setPageToken <> page-token-or-nil))))) (defn- describe-database [database] {:pre [(map? database)]} @@ -140,7 +85,7 @@ ([^Bigquery client, ^String project-id, ^String dataset-id, ^String table-id] {:pre [client (seq project-id) (seq dataset-id) (seq table-id)]} - (execute (.get (.tables client) project-id dataset-id table-id)))) + (google/execute (.get (.tables client) project-id dataset-id table-id)))) (def ^:private ^:const bigquery-type->base-type {"BOOLEAN" :type/Boolean @@ -171,9 +116,8 @@ {:pre [client (seq project-id) (seq query-string)]} (let [request (doto (QueryRequest.) (.setTimeoutMs (* query-timeout-seconds 1000)) - #_(.setUseLegacySql false) ; use standards-compliant non-legacy dialect -- see https://cloud.google.com/bigquery/sql-reference/enabling-standard-sql (.setQuery query-string))] - (execute (.query (.jobs client) project-id request))))) + (google/execute (.query (.jobs client) project-id request))))) (def ^:private ^java.util.TimeZone default-timezone (java.util.TimeZone/getDefault)) diff --git a/src/metabase/driver/druid.clj b/src/metabase/driver/druid.clj index 84b614f432b..5ad989640e9 100644 --- a/src/metabase/driver/druid.clj +++ b/src/metabase/driver/druid.clj @@ -158,7 +158,7 @@ :type :integer :default 8082}]) :execute-query (fn [_ query] (qp/execute-query do-query query)) - :features (constantly #{:set-timezone}) + :features (constantly #{:basic-aggregations :set-timezone}) :field-values-lazy-seq (u/drop-first-arg field-values-lazy-seq) :mbql->native (u/drop-first-arg qp/mbql->native)})) diff --git a/src/metabase/driver/generic_sql.clj b/src/metabase/driver/generic_sql.clj index d0f18145fe3..2559cbcfccb 100644 --- a/src/metabase/driver/generic_sql.clj +++ b/src/metabase/driver/generic_sql.clj @@ -288,7 +288,8 @@ (defn features "Default implementation of `IDriver` `features` for SQL drivers." [driver] - (cond-> #{:standard-deviation-aggregations + (cond-> #{:basic-aggregations + :standard-deviation-aggregations :foreign-keys :expressions :native-parameters} diff --git a/src/metabase/driver/google.clj b/src/metabase/driver/google.clj new file mode 100644 index 00000000000..aa2e5da849d --- /dev/null +++ b/src/metabase/driver/google.clj @@ -0,0 +1,92 @@ +(ns metabase.driver.google + "Shared logic for various Google drivers, including BigQuery and Google Analytics." + (:require [clojure.tools.logging :as log] + [metabase.config :as config] + [metabase.db :as db] + [metabase.models.database :refer [Database]] + [metabase.util :as u]) + (:import java.util.Collections + (com.google.api.client.googleapis.auth.oauth2 GoogleCredential GoogleCredential$Builder GoogleAuthorizationCodeFlow GoogleAuthorizationCodeFlow$Builder GoogleTokenResponse) + com.google.api.client.googleapis.javanet.GoogleNetHttpTransport + (com.google.api.client.googleapis.json GoogleJsonError GoogleJsonResponseException) + com.google.api.client.googleapis.services.AbstractGoogleClientRequest + com.google.api.client.http.HttpTransport + com.google.api.client.json.JsonFactory + com.google.api.client.json.jackson2.JacksonFactory)) + +(def ^HttpTransport http-transport + "`HttpTransport` for use with Google drivers." + (GoogleNetHttpTransport/newTrustedTransport)) + +(def ^JsonFactory json-factory + "`JsonFactory` for use with Google drivers." + (JacksonFactory/getDefaultInstance)) + +(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 rethrowing them." + [^AbstractGoogleClientRequest request] + (try (.execute request) + (catch GoogleJsonResponseException e + (let [^GoogleJsonError error (.getDetails e)] + (throw (ex-info (or (.getMessage error) + (.getStatusMessage e)) + (into {} error))))))) + +(defn execute + "`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." + [^AbstractGoogleClientRequest request] + (u/auto-retry 2 + (execute-no-auto-retry request))) + +(def ^:const ^String application-name + "The application name we should use for Google drivers. Requested by Google themselves -- see #2627" + (let [{:keys [tag hash branch]} config/mb-version-info] + (format "Metabase/%s (GPN:Metabse; %s %s)" tag hash branch))) + + +(defn- fetch-access-and-refresh-tokens* [scopes, ^String client-id, ^String client-secret, ^String auth-code] + {:pre [(seq client-id) (seq client-secret) (seq auth-code)] + :post [(seq (:access-token %)) (seq (:refresh-token %))]} + (log/info (u/format-color 'magenta "Fetching Google access/refresh tokens with auth-code '%s'..." auth-code)) + (let [^GoogleAuthorizationCodeFlow flow (.build (doto (GoogleAuthorizationCodeFlow$Builder. http-transport json-factory client-id client-secret scopes) + (.setAccessType "offline"))) + ^GoogleTokenResponse response (.execute (doto (.newTokenRequest flow auth-code) ; don't use `execute` here because this is a *different* type of Google request + (.setRedirectUri redirect-uri)))] + {:access-token (.getAccessToken response), :refresh-token (.getRefreshToken response)})) + +(def ^{:arglists '([scopes client-id client-secret auth-code])} fetch-access-and-refresh-tokens + "Fetch Google access and refresh tokens. + This function is memoized because you're only allowed to redeem an auth-code once. + This way we can redeem it the first time when `can-connect?` checks to see if the DB details are viable; + then the second time we go to redeem it we can save the access token and refresh token with the newly created `Database` <3" + (memoize fetch-access-and-refresh-tokens*)) + + +(defn database->credential + "Get a `GoogleCredential` for a `DatabaseInstance`." + {:arglists '([scopes database])} + ^com.google.api.client.googleapis.auth.oauth2.GoogleCredential + [scopes, {{:keys [^String client-id, ^String client-secret, ^String auth-code, ^String access-token, ^String refresh-token], :as details} :details, id :id, :as db}] + {:pre [(map? db) (seq client-id) (seq client-secret) (or (seq auth-code) + (and (seq access-token) (seq refresh-token)))]} + (if-not (and (seq access-token) + (seq refresh-token)) + ;; If Database doesn't have access/refresh tokens fetch them and try again + (let [details (-> (merge details (fetch-access-and-refresh-tokens scopes client-id client-secret auth-code)) + (dissoc :auth-code))] + (when id + (db/update! Database id, :details details)) + (recur scopes (assoc db :details details))) + ;; Otherwise return credential as normal + (doto (.build (doto (GoogleCredential$Builder.) + (.setClientSecrets client-id client-secret) + (.setJsonFactory json-factory) + (.setTransport http-transport))) + (.setAccessToken access-token) + (.setRefreshToken refresh-token)))) diff --git a/src/metabase/driver/googleanalytics.clj b/src/metabase/driver/googleanalytics.clj new file mode 100644 index 00000000000..98c1ca52429 --- /dev/null +++ b/src/metabase/driver/googleanalytics.clj @@ -0,0 +1,267 @@ +(ns metabase.driver.googleanalytics + ;; TODO - probably makes to call this namespace `google-analytics` + (:require (clojure [set :as set] + [string :as s] + [walk :as walk]) + [clojure.tools.logging :as log] + [cheshire.core :as json] + [metabase.config :as config] + [metabase.db :as db] + [metabase.driver :as driver] + [metabase.driver.google :as google] + (metabase.driver.googleanalytics [query-processor :as qp]) + (metabase.models [database :refer [Database]] + [field :as field] + [table :as table]) + [metabase.sync-database.analyze :as analyze] + metabase.query-processor.interface + [metabase.util :as u]) + (:import (java.util Collections Date List Map) + (com.google.api.client.googleapis.auth.oauth2 GoogleCredential GoogleCredential$Builder GoogleAuthorizationCodeFlow GoogleAuthorizationCodeFlow$Builder GoogleTokenResponse) + com.google.api.client.googleapis.javanet.GoogleNetHttpTransport + (com.google.api.client.googleapis.json GoogleJsonError GoogleJsonResponseException) + com.google.api.client.googleapis.services.AbstractGoogleClientRequest + com.google.api.client.http.HttpTransport + com.google.api.client.json.JsonFactory + com.google.api.client.json.jackson2.JacksonFactory + (com.google.api.services.analytics Analytics Analytics$Builder Analytics$Data$Ga$Get AnalyticsScopes) + (com.google.api.services.analytics.model Account Accounts Columns Column Profile Profiles Webproperty Webproperties))) + + +;;; ------------------------------------------------------------ Client ------------------------------------------------------------ + +(defn- ^Analytics credential->client [^GoogleCredential credential] + (.build (doto (Analytics$Builder. google/http-transport google/json-factory credential) + (.setApplicationName google/application-name)))) + +(def ^:private ^{:arglists '([database])} ^GoogleCredential database->credential + (partial google/database->credential (Collections/singleton AnalyticsScopes/ANALYTICS_READONLY))) + +(def ^:private ^{:arglists '([database])} ^Analytics database->client + (comp credential->client database->credential)) + + +;;; ------------------------------------------------------------ describe-database ------------------------------------------------------------ + +(defn- fetch-properties + ^Webproperties [^Analytics client, ^String account-id] + (google/execute (.list (.webproperties (.management client)) account-id))) + +(defn- fetch-profiles + ^Profiles [^Analytics client, ^String account-id, ^String property-id] + (google/execute (.list (.profiles (.management client)) account-id property-id))) + +(defn- properties+profiles + "Return a set of tuples of `Webproperty` and `Profile` for DATABASE." + [{{:keys [account-id]} :details, :as database}] + (let [client (database->client database)] + (set (for [^Webproperty property (.getItems (fetch-properties client account-id)) + ^Profile profile (.getItems (fetch-profiles client account-id (.getId property)))] + [property profile])))) + +(defn- profile-ids + "Return a set of all numeric IDs for different profiles available to this account." + [database] + (set (for [[_, ^Profile profile] (properties+profiles database)] + (.getId profile)))) + +(defn- describe-database [database] + {:tables (set (for [table-id (cons "_metabase_metadata" (profile-ids database))] + {:name table-id + :schema nil}))}) + + +;;; ------------------------------------------------------------ describe-table ------------------------------------------------------------ + +;; This is the +(def ^:private ^:const redundant-date-fields + "Set of column IDs covered by `unit->ga-dimension` in the GA QP. + We don't need to present them because people can just use date bucketing on the `ga:date` field." + #{"ga:minute" + "ga:dateHour" + "ga:hour" + "ga:dayOfWeek" + "ga:day" + "ga:yearWeek" + "ga:week" + "ga:yearMonth" + "ga:month" + "ga:year" + ;; leave these out as well because their display names are things like "Month" but they're not dates so they're not really useful + "ga:cohortNthDay" + "ga:cohortNthMonth" + "ga:cohortNthWeek"}) + +(defn- fetch-columns + ^Columns [^Analytics client] + (google/execute (.list (.columns (.metadata client)) "ga"))) + +(defn- column-attribute + "Get the value of ATTRIBUTE-NAME for COLUMN." + [^Column column, attribute-name] + (get (.getAttributes column) (name attribute-name))) + +(defn- column-has-attributes? ^Boolean [^Column column, ^Map attributes-map] + (or (empty? attributes-map) + (reduce #(and %1 %2) (for [[k v] attributes-map] + (= (column-attribute column k) v))))) + +(defn- columns + "Return a set of `Column`s for this database. Each table in a Google Analytics database has the same columns." + ([database] + (columns database {:status "PUBLIC", :type "DIMENSION"})) + ([database attributes] + (set (for [^Column column (.getItems (fetch-columns (database->client database))) + :when (and (not (contains? redundant-date-fields (.getId column))) + (column-has-attributes? column attributes))] + column)))) + +(defn- describe-columns [database] + (set (for [^Column column (columns database)] + {:name (.getId column) + :base-type (if (= (.getId column) "ga:date") + :type/Date + (qp/ga-type->base-type (column-attribute column :dataType)))}))) + +(defn- describe-table [database table] + {:name (:name table) + :schema (:schema table) + :fields (describe-columns database)}) + + +;;; ------------------------------------------------------------ _metabase_metadata ------------------------------------------------------------ + +(defn- property+profile->display-name + "Format a table name for a GA property and GA profile" + [^Webproperty property, ^Profile profile] + (let [property-name (s/replace (.getName property) #"^https?://" "") + profile-name (s/replace (.getName profile) #"^https?://" "")] + ;; don't include the profile if it's the same as property-name or is the default "All Web Site Data" + (if (or (.contains property-name profile-name) + (= profile-name "All Web Site Data")) + property-name + (str property-name " (" profile-name ")")))) + +(defn- table-rows-seq [database table] + ;; this method is only supposed to be called for _metabase_metadata, make sure that's the case + {:pre [(= (:name table) "_metabase_metadata")]} + ;; now build a giant sequence of all the things we want to set + (apply concat + ;; set display_name for all the tables + (for [[^Webproperty property, ^Profile profile] (properties+profiles database)] + (cons {:keypath (str (.getId profile) ".display_name") + :value (property+profile->display-name property profile)} + ;; set display_name and description for each column for this table + (apply concat (for [^Column column (columns database)] + [{:keypath (str (.getId profile) \. (.getId column) ".display_name") + :value (column-attribute column :uiName)} + {:keypath (str (.getId profile) \. (.getId column) ".description") + :value (column-attribute column :description)}])))))) + + +;;; ------------------------------------------------------------ can-connect? ------------------------------------------------------------ + +(defn- can-connect? [details-map] + {:pre [(map? details-map)]} + (boolean (profile-ids {:details details-map}))) + + +;;; ------------------------------------------------------------ execute-query ------------------------------------------------------------ + +(defn- column-with-name ^Column [database-or-id column-name] + (some (fn [^Column column] + (when (= (.getId column) (name column-name)) + column)) + (columns (Database (u/get-id database-or-id)) {:status "PUBLIC"}))) + +(defn- column-metadata [database-id column-name] + (when-let [ga-column (column-with-name database-id column-name)] + (merge + {:display_name (column-attribute ga-column :uiName) + :description (column-attribute ga-column :description)} + (let [data-type (column-attribute ga-column :dataType)] + (when-let [base-type (cond + (= column-name "ga:date") :type/Date + (= data-type "INTEGER") :type/Integer + (= data-type "STRING") :type/Text)] + {:base_type base-type}))))) + +;; memoize this because the display names and other info isn't going to change and fetching this info from GA can take around half a second +(def ^:private ^{:arglists '([database-id column-name])} memoized-column-metadata + (memoize column-metadata)) + +(defn- add-col-metadata [{database :database} col] + (merge col (memoized-column-metadata (u/get-id database) (:name col)))) + +(defn- add-built-in-column-metadata [query results] + (update-in results [:data :cols] (partial map (partial add-col-metadata query)))) + +(defn- process-query-in-context [qp] + (comp (fn [query] + (add-built-in-column-metadata query (qp query))) + qp/transform-query)) + +(defn- mbql-query->request ^Analytics$Data$Ga$Get [{{:keys [query]} :native, database :database}] + (let [query (if (string? query) + (json/parse-string query keyword) + query) + client (database->client database)] + (u/prog1 (.get (.ga (.data client)) + (:ids query) + (:start-date query) + (:end-date query) + (:metrics query)) + (when-not (s/blank? (:dimensions query)) + (.setDimensions <> (:dimensions query))) + (when-not (s/blank? (:sort query)) + (.setSort <> (:sort query))) + (when-not (s/blank? (:filters query)) + (.setFilters <> (:filters query))) + (when-not (s/blank? (:segment query)) + (.setSegment <> (:segment query))) + (when-not (nil? (:max-results query)) + (.setMaxResults <> (:max-results query))) + (when-not (nil? (:include-empty-rows query)) + (.setIncludeEmptyRows <> (:include-empty-rows query)))))) + +(defn- do-query + [query] + (google/execute (mbql-query->request query))) + + +;;; ------------------------------------------------------------ Driver ------------------------------------------------------------ + +(defrecord GoogleAnalyticsDriver [] + clojure.lang.Named + (getName [_] "Google Analytics")) + +(u/strict-extend GoogleAnalyticsDriver + driver/IDriver + (merge driver/IDriverDefaultsMixin + {:can-connect? (u/drop-first-arg can-connect?) + :describe-database (u/drop-first-arg describe-database) + :describe-table (u/drop-first-arg describe-table) + :details-fields (constantly [{:name "account-id" + :display-name "Google Analytics Account ID" + :placeholder "1234567" + :required true} + {:name "client-id" + :display-name "Client ID" + :placeholder "1201327674725-y6ferb0feo1hfssr7t40o4aikqll46d4.apps.googleusercontent.com" + :required true} + {:name "client-secret" + :display-name "Client Secret" + :placeholder "dJNi4utWgMzyIFo2JbnsK6Np" + :required true} + {:name "auth-code" + :display-name "Auth Code" + :placeholder "4/HSk-KtxkSzTt61j5zcbee2Rmm5JHkRFbL5gD5lgkXek" + :required true}]) + :execute-query (u/drop-first-arg (partial qp/execute-query do-query)) + :field-values-lazy-seq (constantly []) + :process-query-in-context (u/drop-first-arg process-query-in-context) + :mbql->native (u/drop-first-arg qp/mbql->native) + :table-rows-seq (u/drop-first-arg table-rows-seq)})) + + +(driver/register-driver! :googleanalytics (GoogleAnalyticsDriver.)) diff --git a/src/metabase/driver/googleanalytics/query_processor.clj b/src/metabase/driver/googleanalytics/query_processor.clj new file mode 100644 index 00000000000..3f6143f155b --- /dev/null +++ b/src/metabase/driver/googleanalytics/query_processor.clj @@ -0,0 +1,324 @@ +(ns metabase.driver.googleanalytics.query-processor + "The Query Processor is responsible for translating the Metabase Query Language into Google Analytics request format." + (:require (clojure [string :as s]) + [clojure.tools.logging :as log] + [clojure.tools.reader.edn :as edn] + [medley.core :as m] + [metabase.query-processor.expand :as ql] + [metabase.util :as u]) + (:import java.sql.Timestamp + java.util.Date + clojure.lang.PersistentArrayMap + (com.google.api.services.analytics.model GaData GaData$ColumnHeaders) + (metabase.query_processor.interface AgFieldRef + DateTimeField + DateTimeValue + Field + RelativeDateTimeValue + Value))) + +(def ^:private ^:const earliest-date "2005-01-01") +(def ^:private ^:const latest-date "today") +(def ^:private ^:const max-rows-maximum 10000) + +(def ^:const ga-type->base-type + "Map of Google Analytics field types to Metabase types." + {"STRING" :type/Text + "FLOAT" :type/Float + "INTEGER" :type/Integer + "PERCENT" :type/Float + "TIME" :type/Float + "CURRENCY" :type/Float + "US_CURRENCY" :type/Float}) + + +(defprotocol ^:private IRValue + (^:private ->rvalue [this])) + +(extend-protocol IRValue + nil (->rvalue [_] nil) + Object (->rvalue [this] this) + Field (->rvalue [this] (:field-name this)) + DateTimeField (->rvalue [this] (->rvalue (:field this))) + Value (->rvalue [this] (:value this)) + DateTimeValue (->rvalue [{{unit :unit} :field, value :value}] (u/format-date "yyyy-MM-dd" (u/date-trunc unit value))) + RelativeDateTimeValue (->rvalue [{:keys [unit amount]}] + (cond + (and (= unit :day) (= amount 0)) "today" + (and (= unit :day) (= amount -1)) "yesterday" + (and (= unit :day) (< amount -1)) (str (- amount) "daysAgo") + :else (u/format-date "yyyy-MM-dd" (u/date-trunc unit (u/relative-date unit amount)))))) + + +(defn- char-escape-map + "Generate a map of characters to escape to their escaped versions." + [chars-to-escape] + (into {} (for [c chars-to-escape] + {c (str "\\" c)}))) + +(def ^:private ^{:arglists '([s])} escape-for-regex (u/rpartial s/escape (char-escape-map ".\\+*?[^]$(){}=!<>|:-"))) +(def ^:private ^{:arglists '([s])} escape-for-filter-clause (u/rpartial s/escape (char-escape-map ",;\\"))) + +(defn- ga-filter [& parts] + (escape-for-filter-clause (apply str parts))) + + +;;; ### source-table + +(defn- handle-source-table [{{source-table-name :name} :source-table}] + {:pre [(u/string-or-keyword? source-table-name)]} + {:ids (str "ga:" source-table-name)}) + + +;;; ### breakout + +(defn- first-aggregation [{aggregations :aggregation}] + (if (every? sequential? aggregations) + (first aggregations) + aggregations)) + +(defn- unit->ga-dimension + [unit] + (case unit + :minute-of-hour "ga:minute" + :hour "ga:dateHour" + :hour-of-day "ga:hour" + :day "ga:date" + :day-of-week "ga:dayOfWeek" + :day-of-month "ga:day" + :week "ga:yearWeek" + :week-of-year "ga:week" + :month "ga:yearMonth" + :month-of-year "ga:month" + :year "ga:year")) + +(defn- handle-breakout [{breakout-clause :breakout}] + {:dimensions (if-not breakout-clause + "" + (s/join "," (for [breakout-field breakout-clause] + (if (instance? DateTimeField breakout-field) + (unit->ga-dimension (:unit breakout-field)) + (->rvalue breakout-field)))))}) + + +;;; ### filter + +;; TODO: implement negate? +(defn- parse-filter-subclause:filters [{:keys [filter-type field value] :as filter} & [negate?]] + (if negate? (throw (Exception. ":not is :not yet implemented"))) + (when-not (instance? DateTimeField field) + (let [field (when field (->rvalue field)) + value (when value (->rvalue value))] + (case filter-type + :contains (ga-filter field "=@" value) + :starts-with (ga-filter field "=~^" (escape-for-regex value)) + :ends-with (ga-filter field "=~" (escape-for-regex value) "$") + := (ga-filter field "==" value) + :!= (ga-filter field "!=" value) + :> (ga-filter field ">" value) + :< (ga-filter field "<" value) + :>= (ga-filter field ">=" value) + :<= (ga-filter field "<=" value) + :between (str (ga-filter field ">=" (->rvalue (:min-val filter))) + ";" + (ga-filter field "<=" (->rvalue (:max-val filter)))))))) + +(defn- parse-filter-clause:filters [{:keys [compound-type subclause subclauses], :as clause}] + (case compound-type + :and (s/join ";" (remove nil? (map parse-filter-clause:filters subclauses))) + :or (s/join "," (remove nil? (map parse-filter-clause:filters subclauses))) + :not (parse-filter-subclause:filters subclause :negate) + nil (parse-filter-subclause:filters clause))) + +(defn- handle-filter:filters [{filter-clause :filter}] + (when filter-clause + (let [filter (parse-filter-clause:filters filter-clause)] + (when-not (s/blank? filter) + {:filters filter})))) + +(defn- parse-filter-subclause:interval [{:keys [filter-type field value], :as filter} & [negate?]] + (when negate? + (throw (Exception. ":not is :not yet implemented"))) + (when (instance? DateTimeField field) + (case filter-type + :between {:start-date (->rvalue (:min-val filter)) + :end-date (->rvalue (:max-val filter))} + :> {:start-date (->rvalue (:value filter)) + :end-date latest-date} + :< {:start-date earliest-date + :end-date (->rvalue (:value filter))} + := {:start-date (->rvalue (:value filter)) + :end-date (condp instance? (:value filter) + DateTimeValue (->rvalue (:value filter)) + RelativeDateTimeValue (->rvalue (update (:value filter) :amount inc)))}))) ;; inc the end date so we'll get a proper date range once everything is bucketed + +(defn- parse-filter-clause:interval [{:keys [compound-type subclause subclauses], :as clause}] + (case compound-type + :and (apply concat (remove nil? (map parse-filter-clause:interval subclauses))) + :or (apply concat (remove nil? (map parse-filter-clause:interval subclauses))) + :not (remove nil? [(parse-filter-subclause:interval subclause :negate)]) + nil (remove nil? [(parse-filter-subclause:interval clause)]))) + +(defn- handle-filter:interval + "Handle datetime filter clauses. (Anything that *isn't* a datetime filter will be removed by the `handle-builtin-segment` logic)." + [{filter-clause :filter}] + (let [date-filters (when filter-clause + (parse-filter-clause:interval filter-clause))] + (case (count date-filters) + 0 {:start-date earliest-date, :end-date latest-date} + 1 (first date-filters) + (throw (Exception. "Multiple date filters are not supported"))))) + +;;; ### order-by + +(defn- handle-order-by [{:keys [order-by], :as query}] + (when order-by + {:sort (s/join "," (for [{:keys [field direction]} order-by] + (str (case direction + :ascending "" + :descending "-") + (cond + (instance? DateTimeField field) (unit->ga-dimension (:unit field)) + (instance? AgFieldRef field) (second (first-aggregation query)) ; aggregation is of format [ag-type metric-name]; get the metric-name + :else (->rvalue field)))))})) + +;;; ### limit + +(defn- handle-limit [{limit-clause :limit}] + {:max-results (int (if (nil? limit-clause) + 10000 + limit-clause))}) + +(defn mbql->native + "Transpile MBQL query into parameters required for a Google Analytics request." + [{:keys [query], :as raw}] + {:query (merge (handle-source-table query) + (handle-breakout query) + (handle-filter:interval query) + (handle-filter:filters query) + (handle-order-by query) + (handle-limit query) + ;; segments and metrics are pulled out in transform-query + (get raw :ga) + ;; set to false to match behavior of other drivers + {:include-empty-rows false}) + :mbql? true}) + +(defn- parse-number [s] + (edn/read-string (s/replace s #"^0+(.+)$" "$1"))) + +(def ^:private ga-dimension->date-format-fn + {"ga:minute" parse-number + "ga:dateHour" (partial u/parse-date "yyyyMMddHH") + "ga:hour" parse-number + "ga:date" (partial u/parse-date "yyyyMMdd") + "ga:dayOfWeek" (comp inc parse-number) + "ga:day" parse-number + "ga:yearWeek" (partial u/parse-date "YYYYww") + "ga:week" parse-number + "ga:yearMonth" (partial u/parse-date "yyyyMM") + "ga:month" parse-number + "ga:year" parse-number}) + +(defn- header->column [^GaData$ColumnHeaders header] + (let [date-parser (ga-dimension->date-format-fn (.getName header))] + (if date-parser + {:name (keyword "ga:date") + :base-type :type/DateTime} + {:name (keyword (.getName header)) + :base-type (ga-type->base-type (.getDataType header)) + :field-display-name "COOL"}))) + +(defn- header->getter-fn [^GaData$ColumnHeaders header] + (let [date-parser (ga-dimension->date-format-fn (.getName header)) + base-type (ga-type->base-type (.getDataType header))] + (cond + date-parser date-parser + (isa? base-type :type/Number) edn/read-string + :else identity))) + +(defn execute-query + "Execute a QUERY using the provided DO-QUERY function, and return the results in the usual format." + [do-query query] + (let [mbql? (:mbql? (:native query)) + ^GaData response (do-query query) + columns (map header->column (.getColumnHeaders response)) + getters (map header->getter-fn (.getColumnHeaders response))] + {:columns (map :name columns) + :cols columns + :rows (for [row (.getRows response)] + (for [[data getter] (map vector row getters)] + (getter data))) + :annotate mbql?})) + + +;;; ------------------------------------------------------------ "transform-query" ------------------------------------------------------------ + +;;; metrics + +(defn- built-in-metrics + [{query :query}] + (let [[aggregation-type metric-name] (first-aggregation query)] + (when (and aggregation-type + (= :metric (ql/normalize-token aggregation-type)) + (string? metric-name)) + metric-name))) + +(defn- handle-built-in-metrics [query] + (-> query + (assoc-in [:ga :metrics] (built-in-metrics query)) + (m/dissoc-in [:query :aggregation]))) + + +;;; segments + +(defn- filter-type ^clojure.lang.Keyword [filter-clause] + (when (and (sequential? filter-clause) + (u/string-or-keyword? (first filter-clause))) + (ql/normalize-token (first filter-clause)))) + +(defn- compound-filter? [filter-clause] + (contains? #{:and :or :not} (filter-type filter-clause))) + +(defn- built-in-segment? [filter-clause] + (and (= :segment (filter-type filter-clause)) + (string? (second filter-clause)))) + +(defn- built-in-segments [{{filter-clause :filter} :query}] + (if-not (compound-filter? filter-clause) + ;; if the top-level filter isn't compound check if it's built-in and return it if it is + (when (built-in-segment? filter-clause) + (second filter-clause)) + ;; otherwise if it *is* compound return the first subclause that is built-in; if more than one is built-in throw exception + (when-let [[built-in-segment-name & more] (seq (for [subclause filter-clause + :when (built-in-segment? subclause)] + (second subclause)))] + (when (seq more) + (throw (Exception. "Only one Google Analytics segment allowed at a time."))) + built-in-segment-name))) + +(defn- remove-built-in-segments [filter-clause] + (if-not (compound-filter? filter-clause) + ;; if top-level filter isn't compound just return it as long as it's not built-in + (when-not (built-in-segment? filter-clause) + filter-clause) + ;; otherwise for compound filters filter out the built-in filters + (when-let [filter-clause (seq (for [subclause filter-clause + :when (not (built-in-segment? subclause))] + subclause))] + ;; don't keep the filter clause if it's something like an empty compound filter like [:and] + (when (> (count filter-clause) 1) + (vec filter-clause))))) + +(defn- handle-built-in-segments [query] + (-> query + (assoc-in [:ga :segment] (built-in-segments query)) + (update-in [:query :filter] remove-built-in-segments))) + + +;;; public + +(def ^{:arglists '([query])} transform-query + "Preprocess the incoming query to pull out built-in segments and metrics. + This removes customizations to the query dict and makes it compatible with MBQL." + (comp handle-built-in-metrics handle-built-in-segments)) diff --git a/src/metabase/driver/h2.clj b/src/metabase/driver/h2.clj index f168ae27717..fc56991cd44 100644 --- a/src/metabase/driver/h2.clj +++ b/src/metabase/driver/h2.clj @@ -6,6 +6,7 @@ [metabase.db.spec :as dbspec] [metabase.driver :as driver] [metabase.driver.generic-sql :as sql] + [metabase.models.database :refer [Database]] [metabase.util :as u] [metabase.util.honeysql-extensions :as hx])) @@ -118,12 +119,13 @@ (hsql/raw "timestamp '1970-01-01T00:00:00Z'"))) -(defn- check-native-query-not-using-default-user [{query-type :type, :as query}] +(defn- check-native-query-not-using-default-user [{query-type :type, database-id :database, :as query}] + {:pre [(integer? database-id)]} (u/prog1 query ;; For :native queries check to make sure the DB in question has a (non-default) NAME property specified in the connection string. ;; We don't allow SQL execution on H2 databases for the default admin account for security reasons (when (= (keyword query-type) :native) - (let [{:keys [db]} (get-in query [:database :details]) + (let [{:keys [db]} (db/select-one-field :details Database :id database-id) _ (assert db) [_ options] (connection-string->file+options db) {:strs [USER]} options] diff --git a/src/metabase/driver/mongo.clj b/src/metabase/driver/mongo.clj index d8a5a2eeda4..addce073530 100644 --- a/src/metabase/driver/mongo.clj +++ b/src/metabase/driver/mongo.clj @@ -9,10 +9,12 @@ [conversion :as conv] [db :as mdb] [query :as mq]) + [metabase.db :as db] [metabase.driver :as driver] (metabase.driver.mongo [query-processor :as qp] [util :refer [*mongo-connection* with-mongo-connection values->base-type]]) - (metabase.models [field :as field] + (metabase.models [database :refer [Database]] + [field :as field] [table :as table]) [metabase.sync-database.analyze :as analyze] [metabase.util :as u]) @@ -42,8 +44,8 @@ message)) (defn- process-query-in-context [qp] - (fn [{:keys [database], :as query}] - (with-mongo-connection [^DB conn, database] + (fn [{database-id :database, :as query}] + (with-mongo-connection [^DB conn, (db/select-one [Database :details], :id database-id)] (qp query)))) @@ -64,9 +66,9 @@ (and (string? field-value) (or (.startsWith "{" field-value) (.startsWith "[" field-value))) (when-let [j (u/try-apply json/parse-string field-value)] - (when (or (map? j) - (sequential? j)) - :type/SerializedJSON)))) + (when (or (map? j) + (sequential? j)) + :type/SerializedJSON)))) (defn- find-nested-fields [field-value nested-fields] (loop [[k & more-keys] (keys field-value) @@ -195,11 +197,12 @@ :type :boolean :default false}]) :execute-query (u/drop-first-arg qp/execute-query) - :features (constantly #{:dynamic-schema :nested-fields}) + :features (constantly #{:basic-aggregations :dynamic-schema :nested-fields}) :field-values-lazy-seq (u/drop-first-arg field-values-lazy-seq) :humanize-connection-error-message (u/drop-first-arg humanize-connection-error-message) :mbql->native (u/drop-first-arg qp/mbql->native) :process-query-in-context (u/drop-first-arg process-query-in-context) :sync-in-context (u/drop-first-arg sync-in-context)})) + (driver/register-driver! :mongo (MongoDriver.)) diff --git a/src/metabase/query_processor.clj b/src/metabase/query_processor.clj index 9e148ff141a..2165c1302d1 100644 --- a/src/metabase/query_processor.clj +++ b/src/metabase/query_processor.clj @@ -477,10 +477,10 @@ (binding [*driver* driver] ((<<- wrap-catch-exceptions pre-add-settings + (driver/process-query-in-context driver) pre-expand-macros pre-substitute-parameters pre-expand-resolve - (driver/process-query-in-context driver) post-add-row-count-and-status post-format-rows pre-add-implicit-fields diff --git a/src/metabase/query_processor/annotate.clj b/src/metabase/query_processor/annotate.clj index 12520045131..07aeae483e6 100644 --- a/src/metabase/query_processor/annotate.clj +++ b/src/metabase/query_processor/annotate.clj @@ -193,15 +193,16 @@ :table_id nil}] (-> (merge defaults field) (update :field-display-name name) - (set/rename-keys {:base-type :base_type - :field-id :id - :field-name :name - :field-display-name :display_name - :schema-name :schema_name - :special-type :special_type - :visibility-type :visibility_type - :table-id :table_id - :fk-field-id :fk_field_id}) + (set/rename-keys {:base-type :base_type + :field-display-name :display_name + :field-id :id + :field-name :name + :fk-field-id :fk_field_id + :preview-display :preview_display + :schema-name :schema_name + :special-type :special_type + :table-id :table_id + :visibility-type :visibility_type}) (dissoc :position :clause-position :parent :parent-id :table-name)))) (defn- fk-field->dest-fn diff --git a/src/metabase/query_processor/expand.clj b/src/metabase/query_processor/expand.clj index 8abf1911156..49920681f7c 100644 --- a/src/metabase/query_processor/expand.clj +++ b/src/metabase/query_processor/expand.clj @@ -27,7 +27,7 @@ ;;; # ------------------------------------------------------------ Token dispatch ------------------------------------------------------------ -(s/defn ^:private ^:always-validate normalize-token :- s/Keyword +(s/defn ^:always-validate normalize-token :- s/Keyword "Convert a string or keyword in various cases (`lisp-case`, `snake_case`, or `SCREAMING_SNAKE_CASE`) to a lisp-cased keyword." [token :- su/KeywordOrString] (-> (name token) diff --git a/src/metabase/sync_database/sync.clj b/src/metabase/sync_database/sync.clj index 1fa70349952..2fe011390cc 100644 --- a/src/metabase/sync_database/sync.clj +++ b/src/metabase/sync_database/sync.clj @@ -62,7 +62,7 @@ (db/update-where! Table {:name table-name :db_id (:id database)} (keyword k) value)) - (log/error (u/format-color "Error syncing _metabase_metadata: no matching keypath: %s" keypath))) + (log/error (u/format-color 'red "Error syncing _metabase_metadata: no matching keypath: %s" keypath))) (catch Throwable e (log/error (u/format-color 'red "Error in _metabase_metadata: %s" (.getMessage e))))))))) @@ -225,4 +225,5 @@ raw-table-id->table (u/key-by :raw_table_id (db/select Table, :db_id database-id, :active true))] (create-and-update-tables! database raw-table-id->table raw-tables) (set-fk-relationships! database) + ;; HACK! we can't sync the _metabase_metadata table until all the "Raw" Tables/Columns are backed (maybe-sync-metabase-metadata-table! database raw-tables))) diff --git a/src/metabase/util.clj b/src/metabase/util.clj index 3c00744ae3d..b2dd0732db7 100644 --- a/src/metabase/util.clj +++ b/src/metabase/util.clj @@ -19,8 +19,9 @@ InetSocketAddress InetAddress) (java.sql SQLException Timestamp) - (java.util Calendar TimeZone) + (java.util Calendar Date TimeZone) javax.xml.bind.DatatypeConverter + org.joda.time.DateTime org.joda.time.format.DateTimeFormatter)) ;; This is the very first log message that will get printed. @@ -33,6 +34,12 @@ (declare pprint-to-str) +(defmacro ignore-exceptions + "Simple macro which wraps the given expression in a try/catch block and ignores the exception if caught." + {:style/indent 0} + [& body] + `(try ~@body (catch Throwable ~'_))) + ;;; ### Protocols (defprotocol ITimestampCoercible @@ -42,20 +49,22 @@ Strings are parsed as ISO-8601.")) (extend-protocol ITimestampCoercible - nil (->Timestamp [_] - nil) - Timestamp (->Timestamp [this] - this) - java.util.Date (->Timestamp [this] - (Timestamp. (.getTime this))) + nil (->Timestamp [_] + nil) + Timestamp (->Timestamp [this] + this) + Date (->Timestamp [this] + (Timestamp. (.getTime this))) ;; Number is assumed to be a UNIX timezone in milliseconds (UTC) - Number (->Timestamp [this] - (Timestamp. this)) - Calendar (->Timestamp [this] - (->Timestamp (.getTime this))) + Number (->Timestamp [this] + (Timestamp. this)) + Calendar (->Timestamp [this] + (->Timestamp (.getTime this))) ;; Strings are expected to be in ISO-8601 format. `YYYY-MM-DD` strings *are* valid ISO-8601 dates. - String (->Timestamp [this] - (->Timestamp (DatatypeConverter/parseDateTime this)))) + String (->Timestamp [this] + (->Timestamp (DatatypeConverter/parseDateTime this))) + DateTime (->Timestamp [this] + (->Timestamp (.getMillis this)))) (defprotocol IDateTimeFormatterCoercible @@ -72,6 +81,15 @@ (throw (Exception. (format "Invalid formatter name, must be one of:\n%s" (pprint-to-str (sort (keys time/formatters))))))))) +(defn parse-date + "Parse a datetime string S with a custom DATE-FORMAT, which can be a format string, + clj-time formatter keyword, or anything else that can be coerced to a `DateTimeFormatter`. + + (parse-date \"yyyyMMdd\" \"20160201\") -> #inst \"2016-02-01\" + (parse-date :date-time \"2016-02-01T00:00:00.000Z\") -> #inst \"2016-02-01\"" + ^java.sql.Timestamp [date-format, ^String s] + (->Timestamp (time/parse (->DateTimeFormatter date-format) s))) + (defprotocol ISO8601 "Protocol for converting objects to ISO8601 formatted strings." @@ -79,10 +97,11 @@ "Coerce object to an ISO8601 date-time string such as \"2015-11-18T23:55:03.841Z\" with a given TIMEZONE.")) (def ^:private ISO8601Formatter - ;; memoize this because the formatters are static. they must be distinct per timezone though. + ;; memoize this because the formatters are static. They must be distinct per timezone though. (memoize (fn [timezone-id] - (if timezone-id (time/with-zone (time/formatters :date-time) (t/time-zone-for-id timezone-id)) - (time/formatters :date-time))))) + (if timezone-id + (time/with-zone (time/formatters :date-time) (t/time-zone-for-id timezone-id)) + (time/formatters :date-time))))) (extend-protocol ISO8601 nil (->iso-8601-datetime [_ _] nil) @@ -112,7 +131,8 @@ DATE is anything that can coerced to a `Timestamp` via `->Timestamp`, such as a `Date`, `Timestamp`, `Long` (ms since the epoch), or an ISO-8601 `String`. DATE defaults to the current moment in time. - DATE-FORMAT is anything that can be passed to `->DateTimeFormatter`, such as `String` (using [the usual date format args](http://docs.oracle.com/javase/7/docs/api/java/text/SimpleDateFormat.html)), + DATE-FORMAT is anything that can be passed to `->DateTimeFormatter`, such as `String` + (using [the usual date format args](http://docs.oracle.com/javase/7/docs/api/java/text/SimpleDateFormat.html)), `Keyword`, or `DateTimeFormatter`. @@ -132,8 +152,8 @@ "Is S a valid ISO 8601 date string?" [^String s] (boolean (when (string? s) - (try (->Timestamp s) - (catch Throwable e))))) + (ignore-exceptions + (->Timestamp s))))) (defn ->Date @@ -209,7 +229,7 @@ (def ^:private ^:const date-trunc-units - #{:minute :hour :day :week :month :quarter}) + #{:minute :hour :day :week :month :quarter :year}) (defn- trunc-with-format [format-string date timezone-id] (->Timestamp (format-date (time/with-zone (time/formatter format-string) @@ -248,7 +268,8 @@ :day (trunc-with-format "yyyy-MM-ddZZ" date timezone-id) :week (trunc-with-format "yyyy-MM-ddZZ" (->first-day-of-week date timezone-id) timezone-id) :month (trunc-with-format "yyyy-MM-01ZZ" date timezone-id) - :quarter (trunc-with-format (format-string-for-quarter date timezone-id) date timezone-id)))) + :quarter (trunc-with-format (format-string-for-quarter date timezone-id) date timezone-id) + :year (trunc-with-format "yyyy-01-01ZZ" date timezone-id)))) (defn date-trunc-or-extract @@ -339,13 +360,6 @@ [default args])) -(defmacro ignore-exceptions - "Simple macro which wraps the given expression in a try/catch block and ignores the exception if caught." - {:style/indent 0} - [& body] - `(try ~@body (catch Throwable ~'_))) - - ;; TODO - rename to `email?` (defn is-email? "Is STRING a valid email address?" diff --git a/test/metabase/driver/google_analytics_test.clj b/test/metabase/driver/google_analytics_test.clj new file mode 100644 index 00000000000..b4a80dbeda9 --- /dev/null +++ b/test/metabase/driver/google_analytics_test.clj @@ -0,0 +1,182 @@ +(ns metabase.driver.google-analytics-test + "Tests for the Google Analytics driver and query processor." + (:require [expectations :refer :all] + [metabase.db :as db] + [metabase.driver.googleanalytics :as ga] + [metabase.driver.googleanalytics.query-processor :as qp] + [metabase.models.database :refer [Database]] + (metabase.query-processor [expand :as ql] + [interface :as qpi]) + [metabase.test.util :as tu] + [metabase.util :as u])) + +;;; +------------------------------------------------------------------------------------------------------------------------+ +;;; | QUERY "TRANSFORMATION | +;;; +------------------------------------------------------------------------------------------------------------------------+ + +;; check that a built-in Metric gets removed from the query and put in `:ga` +(expect + {:query {:filter nil} + :ga {:segment nil, :metrics "ga:users"}} + (qp/transform-query {:query {:aggregation ["METRIC" "ga:users"]}})) + + +;; check that a built-in segment gets removed from the query and put in `:ga` +(expect + {:query {:filter nil} + :ga {:segment "gaid::-4", :metrics nil}} + (qp/transform-query {:query {:filter [:segment "gaid::-4"]}})) + +;; check that it still works if wrapped in an `:and` +(expect + {:query {:filter nil} + :ga {:segment "gaid::-4", :metrics nil}} + (qp/transform-query {:query {:filter [:and [:segment "gaid::-4"]]}})) + +;; check that other things stay in the order-by clause +(expect + {:query {:filter [:< 100 200]} + :ga {:segment nil, :metrics nil}} + (qp/transform-query {:query {:filter [:< 100 200]}})) + +(expect + {:query {:filter [:and [:< 100 200]]} + :ga {:segment nil, :metrics nil}} + (qp/transform-query {:query {:filter [:and [:< 100 200]]}})) + +(expect + {:query {:filter [:and [:< 100 200]]} + :ga {:segment "gaid::-4", :metrics nil}} + (qp/transform-query {:query {:filter [:and [:segment "gaid::-4"] + [:< 100 200]]}})) + + +;;; +------------------------------------------------------------------------------------------------------------------------+ +;;; | MBQL->NATIVE (EXPANDED QUERY -> GA QUERY) | +;;; +------------------------------------------------------------------------------------------------------------------------+ + +(defn- ga-query [inner-query] + {:query (merge {:ids "ga:0123456" + :dimensions "" + :start-date "2005-01-01" + :end-date "today" + :max-results 10000 + :include-empty-rows false} + inner-query) + :mbql? true}) + +(defn- mbql->native [query] + (qp/mbql->native (update query :query (partial merge {:source-table {:name "0123456"}})))) + +;; just check that a basic almost-empty MBQL query can be compiled +(expect + (ga-query {}) + (mbql->native {})) + + +;; try a basic query with a metric (aggregation) +(expect + (ga-query {:metrics "ga:users"}) + (mbql->native {:ga {:metrics "ga:users"}})) + + +;; query with metric (aggregation) + breakout +(expect + (ga-query {:metrics "ga:users" + :dimensions "ga:browser"}) + (mbql->native {:query {:breakout [(qpi/map->Field {:field-name "ga:browser"})]} + :ga {:metrics "ga:users"}})) + + +;; query w/ segment (filter) +(expect + (ga-query {:segment "gaid::-4"}) + (mbql->native {:ga {:segment "gaid::-4"}})) + + +;; query w/ non-segment filter +(expect + (ga-query {:filters "ga:continent==North America"}) + (mbql->native {:query {:filter {:filter-type := + :field (qpi/map->Field {:field-name "ga:continent"}) + :value (qpi/map->Value {:value "North America"})}}})) + +;; query w/ segment & non-segment filter +(expect + (ga-query {:filters "ga:continent==North America" + :segment "gaid::-4"}) + (mbql->native {:query {:filter {:filter-type := + :field (qpi/map->Field {:field-name "ga:continent"}) + :value (qpi/map->Value {:value "North America"})}} + :ga {:segment "gaid::-4"}})) + +;; query w/ date filter +(defn- ga-date-field [unit] + (qpi/map->DateTimeField {:field (qpi/map->Field {:field-name "ga:date"}) + :unit unit})) + +;; absolute date +(expect + (ga-query {:start-date "2016-11-08", :end-date "2016-11-08"}) + (mbql->native {:query {:filter {:filter-type := + :field (ga-date-field :day) + :value (qpi/map->DateTimeValue {:value #inst "2016-11-08" + :field (ga-date-field :day)})}}})) + +;; relative date -- last month +(expect + (ga-query {:start-date (u/format-date "yyyy-MM-01" (u/relative-date :month -1)) + :end-date (u/format-date "yyyy-MM-01")}) + (mbql->native {:query {:filter {:filter-type := + :field (ga-date-field :month) + :value (qpi/map->RelativeDateTimeValue {:amount -1 + :unit :month + :field (ga-date-field :month)})}}})) + +;; relative date -- this month +(expect + (ga-query {:start-date (u/format-date "yyyy-MM-01") + :end-date (u/format-date "yyyy-MM-01" (u/relative-date :month 1))}) + (mbql->native {:query {:filter {:filter-type := + :field (ga-date-field :month) + :value (qpi/map->RelativeDateTimeValue {:amount 0 + :unit :month + :field (ga-date-field :month)})}}})) + +;; relative date -- next month +(expect + (ga-query {:start-date (u/format-date "yyyy-MM-01" (u/relative-date :month 1)) + :end-date (u/format-date "yyyy-MM-01" (u/relative-date :month 2))}) + (mbql->native {:query {:filter {:filter-type := + :field (ga-date-field :month) + :value (qpi/map->RelativeDateTimeValue {:amount 1 + :unit :month + :field (ga-date-field :month)})}}})) + +;; relative date -- 2 months from now +(expect + (ga-query {:start-date (u/format-date "yyyy-MM-01" (u/relative-date :month 2)) + :end-date (u/format-date "yyyy-MM-01" (u/relative-date :month 3))}) + (mbql->native {:query {:filter {:filter-type := + :field (ga-date-field :month) + :value (qpi/map->RelativeDateTimeValue {:amount 2 + :unit :month + :field (ga-date-field :month)})}}})) + +;; relative date -- last year +(expect + (ga-query {:start-date (u/format-date "yyyy-01-01" (u/relative-date :year -1)) + :end-date (u/format-date "yyyy-01-01")}) + (mbql->native {:query {:filter {:filter-type := + :field (ga-date-field :year) + :value (qpi/map->RelativeDateTimeValue {:amount -1 + :unit :year + :field (ga-date-field :year)})}}})) + + + + +;; limit +(expect + (ga-query {:max-results 25}) + (mbql->native {:query {:limit 25}})) diff --git a/test/metabase/test/data/bigquery.clj b/test/metabase/test/data/bigquery.clj index f58dbd425fc..4f920dc52ad 100644 --- a/test/metabase/test/data/bigquery.clj +++ b/test/metabase/test/data/bigquery.clj @@ -2,6 +2,7 @@ (:require [clojure.string :as s] [environ.core :refer [env]] [medley.core :as m] + [metabase.driver.google :as google] [metabase.driver.bigquery :as bigquery] (metabase.test.data [dataset-definitions :as defs] [datasets :as datasets] @@ -14,7 +15,7 @@ (com.google.api.services.bigquery.model Dataset DatasetReference QueryRequest Table TableDataInsertAllRequest TableDataInsertAllRequest$Rows TableFieldSchema TableReference TableRow TableSchema) metabase.driver.bigquery.BigQueryDriver)) -(resolve-private-vars metabase.driver.bigquery execute execute-no-auto-retry post-process-native) +(resolve-private-vars metabase.driver.bigquery post-process-native) ;;; # ------------------------------------------------------------ CONNECTION DETAILS ------------------------------------------------------------ @@ -50,16 +51,16 @@ (defn- create-dataset! [^String dataset-id] {:pre [(seq dataset-id)]} - (execute (.insert (.datasets bigquery) project-id (doto (Dataset.) - (.setLocation "US") - (.setDatasetReference (doto (DatasetReference.) - (.setDatasetId dataset-id)))))) + (google/execute (.insert (.datasets bigquery) project-id (doto (Dataset.) + (.setLocation "US") + (.setDatasetReference (doto (DatasetReference.) + (.setDatasetId dataset-id)))))) (println (u/format-color 'blue "Created BigQuery dataset '%s'." dataset-id))) (defn- destroy-dataset! [^String dataset-id] {:pre [(seq dataset-id)]} - (execute-no-auto-retry (doto (.delete (.datasets bigquery) project-id dataset-id) - (.setDeleteContents true))) + (google/execute-no-auto-retry (doto (.delete (.datasets bigquery) project-id dataset-id) + (.setDeleteContents true))) (println (u/format-color 'red "Deleted BigQuery dataset '%s'." dataset-id))) (def ^:private ^:const valid-field-types @@ -67,23 +68,23 @@ (defn- create-table! [^String dataset-id, ^String table-id, field-name->type] {:pre [(seq dataset-id) (seq table-id) (map? field-name->type) (every? (partial contains? valid-field-types) (vals field-name->type))]} - (execute (.insert (.tables bigquery) project-id dataset-id (doto (Table.) - (.setTableReference (doto (TableReference.) - (.setProjectId project-id) - (.setDatasetId dataset-id) - (.setTableId table-id))) - (.setSchema (doto (TableSchema.) - (.setFields (for [[field-name field-type] field-name->type] - (doto (TableFieldSchema.) - (.setMode "REQUIRED") - (.setName (name field-name)) - (.setType (name field-type)))))))))) + (google/execute (.insert (.tables bigquery) project-id dataset-id (doto (Table.) + (.setTableReference (doto (TableReference.) + (.setProjectId project-id) + (.setDatasetId dataset-id) + (.setTableId table-id))) + (.setSchema (doto (TableSchema.) + (.setFields (for [[field-name field-type] field-name->type] + (doto (TableFieldSchema.) + (.setMode "REQUIRED") + (.setName (name field-name)) + (.setType (name field-type)))))))))) (println (u/format-color 'blue "Created BigQuery table '%s.%s'." dataset-id table-id))) (defn- table-row-count ^Integer [^String dataset-id, ^String table-id] - (ffirst (:rows (post-process-native (execute (.query (.jobs bigquery) project-id - (doto (QueryRequest.) - (.setQuery (format "SELECT COUNT(*) FROM [%s.%s]" dataset-id table-id))))))))) + (ffirst (:rows (post-process-native (google/execute (.query (.jobs bigquery) project-id + (doto (QueryRequest.) + (.setQuery (format "SELECT COUNT(*) FROM [%s.%s]" dataset-id table-id))))))))) ;; This is a dirty HACK (defn- ^DateTime timestamp-honeysql-form->GoogleDateTime @@ -95,17 +96,17 @@ (defn- insert-data! [^String dataset-id, ^String table-id, row-maps] {:pre [(seq dataset-id) (seq table-id) (sequential? row-maps) (seq row-maps) (every? map? row-maps)]} - (execute (.insertAll (.tabledata bigquery) project-id dataset-id table-id - (doto (TableDataInsertAllRequest.) - (.setRows (for [row-map row-maps] - (let [data (TableRow.)] - (doseq [[k v] row-map - :let [v (if (instance? honeysql.types.SqlCall v) - (timestamp-honeysql-form->GoogleDateTime v) - v)]] - (.set data (name k) v)) - (doto (TableDataInsertAllRequest$Rows.) - (.setJson data)))))))) + (google/execute (.insertAll (.tabledata bigquery) project-id dataset-id table-id + (doto (TableDataInsertAllRequest.) + (.setRows (for [row-map row-maps] + (let [data (TableRow.)] + (doseq [[k v] row-map + :let [v (if (instance? honeysql.types.SqlCall v) + (timestamp-honeysql-form->GoogleDateTime v) + v)]] + (.set data (name k) v)) + (doto (TableDataInsertAllRequest$Rows.) + (.setJson data)))))))) ;; Wait up to 30 seconds for all the rows to be loaded and become available by BigQuery (let [expected-row-count (count row-maps)] (loop [seconds-to-wait-for-load 30] @@ -160,8 +161,8 @@ (defn- existing-dataset-names "Fetch a list of *all* dataset names that currently exist in the BQ test project." [] - (for [dataset (get (execute (doto (.list (.datasets bigquery) project-id) - (.setMaxResults (long Integer/MAX_VALUE)))) ; Long/MAX_VALUE barfs but it has to be a Long + (for [dataset (get (google/execute (doto (.list (.datasets bigquery) project-id) + (.setMaxResults (long Integer/MAX_VALUE)))) ; Long/MAX_VALUE barfs but it has to be a Long "datasets")] (get-in dataset ["datasetReference" "datasetId"]))) diff --git a/yarn.lock b/yarn.lock index 10dc3b5f815..ae80e49f234 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5381,6 +5381,10 @@ promise-chain-decorator@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/promise-chain-decorator/-/promise-chain-decorator-1.2.0.tgz#3ed952bd37f6351b3989468d1aff40e7d740b451" +promise-loader: + version "1.0.0" + resolved "https://registry.yarnpkg.com/promise-loader/-/promise-loader-1.0.0.tgz#6fc7c8529c1fdfc497bef5fe7448bb61af9546cc" + promise@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/promise/-/promise-7.1.1.tgz#489654c692616b8aa55b0724fa809bb7db49c5bf" -- GitLab