Ibis: an idiomatic flavor of SQL for Python programmers
Ibis is a mature open-source project that has been in development for about 5 years; it currently has about 1350 stars on Github. It provides an interface to SQL for Python programmers and bridges the gap between remote storage & execution systems. These features provide authors the ability to:
Ibis is an alternative approach using databases that relies on Python rather than SQL experience. Typically, users have to learn an entirely new syntax or flavor of SQL to perform simple tasks. Now, those familiar with Python can avoid a new learning curve by using Ibis for composing and executing database queries using familiar Python syntaxes (i.e., similar to Pandas and Dask). Ibis assists in formation of SQL expressions by providing visual feedback about each Python object. This post focuses on writing SQL expressions in Python and how to compose queries visually using Ibis. We'll demonstrate this with a SQLite database—in particular, Sean Lahman’s baseball database.
Connecting to a database¶
To get started, we’ll need to establish a database connection. Ibis makes it easy to create connections of different types. Let's go ahead and do this now with the function ibis.sqlite.connect (in this instance, the database used is a SQLite database):
%matplotlib inline
import ibis
import pathlib, requests
db_path = pathlib.Path.cwd() / 'lahmansbaseballdb.sqlite'
if not db_path.exists(): # Downloads database if necessary
with open(db_path, 'wb') as f:
URL = 'https://github.com/WebucatorTraining/lahman-baseball-mysql/raw/master/lahmansbaseballdb.sqlite'
req = requests.get(URL)
f.write(req.content)
client = ibis.sqlite.connect(db_path.name) # Opens SQLite database connection
The client object represents our connection to the database. It is essential to use the appropriate Ibis connection—SQLite in this case constructed through the ibis.sqlite namespace—for the particular database.
This baseball database has 29 distinct tables; we can see by running the following code:
tables = client.list_tables()
print(f'This database has {len(tables)} tables.')
Selecting and visualizing tables¶
Displaying the list tables, gives the names of all the tables which include, among others, tables with identifiers
{python}
[...'appearances'...'halloffame', 'homegames', 'leagues', 'managers',...]
Let's use the database connection to extract & examine dataframe representations of the halloffame and appearances tables from the baseball database. To do this, we can invoke the table method associated with the client object called with the appropriate names.
halloffame = client.table('halloffame', database='base')
appearances = client.table('appearances', database='base')
At the moment, the objects objects halloffame and appearances just constructed don’t hold any data; instead, the objects are expressions of type TableExpr that represent putative operations applied to the data. The data itself is inert wherever it's actually located—in this case, within the SQLite database. We can verify this by examining their types or by using assertions like this:
print(f'The object appearances has type {type(appearances).__name__}.')
assert isinstance(halloffame, ibis.expr.types.TableExpr), 'Wrong type for halloffame'
We can examine the contents of these Ibis table expressions using the TableExpr.limit or the TableExpr.head method (similar to the Pandas DataFrame.head method). That is, we can define an object sample that represents a sub-table comprising the first few rows of the halloffame table:
sample = halloffame.head()
print(f'The object sample is of type {type(sample).__name__}')
Remember, the object sample is a TableExpr object representing some SQL query to extracts a sub-table from a larger table. We can view the actual SQL query corresponding to sample by compiling it with the compile method and converting the result to a string:
str(sample.compile())
Another useful feature of Ibis is its ability to represent an SQL query as a DAG (Directed Acyclic Graph). For instance, evaluating the object sample at the interactive command prompt yields a visualization of a sequence of database operations:
sample # This produces the image below in a suitably enabled shell
This image of a DAG is produced using Graphviz; those familiar with Dask may have used a similar helpful feature to assemble task graphs.
Finally, the actual sub-table corresponding to the expression sample can be extracted using the execute method (similar to compute in Dask). The result returned by executing the expression sample is a tidy Pandas DataFrame object.
result = sample.execute()
print(f'The type of result is {type(result).__name__}')
result # Leading 5 rows of halloffame table)
A similar extraction of the leading five rows from the appearances table (in one line)
gives the following table with 23 columns:
appearances.head().execute() # Leading 5 rows of appearances table)
Filtering and selecting data¶
As mentioned earlier, Ibis uses familiar Pandas syntax to build SQL queries. As an example, let's look at the various kinds of entries in the category column from the halloffame table. A nice way to do this is to extract the relevant column with attribute access and apply the value_counts method. Remember, an invokation of execute is needed to realize the actual expression.
halloffame.category.value_counts().execute()
There are four different types of entries in this column, most of which are Players. To illustrate filtering and selection, we'll create a expression condition of boolean values corresponding to rows from the halloffame table in which the category column has the value Player. The boolean values represented by condition can be extracted from the table halloffame using brackets. The final result is bound to the identifier players.
condition = halloffame.category == 'Player'
players = halloffame[condition]
players.execute() # take a look at this table
Joining Ibis tables¶
If we want a single view of the halloffame players and their appearances, we need to join) the tables halloffame and appearances. To do this, we’ll perform an inner join based on the playerID columns of our players & appearances tables.
condition = players.playerID == appearances.playerID
We notice that both the players and the appearances tables each have a column labelled ID. This column needs to be excluded from appearances; otherwise the overlapping columns will corrupt the computed join. Specifically, we want to filter out the ID and playerID columns from the appearances table. One strategy to do this is to use a list comprehension.
columns = [col for col in appearances.columns if col not in ('playerID', 'ID')]
Now, we use the TableExpr.join method to compute an inner join of the players table and the filtered appearances table; the result is bound to the identifier unmaterialized.
unmaterialized = players.join(appearances[columns], condition)
Materializing the join¶
We used the identifier unmaterialized just above to emphasize that the resulting expression is not a materialized view (that would be required to build new expressions). Without a materialized view, Ibis raises an exception (as demonstrated here).
try:
unmaterialized.distinct()
except Exception as e:
print('Unable to execute "unmaterialized.distinct()"')
print(repr(e))
The distinct method in the preceding code behaves like the Pandas DataFrame.drop_duplicates method, i.e., it drops duplicated rows. We can obtain such a materialized view to circumvent the exception above using the expression's materialize method.
join = unmaterialized.materialize().distinct()
The code above completes the join and binds the resulting expression to the materialized object join; here is a sample of the leading five rows of our joined data (notice the result has 31 columns).
join.head().execute()
Ibis supports other join strategies as methods of the class TableExpr. The following list comprehension shows us what they are.
[method_name for method_name in dir(players) if 'join' in method_name]
Executing an expression¶
We'll now expand the expression join as a Pandas DataFrame object. We'll use this DataFrame to answer the following question:
Some of the "hitters" have also been "pitchers", so we’ll need to filter out rows corresponding to those appearances from the table join. That is, to identify a specific player as a "pitcher", we’ll choose those players who played mostly as pitchers; in particular, we’ll take 100 games as an arbitrary threshold between pitchers and non-pitchers. The column G_p from the table join represents the numbers of games a player played as a pitcher; the desired filtering expression, then, is as follows:
pitchers = join[join.G_p > 100]
Next, we group the pitchers table based on a specific pair of columns (stored as a list cols) and then count them annually using a groupby with a count aggregation.
cols = [pitchers.inducted, pitchers.yearID]
grouped_pitchers = pitchers.groupby(cols).count()
The expression grouped_pitchers is still an Ibis TableExpr; as we've seen several times now, it can be realized as a Pandas DataFrame using the execute method. The resulting DataFrame's index can be set as a multi-index using the inducted and yearID columns.
df = grouped_pitchers.execute().set_index('inducted yearID'.split())
df
The dataframe df has counts of the number of pitchers who were (inducted index 'Y') and were not (inducted index 'N') inducted into the baseball Hall of Fame in a given year. We'll pull in all the relevant counts of inductees into a dataframe count_inducted_pitchers. Notice the use of the Pandas DataFrame.fillna method to assign 0s in rows appropriately (i.e., reflecting that no pitchers were inducted into the Hall of Fame in those years).
count_inducted_pitchers = df.loc['Y'].fillna(0).rename({'count':'Inducted pitchers'})
count_inducted_pitchers
The Pandas DataFrame & Series classes have a convenient plotting interface. We'll use a dictionary options to specify keyword arguments to tidy the final invokation of plot.bar.
options = dict(figsize=(15, 5), grid=True, legend=None)
count_inducted_pitchers.plot.bar(**options);
What next?¶
That's it! In future posts, we’ll explore other backends and visualize more Ibis objects. If you’d like to contribute to Ibis, please take a look at Ibis contributing guide and OpenTeams.