Writing a Driver: The Basics
Metabase drivers are written in Clojure, like Metabase itself. If you have no experience writing Clojure, I highly recommend you take a bit of time and familiarize yourself with the language a bit before diving in. If you're really ambitious, the book Clojure for the Brave and True comes highly recommended, and it's free online. If you don't feel like reading a whole book, Mark Volkmann's Clojure tutorial is another good starting point.
Having an in-depth understanding of Clojure is less important when writing JDBC-based drivers because their implementation is simpler -- much of the work is already for you -- but it would still be helpful to understand what things like multimethods are.
Let's start with what a Metabase driver does. At its core, a Metabase driver does four things:
- Provides basic information such as the capabilities of the database and the connection properties to ask for we someone tries to add a database of that type
- Provides information about the schema of the database -- the tables (or equivalent), columns in those tables, and foreign key relationships, for databases that support foreign keys.
- This functionality is used by the Metabase sync process and stored in the application database
- The stored information is used in the visual Query Builder and other places to show users what tables/columns/etc. are available
- Compiles our in-house query language, MBQL, into native queries.
- MBQL queries are generated by the visual Query Builder
- The Metabase query processor uses this functionality to convert MBQL queries to native queries
- Executes native queries and returns results.
- Like the MBQL to native functionality, this is used by the query processor.
Let's take a high-level look at how Metabase drivers are written. Take a look at this sample code, and we'll discuss at everything in there in detail:
(ns com.mycompany.metabase.driver.foxpro98
(:require [metabase.driver :as driver]))
(driver/register! :foxpro98)
(defmethod driver/display-name :foxpro98 [_]
"Visual FoxPro '98")
Of course, because you took my advice at the beginning of this chapter and have already familiarized yourself with Clojure 😉, you already know exactly what's happening here. We define a namespace, call some function called register!
, and define a multimethod implementation for the dispatch value :foxpro98
. Nothing out of the ordinary. But why?
Typically, each Metabase driver lives in its own namespace -- com.mycompany.metabase.driver.foxpro98
in this case. All core Metabase drivers live in metabase.driver.*
namespaces, but feel free to name yours whatever you want, unless your goal is to have it merged into the core Metabase repo, in which case you'll need to stick to metabase.driver.<driver-name>
. It's probably best to use something resembling the Java package naming conventions.
Many drivers are further broken out into additional namespaces, especially larger drivers. Commonly, a driver will have a query-processor
namespace (e.g. com.mycompany.metabase.driver.foxpro98.query-processor
) that contains the logic for converting MBQL queries into native ones. This is often the most complicated part of a driver, and separating that logic from everything else can help make things easier to work with. Some drivers also have a separate sync
namespace that has implementations for methods used by the sync process.
Now that we've covered namespaces, what goes in them?
The very first call after the namespace declaration in the example above was a call to metabase.driver/register-driver!
:
(driver/register! :foxpro98)
All Metabase drivers must be registered before use. You will probably not need to do this manually using the call to register!
because this is done automatically for drivers shipped as separate plugins; this will be discussed more in later chapters.
The metabase.driver
namespace defines a series of multimethods, and drivers provide implementations for them, as in our example:
(defmethod driver/display-name :foxpro98 [_]
"Visual FoxPro '98")
The four main features of a Metabase driver described above are all implemented by multimethod implementations. All of these methods dispatch off of the keyword name of a driver -- :foxpro98
in our case. In fact, that's all a Metabase driver is -- a keyword! There are no classes or objects to be seen -- just a single keyword.
You can browser the metabase.driver
namespace for a complete list of multimethods you might consider implementing. You do not need to implement all of them -- some are optional -- but be sure to read the docstring for each method and decide whether you need to implement it. Ones that are optional are clearly described as such.
To quickly look up a list of all driver multimethods, you can run the command
lein run driver-methods
which will print a list of all driver namespaces and multimethods. This includes many things like sql
and sql-jdbc
multimethods, as well as test extension multimethods. Test extensions will be covered in a later chapter.
One last thing to note: a lot of drivers share a lot in common, and writing complete implementations for sync methods and the like would involve a lot of code duplication. Thus many high-level features are partially or fully implemented in shared "parent" drivers, such as the most common parent, :sql-jdbc
. A "parent" driver is analogous to a superclass in OO programming.
You can define a driver parent when you register it:
(driver/register! :postgres, :parent :sql-jdbc)
Note that for drivers packaged as separate plugins, you do not need to do this; instead, list the parent in the plugin manifest (discussed in upcoming chapters).
Parents like :sql-jdbc
are intended as a common abstract "base class" for drivers that can share much of their implementation; in the case of :sql-jdbc
, it's intended for SQL-based drivers that use a JDBC driver under the hood.:sql-jdbc
and other parents provide implementations for many of the methods needed to power the four main features of a Metabase driver. In fact, :sql-jdbc
provides implementations of things like driver/execute-query
, so a driver using it as a parent does not need to provide one itself. However, various parent drivers define their own multimethods to implement.
Here's a few parent drivers you should be aware of:
-
:sql-jdbc
can be used as the parent for SQL-based databases with a JDBC driver.-
:sql-jdbc
implements most of the four main features, but instead you must implementsql-jdbc
multimethods found inmetabase.driver.sql-jdbc.*
namespaces, as well as some methods inmetabase.driver.sql.*
namespaces
-
-
:sql
is itself the parent of:sql-jdbc
; it can be used for SQL-based databases that do not have a JDBC driver, such as BigQuery.-
:sql
implements a significant chunk of driver functionality, but you must implement some methods found inmetabase.driver.sql.*
namespaces to use it
-
- Drivers that use Google's API, such as BigQuery and Google Analytics, can use the
:google
driver as a parent - Some drivers use other "concrete" drivers as their parent -- for example,
:redshift
uses:postgres
as a parent, only supplying method implementations to override postgres ones where needed
You can get a parent driver's implementation for a method by using get-method
:
(defmethod driver/mbql->native :bigquery [driver query]
((get-method driver/mbql-native :sql) driver query))
This is the equivalent of calling super.someMethod()
is OO-programming. Note that it is important to pass the driver argument to the parent implementation as-is, so any methods called by that method used the correct implementation. Here's two ways of calling parents that you should avoid:
(defmethod driver/mbql->native :bigquery [_ query]
;; BAD! If :sql's implementation of mbql->native calls any other methods, it won't use the :bigquery implementation
((get-method driver/mbql->native :sql) :sql query))
(defmethod driver/mbql->native :bigquery [_ query]
;; BAD! If someone else creates a driver using :bigquery as a parent, any methods called by :sql's implementation
;; of mbql->native will use :bigquery method implementations instead of custom ones for that driver
((get-method driver/mbql->native :sql) :bigquery query))
The astute may have noticed that BigQuery is mentioned as having both :sql
and :google
as a parent -- the equivalent of OO "multiple inheritance". This is allowed and helpful! You can define a driver with multiple parents as follows:
(driver/register! :bigquery, :parent #{:sql :google})
For drivers shipped as a plugin, this is done in the plugin manifest, which will be described in a later chapter.
In some cases, both parents may provide an implementation for a method; to fix this ambiguity, simply provide an implementation for your driver and pass them to the preferred parent driver's implementation as described above.
- Familiarize yourself with Clojure before proceeding
- Metabase drivers do four things:
- provide basic information like capabilities of the DB, connection properties needed to connect to it, etc.
- provide information about the schema of the DB
- transpile MBQL queries to native queries
- run native queries and return results
- Metabase drivers typically live in their own namespace; some also have additional
query-processor
orsync
namespaces - Drivers must be registered before use; this is done automatically for drivers packaged as separate plugins
- Drivers are nothing more than a series of multimethod implementations
- Drivers can have one or more parent drivers to use shared functionality
- The most common parents are
:sql
and:sql-jdbc
- Most parent drivers define their own set of multimethods you must implement
- Use
get-method
to call a parent driver implementation; be sure to pass thedriver
argument as-is
- The most common parents are
- Backend
- Metabase Developer Reference
- Product Management
- QA and Testing
- Writing A Driver
- Driver Notices
- REST API Notices
- Writing style guide for documentation and blog posts (WIP)