Skip to content
Snippets Groups Projects
Commit 5b4b7b80 authored by Cam Saul's avatar Cam Saul
Browse files

Merge branch 'master' of github.com:metabase/metabase-init

parents 67fd6188 d1006f32
No related branches found
No related tags found
No related merge requests found
Showing
with 133 additions and 605 deletions
......@@ -21,7 +21,7 @@
(expect-eval-actual-first 1)
(expect-expansion 0)
(expect-let 1)
(expect-when-testing-against-dataset 1)
(expect-when-testing-dataset 1)
(expect-when-testing-mongo 1)
(expect-with-all-drivers 1)
(expect-with-dataset 1)
......
......@@ -4,34 +4,24 @@
</section>
<section>
<table class="ContentTable">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Value</th>
<th></th>
</tr>
</thead>
<tbody>
<tr ng-show="!settings">
<td colspan=4>
<mb-loading-icon></mb-loading-icon>
<h3>Loading ...</h3>
</td>
</tr>
<tr ng-repeat="setting in settings">
<td>{{setting.key}}</td>
<td>{{setting.description}}</td>
<td>
<input class="input block full" type="text" placeholder="{{setting.default}}" ng-model="setting.value"></input>
</td>
<td class="Table-actions">
<button class="Button Button--primary" ng-click="saveSetting(setting)" ng-disabled="!setting.value || setting.value === setting.originalValue">Save</button>
<button class="mx2 Button" ng-class="{'Button--danger': setting.originalValue !== null}" ng-click="deleteSetting(setting)" ng-disabled="!setting.originalValue">Clear</button>
</td>
</tr>
</tbody>
</table>
<form class="Form-new bordered rounded shadowed" name="form" novalidate>
<!-- Form -->
<div class="FormInputGroup">
<div class="Form-field" ng-repeat="setting in settings" mb-form-field="{{setting.key}}">
<mb-form-label display-name="{{settingName(setting)}}" field-name="{{setting.key}}"></mb-form-label>
<input class="Form-input Form-offset full" name="{{setting.key}}" placeholder="{{settingPlaceholder(setting)}}" ng-model="setting.value" />
<span class="Form-charm"></span>
</div>
</div>
<!-- Bottom Actions -->
<div class="Form-actions">
<button class="Button" ng-class="{'Button--primary': form.$valid}" ng-click="save(database, details)" ng-disabled="!form.$valid">
Save
</button>
<mb-form-message></mb-form-message>
</div>
</form>
</section>
</div>
......@@ -3,8 +3,8 @@
var SettingsAdminControllers = angular.module('corvusadmin.settings.controllers', ['corvusadmin.settings.services']);
SettingsAdminControllers.controller('SettingsAdminController', ['$scope', 'SettingsAdminServices',
function($scope, SettingsAdminServices) {
SettingsAdminControllers.controller('SettingsAdminController', ['$scope', '$q', 'SettingsAdminServices',
function($scope, $q, SettingsAdminServices) {
$scope.settings = [];
SettingsAdminServices.list(function(results) {
......@@ -16,24 +16,29 @@ SettingsAdminControllers.controller('SettingsAdminController', ['$scope', 'Setti
console.log("Error fetching settings list: ", error);
});
$scope.saveSetting = function(setting) {
SettingsAdminServices.put({
key: setting.key
}, setting, function() {
setting.originalValue = setting.value;
}, function(error) {
console.log("Error saving setting: ", error);
});
};
$scope.settingName = function(setting) {
return setting.description.replace(/\.$/, '');
}
$scope.settingPlaceholder = function(setting) {
return setting.default;
}
$scope.deleteSetting = function(setting) {
SettingsAdminServices.delete({
key: setting.key
}, function() {
setting.value = null;
setting.originalValue = null;
$scope.save = function() {
$scope.$broadcast("form:reset");
return $q.all($scope.settings.map(function(setting) {
if (setting.value !== setting.originalValue) {
return SettingsAdminServices.put({
key: setting.key
}, setting).$promise.then(function() {
setting.originalValue = setting.value;
});
}
})).then(function(results) {
$scope.$broadcast("form:api-success", "Successfully saved!");
}, function(error) {
console.log("Error deleting setting: ", error);
$scope.$broadcast("form:api-error", error);
throw error;
});
};
}
......
......@@ -22,8 +22,7 @@ var Corvus = angular.module('corvus', [
'corvus.dashboard',
'corvus.explore',
'corvus.home',
'corvus.operator', // this is a short term hack
'corvus.reserve',
'corvus.reserve', // this is a short term hack
'corvus.user',
'corvus.setup',
'corvusadmin.databases',
......
'use strict';
var OperatorControllers = angular.module('corvus.operator.controllers', []);
OperatorControllers.controller('SpecialistList', ['$scope', 'Metabase', 'Operator',
function($scope, Metabase, Operator) {
// set initial defaults for sorting
$scope.orderByField = "nick";
$scope.reverseSort = false;
Operator.queryInfo().then(function(queryInfo){
// TODO: we need offset support in dataset_query if we want to do paging
// TODO: ideally we can search by (name, id, store)
$scope.search = function () {
Metabase.dataset({
'database': queryInfo.database,
'type': 'query',
'query': {
'source_table': queryInfo.specialist_table,
'aggregation': ['rows'],
'breakout': [null],
'filter':[null, null],
'limit': null
}
}, function (queryResponse) {
// TODO: we should check that the query succeeded
$scope.specialists = Operator.convertToObjects(queryResponse.data);
}, function (error) {
console.log(error);
});
};
$scope.search();
//run saved SQL queries for overview metrics in right pane
Metabase.dataset({
'database': queryInfo.database,
'type': 'result',
'result':{
query_id: queryInfo.specialist_overview_avg_rating_query
}
}, function(queryResponse){
$scope.overviewAvgRating = queryResponse.data.rows[0][0];
}, function(error){
console.log("error:");
console.log(error);
});
Metabase.dataset({
'database': queryInfo.database,
'type': 'result',
'result': {
query_id: queryInfo.specialist_overview_avg_response_time_query
}
}, function(queryResponse){
$scope.overviewAvgResponseTimeSecs = queryResponse.data.rows[0][0];
}, function(error){
console.log("error:");
console.log(error);
});
}, function(reason){
console.log("failed to get queryInfo:");
console.log(reason);
});
}
]);
OperatorControllers.controller('SpecialistDetail', ['$scope', '$routeParams', 'Metabase', 'Operator',
function($scope, $routeParams, Metabase, Operator) {
// set the default ordering to the last message sent, as the field team is generally concerned with
// recent messages
$scope.orderByField = "time_newmessage_server";
// set reverse to true so we see the most recent messages first
$scope.reverseSort = true;
Operator.queryInfo().then(function(queryInfo){
if ($routeParams.specialistId) {
Metabase.dataset({
'database': queryInfo.database,
'type': 'query',
'query': {
'source_table': queryInfo.specialist_table,
'aggregation': ['rows'],
'breakout': [null],
'filter':['=', queryInfo.specialist_id_field, parseInt($routeParams.specialistId, 10)],
'limit': null
}
}, function (queryResponse) {
$scope.specialist = Operator.convertToObjects(queryResponse.data)[0];
// grab conversations
Metabase.dataset({
'database': queryInfo.database,
'type': 'query',
'query': {
'source_table': queryInfo.conversations_table,
'aggregation': ['rows'],
'breakout': [null],
'filter':['=', queryInfo.conversations_specialist_fk, parseInt($routeParams.specialistId, 10)],
'limit': null
}
}, function (response) {
$scope.conversations = Operator.convertToObjects(response.data);
}, function (error) {
console.log(error);
});
}, function (error) {
console.log(error);
});
}
}, function(reason){
console.log("failed to get queryInfo:");
console.log(reason);
});
}
]);
OperatorControllers.controller('ConversationDetail', ['$scope', '$routeParams', 'Metabase', 'Operator',
function($scope, $routeParams, Metabase, Operator) {
$scope.toObject = function(str) {
//var unescaped = str.replace(/\\"/g, '"');
return angular.fromJson(str);
};
Operator.queryInfo().then(function(queryInfo){
if ($routeParams.conversationId) {
Metabase.dataset({
'database': queryInfo.database,
'type': 'query',
'query': {
'source_table': queryInfo.conversations_table,
'aggregation': ['rows'],
'breakout': [null],
'filter':['=', queryInfo.conversations_id_field, $routeParams.conversationId],
'limit': null
}
}, function (queryResponse) {
$scope.conversation = Operator.convertToObjects(queryResponse.data)[0];
// grab messages
// TODO: ensure ordering by message timestamp
Metabase.dataset({
'database': queryInfo.database,
'type': 'query',
'query': {
'source_table': queryInfo.messages_table,
'aggregation': ['rows'],
'breakout': [null],
'filter':['=', queryInfo.messages_table_conversation_fk, $routeParams.conversationId],
'limit': null
}
}, function (response) {
$scope.messages = Operator.convertToObjects(response.data);
// sort them by timestamp
$scope.messages.sort(function compare (a, b) {
if (a.time_updated_server < b.time_updated_server)
return -1;
if (a.time_updated_server > b.time_updated_server)
return 1;
return 0;
});
}, function (error) {
console.log(error);
});
}, function (error) {
console.log(error);
});
}
}, function(reason){
console.log("failed to get queryInfo:");
console.log(reason);
});
}
]);
'use strict';
var Operator = angular.module('corvus.operator', [
'ngRoute',
'ngCookies',
'corvus.filters',
'corvus.directives',
'corvus.services',
'corvus.metabase.services',
'corvus.operator.controllers',
'corvus.operator.services'
]);
Operator.config(['$routeProvider', function ($routeProvider) {
$routeProvider.when('/operator/specialist/:specialistId', {templateUrl: '/app/operator/partials/specialist_detail.html', controller: 'SpecialistDetail'});
$routeProvider.when('/operator/specialist/', {templateUrl: '/app/operator/partials/specialist_list.html', controller: 'SpecialistList'});
$routeProvider.when('/operator/conversation/:conversationId', {templateUrl: '/app/operator/partials/conversation_detail.html', controller: 'ConversationDetail'});
}]);
'use strict';
/*jslint browser:true */
/*global _*/
/* Services */
var OperatorServices = angular.module('corvus.operator.services', []);
OperatorServices.service('Operator', ['$resource', '$q', 'Metabase', 'Query',
function($resource, $q, Metabase, Query) {
var OPERATOR_DB_NAME = "operator";
var SPECIALIST_TABLE_NAME = "ps_specialist_details_with_calc_metrics";
var SPECIALIST_ID_FIELD_NAME = "specialist_id";
var CONVERSATIONS_TABLE_NAME = "ag_conversations";
var CONVERSATIONS_ID_FIELD_NAME = "channel_id";
var CONVERSATIONS_SPECIALIST_FK_NAME = "specialist_id";
var MESSAGES_TABLE_NAME = "chat_message";
var MESSAGES_CONVERSATIONS_FK_NAME = "channel_id";
var SPECIALIST_OVERVIEW_AVG_RATING_QUERY_NAME = "Specialist Entity Avg Rating";
var SPECIALIST_OVERVIEW_AVG_RESPONSE_TIME_QUERY = "Specialist Entity Avg Response Time Secs";
this.queryInfo = function() {
var deferred = $q.defer();
var queryInfo = {};
Metabase.db_list(function (dbs){
dbs.forEach(function(db){
if(db.name == OPERATOR_DB_NAME){
queryInfo.database = db.id;
Metabase.db_tables({dbId:db.id}, function(tables){
tables.forEach(function(table){
if(table.name == SPECIALIST_TABLE_NAME){
queryInfo.specialist_table = table.id;
}else if(table.name == CONVERSATIONS_TABLE_NAME){
queryInfo.conversations_table = table.id;
}else if(table.name == MESSAGES_TABLE_NAME){
queryInfo.messages_table = table.id;
}
});
Metabase.table_fields({tableId:queryInfo.specialist_table}, function(specialistTableFields){
specialistTableFields.forEach(function(field){
if(field.name == SPECIALIST_ID_FIELD_NAME){
queryInfo.specialist_id_field = field.id;
Metabase.table_fields({tableId:queryInfo.conversations_table}, function(conversationsTableFields){
conversationsTableFields.forEach(function(field){
if(field.name == CONVERSATIONS_ID_FIELD_NAME){
queryInfo.conversations_id_field = field.id;
}else if(field.name == CONVERSATIONS_SPECIALIST_FK_NAME){
queryInfo.conversations_specialist_fk = field.id;
}
});
Metabase.table_fields({tableId:queryInfo.messages_table}, function(messagesTableFields){
messagesTableFields.forEach(function(field){
if(field.name == MESSAGES_CONVERSATIONS_FK_NAME){
queryInfo.messages_table_conversation_fk = field.id;
Query.list({
filterMode: 'all'
}, function(queries){
queries.forEach(function(query){
if(query.name == SPECIALIST_OVERVIEW_AVG_RATING_QUERY_NAME){
queryInfo.specialist_overview_avg_rating_query = query.id;
}else if(query.name == SPECIALIST_OVERVIEW_AVG_RESPONSE_TIME_QUERY){
queryInfo.specialist_overview_avg_response_time_query = query.id;
}
});
deferred.resolve(queryInfo);
}, function(error){
console.log("error getting queries:");
console.log(error);
});
}
});
});
});
}
});
});
});
}
});
});
return deferred.promise;
};
this.convertToObjects = function (data) {
var rows = [];
for (var i = 0; i < data.rows.length; i++) {
var row = {};
for (var j = 0; j < data.cols.length; j++) {
var coldef = data.cols[j];
row[coldef.name] = data.rows[i][j];
}
rows.push(row);
}
return rows;
};
}
]);
<div class="col col-md-12">
<div class="row">
<div class="p4 border-bottom">
<h3>Conversation with
<a class="link" href="/operator/specialist/{{conversation.specialist_id}}">{{conversation.specialist_nick}}</a>
and {{conversation.sender_nick}}
</h3>
</div>
</div>
<div class="row">
<ul>
<li class="Message clearfix" ng-repeat="message in messages" ng-class="{'Message--alt' : message.sender_id == conversation.specialist_id}">
<div class="Message-sender inline-block text-bold">
<div ng-if="message.sender_id == conversation.specialist_id">{{conversation.specialist_nick}}</div>
<div ng-if="message.sender_id == conversation.sender_id">{{conversation.sender_nick}}</div>
</div>
<div class="Timestamp ml1 inline-block">{{message.time_updated_server | date : 'MMM d, y - hh:mm a'}}</div>
<p class="Message-text" ng-if="message.content_type == 'mixed'">{{toObject(message.content).text}}</p>
<p class="Message-text" ng-if="message.content_type != 'mixed'">{{message.content_type}}: {{message.content}}</p>
</li>
</ul>
</div>
</div>
<div class="col col-md-9">
<div class="row">
<div class="clearfix full-width">
<div class="p4 float-left">
<img class="EntityImage EntityImage--large" ng-src="{{specialist.avatar}}" ng-if="specialist.avatar">
<img src="" ng-if="!specialist.avatar" />
</div>
<div class="mt3 float-left">
<h2>
<a class="link" href="/operator/specialist/{{specialist.specialist_id}}">{{specialist.nick}}</a> <span ng-if="specialist.is_test_account">[test account]</span>
</h2>
<div class="inline-block">{{specialist.business_name}}</div>
<div class="inline-block ml2">Specialist since: {{specialist.ts_created | date : 'MMMM d, y'}}</div>
</div>
<div class="float-right m4">
<a class="Button mt1" href="mailto:team@operator.com?subject=Specialist%20{{specialist.nick}}">Share</a>
</div>
</div>
</div>
<div class="row">
<div class="mt3 px3">
<h4>Conversations</h4>
</div>
<div class="EntityTableWrapper">
<table class="EntityTable Table">
<thead>
<tr>
<th ng-click="orderByField='sender_nick'; reverseSort = !reverseSort" ng-class="'ColumnSelected' : orderByField == 'sender_nick'">With:
<span ng-if="orderByField == 'sender_nick'">
<span ng-show="!reverseSort">^</span>
<span ng-show="reverseSort">v</span>
</span>
</th>
<th ng-click="orderByField='ts_start'; reverseSort = !reverseSort" ng-class="'ColumnSelected' : orderByField == 'ts_start'">Started:
<span ng-if="orderByField == 'ts_start'">
<span ng-show="!reverseSort">^</span>
<span ng-show="reverseSort">v</span>
</span>
</th>
<th ng-click="orderByField='ts_newmsg'; reverseSort = !reverseSort" ng-class="'ColumnSelected' : orderByField == 'ts_newmsg'">Last message:
<span ng-if="orderByField == 'ts_newmsg'">
<span ng-show="!reverseSort">^</span>
<span ng-show="reverseSort">v</span>
</span>
</th>
<th ng-click="orderByField='total_channel_msgs'; reverseSort = !reverseSort" ng-class="'ColumnSelected' : orderByField == 'total_channel_msgs'">Total messages:
<span ng-if="orderByField == 'total_channel_msgs'">
<span ng-show="!reverseSort">^</span>
<span ng-show="reverseSort">v</span>
</span>
</th>
<th ng-click="orderByField='avg_response_time_secs'; reverseSort = !reverseSort" ng-class="'ColumnSelected' : orderByField == 'average_response_time_secs'">Avg Response Time (s)
<span ng-if="orderByField == 'average_response_time_secs'">
<span ng-show="!reverseSort">^</span>
<span ng-show="reverseSort">v</span>
</span>
</th>
<th></th>
</tr>
</thead>
<tfoot>
</tfoot>
<tbody>
<tr ng-repeat="convo in conversations | orderBy:orderByField:reverseSort">
<td>
<span class="EntityName">{{convo.sender_nick}}</span>
</td>
<td>{{convo.ts_start | date : 'MMM d, y, hh:mm a'}}</td>
<td>{{convo.ts_newmsg | date : 'MMM d, y, hh:mm a'}}</td>
<td>{{convo.total_channel_msgs}}</td>
<td>{{convo.avg_response_time_secs}}</td>
<td><a class="link" href="/operator/conversation/{{convo.channel_id}}">View conversation</a></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col col-md-3">
<div class="row">
<div class="p3">
<h3 class="mt2">{{specialist.nick}}'s Metrics</h3>
</div>
<ol class="px2 py2">
<li class="Metric mb2 shadowed">
<div class="px3 py1">
<div class="Metric-value">{{specialist.avg_response_time_secs | readableTime}}</div>
<div class="Metric-title mb2">Average response time</div>
</div>
</li>
<li class="Metric mb2 shadowed">
<div class="px3 py1">
<div class="Metric-value">{{specialist.avg_rating}}</div>
<div class="Metric-title mb2">Average rating</div>
</div>
</li>
</ol>
</div>
</div>
<div class="col col-md-9">
<div class="row">
<div class="p3 border-bottom">
<div class="float-right">
<input class="input" type="text" ng-model="searchFilter" placeholder="Search specialists ...">
</div>
<h3 class="text-brand">Specialists</h2>
</div>
<div ng-if="!specialists">
<h3>Loading ...</h3>
</div>
<div class="EntityTableWrapper">
<table class="EntityTable Table">
<thead>
<tr>
<th ng-click="orderByField='nick'; reverseSort = !reverseSort" ng-class="{'EntityTable--columnSelected': orderByField == 'nick'}">Name
<span ng-if="orderByField == 'nick'">
<span ng-show="!reverseSort">^</span>
<span ng-show="reverseSort">v</span>
</span>
</th>
<th ng-click="orderByField='business_name'; reverseSort = !reverseSort" ng-class="{'EntityTable--columnSelected': orderByField == 'business_name'}">Business
<span ng-if="orderByField == 'business_name'">
<span ng-show="!reverseSort">^</span>
<span ng-show="reverseSort">v</span>
</span>
</th>
<th ng-click="orderByField='avg_response_time_secs'; reverseSort = !reverseSort" ng-class="{'EntityTable--columnSelected': orderByField == 'avg_response_time_secs'}">Avg response time (s)
<span ng-if="orderByField == 'avg_response_time_secs'">
<span ng-show="!reverseSort">^</span>
<span ng-show="reverseSort">v</span>
</span>
</th>
<th ng-click="orderByField='avg_rating'; reverseSort = !reverseSort" ng-class="{'EntityTable--columnSelected': orderByField == 'avg_rating'}">Avg rating
<span ng-if="orderByField == 'avg_rating'">
<span ng-show="!reverseSort">^</span>
<span ng-show="reverseSort">v</span>
</span>
</th>
<th ng-click="orderByField='ts_created'; reverseSort = !reverseSort" >Specialist since:
<span ng-if="orderByField == 'ts_created'">
<span ng-show="!reverseSort">^</span>
<span ng-show="reverseSort">v</span>
</span>
</th>
<th ng-click="orderByField='most_recent_activity'; reverseSort = !reverseSort">Most recent activitity:
<span ng-if="orderByField == 'most_recent_activity'">
<span ng-show="!reverseSort">^</span>
<span ng-show="reverseSort">v</span>
</span>
</th>
</tr>
</thead>
<tfoot></tfoot>
<tbody>
<tr ng-repeat="specialist in specialists | orderBy:orderByField:reverseSort | filter:searchFilter">
<td class="clearfix">
<img class="EntityImage EntityImage--small float-left hide lg-show" src="{{specialist.avatar}}">
<a class="EntityName float-left ml1 link" href="/operator/specialist/{{specialist.specialist_id}}">{{specialist.nick}}</a>
</td>
<td>{{specialist.business_name}}</td>
<td>{{specialist.avg_response_time_secs}}</td>
<td>{{specialist.avg_rating}}</td>
<td>{{specialist.ts_created | date : 'MMMM d, y '}}</td>
<td>{{specialist.most_recent_activity}}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col col-md-3">
<div class="row">
<div class="p3 border-bottom">
<h3>Specialist metrics</h3>
</div>
<ol class="px2 py2">
<li class="Metric mb2 shadowed">
<div class="px3 py1">
<div class="Metric-value">{{overviewAvgResponseTimeSecs | readableTime}}</div>
<div class="Metric-title mb2">Average response time</div>
</div>
</li>
<li class="Metric mb2 shadowed">
<div class="px3 py1">
<div class="Metric-value">{{overviewAvgRating}}</div>
<div class="Metric-title mb2">Average rating</div>
</div>
</li>
</ol>
</div>
</div>
......@@ -8,7 +8,15 @@
(metabase.driver.generic-sql [query-processor :as qp]
[util :refer :all])))
(defrecord SqlDriver [column->base-type
(def ^:private ^:const sql-driver-features
"Features supported by *all* Generic SQL drivers."
#{:foreign-keys
:standard-deviation-aggregations
:unix-timestamp-special-type-fields})
(defrecord SqlDriver [;; A set of additional features supported by a specific driver implmentation, e.g. :set-timezone for :postgres
additional-supported-features
column->base-type
connection-details->connection-spec
database->connection-details
sql-string-length-fn
......@@ -20,6 +28,11 @@
;; e.g. #"CAST\(TIMESTAMPADD\('(?:MILLI)?SECOND', ([^\s]+), DATE '1970-01-01'\) AS DATE\)" for H2
uncastify-timestamp-regex]
IDriver
;; Features
(supports? [_ feature]
(or (contains? sql-driver-features feature)
(contains? additional-supported-features feature)))
;; Connection
(can-connect? [this database]
(can-connect-with-details? this (database->connection-details database)))
......
(ns metabase.driver.interface
"Protocols that DB drivers implement. Thus, the interface such drivers provide.")
"Protocols that DB drivers implement. Thus, the interface such drivers provide."
(:import (clojure.lang Keyword)))
(def ^:const driver-optional-features
"A set on optional features (as keywords) that may or may not be supported by individual drivers."
#{:foreign-keys
:set-timezone
:standard-deviation-aggregations
:unix-timestamp-special-type-fields})
;; ## IDriver Protocol
(defprotocol IDriver
"Methods all drivers must implement."
;; Features
(supports? [this ^Keyword feature]
"Does this driver support optional FEATURE?
(supports? metabase.driver.h2/driver :timestamps) -> false")
;; Connection
(can-connect? [this database]
"Check whether we can connect to DATABASE and perform a simple query.
(To check whether we can connect to a database given only its details, use `can-connect-with-details?` instead).
(can-connect? (sel :one Database :id 1))")
(can-connect? driver (sel :one Database :id 1))")
(can-connect-with-details? [this details-map]
"Check whether we can connect to a database and performa a simple query.
Returns true if we can, otherwise returns false or throws an Exception.
(can-connect-with-details? {:engine :postgres, :dbname \"book\", ...})")
(can-connect-with-details? driver {:engine :postgres, :dbname \"book\", ...})")
;; Syncing
(sync-in-context [this database do-sync-fn]
......@@ -80,3 +95,12 @@
If a driver doesn't implement this protocol, it *must* implement `ISyncDriverFieldValues`."
(field-percent-urls [this field]
"Return the percentage of non-nil values of textual FIELD that are valid URLs."))
;; ## Helper Functions
(defn assert-driver-supports
"Helper fn. Assert that DRIVER supports FEATURE."
[driver ^Keyword feature]
(when-not (supports? driver feature)
(throw (Exception. (format "%s is not supported by this driver." (name feature))))))
......@@ -40,8 +40,16 @@
;;; ## MongoDriver
(def ^:const ^:private mongo-driver-features
"Optional features supported by the Mongo driver."
#{}) ; nothing yet
(deftype MongoDriver []
IDriver
;;; ### Features
(supports? [_ feature]
(contains? mongo-driver-features feature))
;;; ### Connection
(can-connect? [_ database]
(with-mongo-connection [^com.mongodb.DBApiLayer conn database]
......
......@@ -125,7 +125,8 @@
(def ^:const driver
(generic-sql/map->SqlDriver
{:column->base-type column->base-type
{:additional-supported-features #{:set-timezone}
:column->base-type column->base-type
:connection-details->connection-spec connection-details->connection-spec
:database->connection-details database->connection-details
:sql-string-length-fn :CHAR_LENGTH
......
......@@ -50,7 +50,7 @@
(defn- pre-expand [qp]
(fn [query]
(qp (expand/expand query))))
(qp (expand/expand *driver* query))))
(defn- post-add-row-count-and-status
......
......@@ -42,6 +42,7 @@
[medley.core :as m]
[swiss.arrows :refer [-<>]]
[metabase.db :refer [sel]]
[metabase.driver.interface :as i]
(metabase.models [database :refer [Database]]
[field :as field]
[foreign-key :refer [ForeignKey]]
......@@ -80,6 +81,12 @@
;; ## -------------------- Expansion - Impl --------------------
(def ^:private ^:dynamic *driver* nil)
(defn- assert-driver-supports [^Keyword feature]
{:pre [*driver*]}
(i/assert-driver-supports *driver* feature))
(defn- non-empty-clause? [clause]
(and clause
(or (not (sequential? clause))
......@@ -185,8 +192,9 @@
(defn expand
"Expand a QUERY-DICT."
[query-dict]
(binding [*field-ids* (atom #{})
[driver query-dict]
(binding [*driver* driver
*field-ids* (atom #{})
*fk-field-ids* (atom #{})
*table-ids* (atom #{})]
(some-> query-dict
......@@ -272,9 +280,10 @@
(->FieldPlaceholder field-id))
["fk->"
(fk-field-id :guard integer?)
(dest-field-id :guard integer?)] (do (swap! *field-ids* conj dest-field-id)
(swap! *fk-field-ids* conj fk-field-id)
(->FieldPlaceholder dest-field-id))))
(dest-field-id :guard integer?)] (do (assert-driver-supports :foreign-keys)
(swap! *field-ids* conj dest-field-id)
(swap! *fk-field-ids* conj fk-field-id)
(->FieldPlaceholder dest-field-id))))
([field-id value]
(->ValuePlaceholder (:field-id (ph field-id)) value)))
......@@ -300,7 +309,8 @@
["avg" field-id] (->Aggregation :avg (ph field-id))
["count" field-id] (->Aggregation :count (ph field-id))
["distinct" field-id] (->Aggregation :distinct (ph field-id))
["stddev" field-id] (->Aggregation :stddev (ph field-id))
["stddev" field-id] (do (assert-driver-supports :standard-deviation-aggregations)
(->Aggregation :stddev (ph field-id)))
["sum" field-id] (->Aggregation :sum (ph field-id))
["cum_sum" field-id] (->Aggregation :cumulative-sum (ph field-id)))
......
......@@ -622,6 +622,15 @@
{:source_table (id :venues)
:aggregation ["stddev" (id :venues :latitude)]})
;; Make sure standard deviation fails for the Mongo driver since its not supported
(datasets/expect-with-dataset :mongo
{:status :failed
:error "standard-deviation-aggregations is not supported by this driver."}
(driver/process-query {:database (db-id)
:type :query
:query {:source_table (id :venues)
:aggregation ["stddev" (id :venues :latitude)]}}))
;;; ## order_by aggregate fields (SQL-only for the time being)
......@@ -891,3 +900,15 @@
[&sightings.id:id "ascending"]]
:limit 10)
:data :rows (map butlast) (map reverse))) ; drop timestamps. reverse ordering to make the results columns order match order_by
;; Check that trying to use a Foreign Key fails for Mongo
(datasets/expect-with-dataset :mongo
{:status :failed
:error "foreign-keys is not supported by this driver."}
(query-with-temp-db defs/tupac-sightings
:source_table &sightings:id
:order_by [[["fk->" &sightings.city_id:id &cities.name:id] "ascending"]
[["fk->" &sightings.category_id:id &categories.name:id] "descending"]
[&sightings.id:id "ascending"]]
:limit 10))
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment