diff --git a/.travis.yml b/.travis.yml
index 1db80743..38f60163 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -14,6 +14,6 @@ services:
- postgresql
env:
global:
- - PGPORT=5432
+ - SB_TEST_PGPORT=5432
script:
- make test-travis
diff --git a/docs/.gitattributes b/docs/.gitattributes
index d9d68857..19c35d26 100644
--- a/docs/.gitattributes
+++ b/docs/.gitattributes
@@ -1,3 +1,2 @@
-*.ipynb filter=nbstripout
*.ipynb diff=ipynb
diff --git a/docs/index.rst b/docs/index.rst
index 7f27327c..6ef37a36 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -3,7 +3,7 @@
:hidden:
intro.Rmd
- intro_sql.Rmd
+ intro_sql.ipynb
.. toctree::
:caption: Core One-table Verbs
diff --git a/docs/intro_sql.Rmd b/docs/intro_sql.Rmd
index 8f4d3909..77c56131 100644
--- a/docs/intro_sql.Rmd
+++ b/docs/intro_sql.Rmd
@@ -12,38 +12,131 @@ jupyter:
name: python3
---
+```{python nbsphinx=hidden}
+import matplotlib.cbook
+
+import warnings
+import plotnine
+warnings.filterwarnings(module='plotnine*', action='ignore')
+warnings.filterwarnings(module='matplotlib*', action='ignore')
+
+# %matplotlib inline
+```
+
# Using to query SQL
+
+# Setting up
+
```{python}
-from sqlalchemy import create_engine
-from siuba.data import mtcars
import pandas as pd
+from siuba.tests.helpers import copy_to_sql
+from siuba import *
+from siuba.dply.vector import lag, desc, row_number
+from siuba.dply.string import str_c
-engine = create_engine('sqlite:///:memory:', echo=False)
+tv_ratings = pd.read_csv(
+ "https://raw.githubusercontent.com/rfordatascience/tidytuesday/master/data/2019/2019-01-08/IMDb_Economist_tv_ratings.csv",
+ parse_dates = ["date"]
+ )
-# note that mtcars is a pandas DataFrame
-mtcars.to_sql('mtcars', engine)
```
```{python}
-from siuba import *
-from siuba.sql import LazyTbl, show_query, collect
+tbl_ratings = copy_to_sql(tv_ratings, "tv_ratings", "postgresql://postgres:@localhost:5433/postgres")
-tbl_mtcars = LazyTbl(engine, 'mtcars')
```
```{python}
-tbl_mtcars
+tbl_ratings
+
```
+## Inspecting a single show
+
```{python}
-tbl_mtcars >> filter(_.hp > 250) >> collect()
+buffy = (tbl_ratings
+ >> filter(_.title == "Buffy the Vampire Slayer")
+ >> collect()
+ )
+
+buffy
+```
+
+```{python}
+buffy >> summarize(avg_rating = _.av_rating.mean())
```
+## Average rating per show, along with dates
+
```{python}
-(tbl_mtcars
- >> group_by(_.cyl)
- >> summarize(avg_mpg = _.mpg.mean())
+avg_ratings = (tbl_ratings
+ >> group_by(_.title)
+ >> summarize(
+ avg_rating = _.av_rating.mean(),
+ date_range = str_c(_.date.dt.year.max(), " - ", _.date.dt.year.min())
+ )
+ )
+
+avg_ratings
+```
+
+## Biggest changes in ratings between two seasons
+
+```{python}
+top_4_shifts = (tbl_ratings
+ >> group_by(_.title)
+ >> mutate(rating_shift = _.av_rating - lag(_.av_rating))
+ >> summarize(
+ max_shift = _.rating_shift.max()
+ )
+ >> arrange(-_.max_shift)
+ >> head(4)
+ )
+
+top_4_shifts
+```
+
+```{python}
+big_shift_series = (top_4_shifts
+ >> select(_.title)
+ >> inner_join(_, tbl_ratings, "title")
>> collect()
)
+
+from plotnine import *
+
+(big_shift_series
+ >> ggplot(aes("seasonNumber", "av_rating"))
+ + geom_point()
+ + geom_line()
+ + facet_wrap("~ title")
+ + labs(
+ title = "Seasons with Biggest Shifts in Ratings",
+ y = "Average rating",
+ x = "Season"
+ )
+ )
+```
+
+## Do we have full data for each season?
+
+```{python}
+mismatches = (tbl_ratings
+ >> arrange(_.title, _.seasonNumber)
+ >> group_by(_.title)
+ >> mutate(
+ row = row_number(_),
+ mismatch = _.row != _.seasonNumber
+ )
+ >> filter(_.mismatch.any())
+ >> ungroup()
+ )
+
+
+mismatches
+```
+
+```{python}
+mismatches >> distinct(_.title) >> count() >> collect()
```
diff --git a/docs/intro_sql.ipynb b/docs/intro_sql.ipynb
new file mode 100644
index 00000000..fb4712ea
--- /dev/null
+++ b/docs/intro_sql.ipynb
@@ -0,0 +1,877 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "metadata": {
+ "nbsphinx": "hidden"
+ },
+ "outputs": [],
+ "source": [
+ "import matplotlib.cbook\n",
+ "\n",
+ "import warnings\n",
+ "import plotnine\n",
+ "warnings.filterwarnings(module='plotnine*', action='ignore')\n",
+ "warnings.filterwarnings(module='matplotlib*', action='ignore')\n",
+ "\n",
+ "%matplotlib inline"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Using to query SQL"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Setting up"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import pandas as pd\n",
+ "from siuba.tests.helpers import copy_to_sql\n",
+ "from siuba import *\n",
+ "from siuba.dply.vector import lag, desc, row_number\n",
+ "from siuba.dply.string import str_c\n",
+ "\n",
+ "tv_ratings = pd.read_csv(\n",
+ " \"https://raw.githubusercontent.com/rfordatascience/tidytuesday/master/data/2019/2019-01-08/IMDb_Economist_tv_ratings.csv\",\n",
+ " parse_dates = [\"date\"]\n",
+ " )\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "tbl_ratings = copy_to_sql(tv_ratings, \"tv_ratings\", \"postgresql://postgres:@localhost:5433/postgres\")\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "
# Source: lazy query\n",
+ "# DB Conn: Engine(postgresql://postgres:***@localhost:5433/postgres)\n",
+ "# Preview:\n",
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " titleId | \n",
+ " seasonNumber | \n",
+ " title | \n",
+ " date | \n",
+ " av_rating | \n",
+ " share | \n",
+ " genres | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 | \n",
+ " tt2879552 | \n",
+ " 1 | \n",
+ " 11.22.63 | \n",
+ " 2016-03-10 | \n",
+ " 8.4890 | \n",
+ " 0.51 | \n",
+ " Drama,Mystery,Sci-Fi | \n",
+ "
\n",
+ " \n",
+ " 1 | \n",
+ " tt3148266 | \n",
+ " 1 | \n",
+ " 12 Monkeys | \n",
+ " 2015-02-27 | \n",
+ " 8.3407 | \n",
+ " 0.46 | \n",
+ " Adventure,Drama,Mystery | \n",
+ "
\n",
+ " \n",
+ " 2 | \n",
+ " tt3148266 | \n",
+ " 2 | \n",
+ " 12 Monkeys | \n",
+ " 2016-05-30 | \n",
+ " 8.8196 | \n",
+ " 0.25 | \n",
+ " Adventure,Drama,Mystery | \n",
+ "
\n",
+ " \n",
+ " 3 | \n",
+ " tt3148266 | \n",
+ " 3 | \n",
+ " 12 Monkeys | \n",
+ " 2017-05-19 | \n",
+ " 9.0369 | \n",
+ " 0.19 | \n",
+ " Adventure,Drama,Mystery | \n",
+ "
\n",
+ " \n",
+ " 4 | \n",
+ " tt3148266 | \n",
+ " 4 | \n",
+ " 12 Monkeys | \n",
+ " 2018-06-26 | \n",
+ " 9.1363 | \n",
+ " 0.38 | \n",
+ " Adventure,Drama,Mystery | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
# .. may have more rows
"
+ ],
+ "text/plain": [
+ "# Source: lazy query\n",
+ "# DB Conn: Engine(postgresql://postgres:***@localhost:5433/postgres)\n",
+ "# Preview:\n",
+ " titleId seasonNumber title date av_rating share \\\n",
+ "0 tt2879552 1 11.22.63 2016-03-10 8.4890 0.51 \n",
+ "1 tt3148266 1 12 Monkeys 2015-02-27 8.3407 0.46 \n",
+ "2 tt3148266 2 12 Monkeys 2016-05-30 8.8196 0.25 \n",
+ "3 tt3148266 3 12 Monkeys 2017-05-19 9.0369 0.19 \n",
+ "4 tt3148266 4 12 Monkeys 2018-06-26 9.1363 0.38 \n",
+ "\n",
+ " genres \n",
+ "0 Drama,Mystery,Sci-Fi \n",
+ "1 Adventure,Drama,Mystery \n",
+ "2 Adventure,Drama,Mystery \n",
+ "3 Adventure,Drama,Mystery \n",
+ "4 Adventure,Drama,Mystery \n",
+ "# .. may have more rows"
+ ]
+ },
+ "execution_count": 4,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "tbl_ratings\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Inspecting a single show"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " titleId | \n",
+ " seasonNumber | \n",
+ " title | \n",
+ " date | \n",
+ " av_rating | \n",
+ " share | \n",
+ " genres | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 | \n",
+ " tt0118276 | \n",
+ " 1 | \n",
+ " Buffy the Vampire Slayer | \n",
+ " 1997-04-14 | \n",
+ " 7.9629 | \n",
+ " 11.70 | \n",
+ " Action,Drama,Fantasy | \n",
+ "
\n",
+ " \n",
+ " 1 | \n",
+ " tt0118276 | \n",
+ " 2 | \n",
+ " Buffy the Vampire Slayer | \n",
+ " 1997-12-31 | \n",
+ " 8.4191 | \n",
+ " 19.41 | \n",
+ " Action,Drama,Fantasy | \n",
+ "
\n",
+ " \n",
+ " 2 | \n",
+ " tt0118276 | \n",
+ " 3 | \n",
+ " Buffy the Vampire Slayer | \n",
+ " 1999-01-29 | \n",
+ " 8.6233 | \n",
+ " 17.12 | \n",
+ " Action,Drama,Fantasy | \n",
+ "
\n",
+ " \n",
+ " 3 | \n",
+ " tt0118276 | \n",
+ " 4 | \n",
+ " Buffy the Vampire Slayer | \n",
+ " 2000-01-19 | \n",
+ " 8.2205 | \n",
+ " 16.19 | \n",
+ " Action,Drama,Fantasy | \n",
+ "
\n",
+ " \n",
+ " 4 | \n",
+ " tt0118276 | \n",
+ " 5 | \n",
+ " Buffy the Vampire Slayer | \n",
+ " 2001-01-12 | \n",
+ " 8.3028 | \n",
+ " 11.99 | \n",
+ " Action,Drama,Fantasy | \n",
+ "
\n",
+ " \n",
+ " 5 | \n",
+ " tt0118276 | \n",
+ " 6 | \n",
+ " Buffy the Vampire Slayer | \n",
+ " 2002-01-29 | \n",
+ " 8.1008 | \n",
+ " 8.45 | \n",
+ " Action,Drama,Fantasy | \n",
+ "
\n",
+ " \n",
+ " 6 | \n",
+ " tt0118276 | \n",
+ " 7 | \n",
+ " Buffy the Vampire Slayer | \n",
+ " 2003-01-18 | \n",
+ " 8.0460 | \n",
+ " 9.89 | \n",
+ " Action,Drama,Fantasy | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " titleId seasonNumber title date av_rating \\\n",
+ "0 tt0118276 1 Buffy the Vampire Slayer 1997-04-14 7.9629 \n",
+ "1 tt0118276 2 Buffy the Vampire Slayer 1997-12-31 8.4191 \n",
+ "2 tt0118276 3 Buffy the Vampire Slayer 1999-01-29 8.6233 \n",
+ "3 tt0118276 4 Buffy the Vampire Slayer 2000-01-19 8.2205 \n",
+ "4 tt0118276 5 Buffy the Vampire Slayer 2001-01-12 8.3028 \n",
+ "5 tt0118276 6 Buffy the Vampire Slayer 2002-01-29 8.1008 \n",
+ "6 tt0118276 7 Buffy the Vampire Slayer 2003-01-18 8.0460 \n",
+ "\n",
+ " share genres \n",
+ "0 11.70 Action,Drama,Fantasy \n",
+ "1 19.41 Action,Drama,Fantasy \n",
+ "2 17.12 Action,Drama,Fantasy \n",
+ "3 16.19 Action,Drama,Fantasy \n",
+ "4 11.99 Action,Drama,Fantasy \n",
+ "5 8.45 Action,Drama,Fantasy \n",
+ "6 9.89 Action,Drama,Fantasy "
+ ]
+ },
+ "execution_count": 5,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "buffy = (tbl_ratings\n",
+ " >> filter(_.title == \"Buffy the Vampire Slayer\")\n",
+ " >> collect()\n",
+ " )\n",
+ "\n",
+ "buffy"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " avg_rating | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 | \n",
+ " 8.239343 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " avg_rating\n",
+ "0 8.239343"
+ ]
+ },
+ "execution_count": 6,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "buffy >> summarize(avg_rating = _.av_rating.mean())"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Average rating per show, along with dates"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "# Source: lazy query\n",
+ "# DB Conn: Engine(postgresql://postgres:***@localhost:5433/postgres)\n",
+ "# Preview:\n",
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " title | \n",
+ " avg_rating | \n",
+ " date_range | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 | \n",
+ " Friends from College | \n",
+ " 6.875100 | \n",
+ " 2017 - 2017 | \n",
+ "
\n",
+ " \n",
+ " 1 | \n",
+ " Better Things | \n",
+ " 8.133150 | \n",
+ " 2017 - 2016 | \n",
+ "
\n",
+ " \n",
+ " 2 | \n",
+ " How to Get Away with Murder | \n",
+ " 8.762340 | \n",
+ " 2018 - 2014 | \n",
+ "
\n",
+ " \n",
+ " 3 | \n",
+ " Dexter | \n",
+ " 8.582400 | \n",
+ " 2013 - 2006 | \n",
+ "
\n",
+ " \n",
+ " 4 | \n",
+ " Queen of the South | \n",
+ " 8.574733 | \n",
+ " 2018 - 2016 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
# .. may have more rows
"
+ ],
+ "text/plain": [
+ "# Source: lazy query\n",
+ "# DB Conn: Engine(postgresql://postgres:***@localhost:5433/postgres)\n",
+ "# Preview:\n",
+ " title avg_rating date_range\n",
+ "0 Friends from College 6.875100 2017 - 2017\n",
+ "1 Better Things 8.133150 2017 - 2016\n",
+ "2 How to Get Away with Murder 8.762340 2018 - 2014\n",
+ "3 Dexter 8.582400 2013 - 2006\n",
+ "4 Queen of the South 8.574733 2018 - 2016\n",
+ "# .. may have more rows"
+ ]
+ },
+ "execution_count": 7,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "avg_ratings = (tbl_ratings \n",
+ " >> group_by(_.title)\n",
+ " >> summarize(\n",
+ " avg_rating = _.av_rating.mean(),\n",
+ " date_range = str_c(_.date.dt.year.max(), \" - \", _.date.dt.year.min())\n",
+ " )\n",
+ " )\n",
+ "\n",
+ "avg_ratings"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Biggest changes in ratings between two seasons"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "# Source: lazy query\n",
+ "# DB Conn: Engine(postgresql://postgres:***@localhost:5433/postgres)\n",
+ "# Preview:\n",
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " title | \n",
+ " max_shift | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 | \n",
+ " Third Watch | \n",
+ " 4.8500 | \n",
+ "
\n",
+ " \n",
+ " 1 | \n",
+ " Are You Afraid of the Dark? | \n",
+ " 2.3430 | \n",
+ "
\n",
+ " \n",
+ " 2 | \n",
+ " Lethal Weapon | \n",
+ " 2.3070 | \n",
+ "
\n",
+ " \n",
+ " 3 | \n",
+ " Law & Order: Special Victims Unit | \n",
+ " 2.0508 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
# .. may have more rows
"
+ ],
+ "text/plain": [
+ "# Source: lazy query\n",
+ "# DB Conn: Engine(postgresql://postgres:***@localhost:5433/postgres)\n",
+ "# Preview:\n",
+ " title max_shift\n",
+ "0 Third Watch 4.8500\n",
+ "1 Are You Afraid of the Dark? 2.3430\n",
+ "2 Lethal Weapon 2.3070\n",
+ "3 Law & Order: Special Victims Unit 2.0508\n",
+ "# .. may have more rows"
+ ]
+ },
+ "execution_count": 8,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "top_4_shifts = (tbl_ratings\n",
+ " >> group_by(_.title)\n",
+ " >> mutate(rating_shift = _.av_rating - lag(_.av_rating))\n",
+ " >> summarize(\n",
+ " max_shift = _.rating_shift.max()\n",
+ " )\n",
+ " >> arrange(-_.max_shift)\n",
+ " >> head(4)\n",
+ " )\n",
+ "\n",
+ "top_4_shifts"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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v/9OQEDAVQ2JiPgONSciAvT80Y+MjHQb6+zspKmpyXXJJDk5meHDh9Pc3MzixYt73VZnZ6fr7MVfqqqqel0/PDycVatWsWfPHlcjNGfOHKCn0Vm9erVr3e3bt7velSMivknNiYgAkJCQQFpaGikpKYwZM4Zff/2VsrIy7HY7a9euBXoun2zatIm8vDyam5uZN28eQUFBnDp1iurqanbs2MHw4cNZsGABr7/+OmVlZdx6661UVlZy8uRJt9d7/vnn+eOPP5g3bx4REREcO3aMmpoacnJyAEhMTGTJkiXk5OTQ2dnJbbfdRmVlJQcPHqS6unrA94+IDBw1JyICQGFhIbt27SInJ4eWlhbCw8NJTExk79693Hvvva711q1bx7hx4yguLqa0tBSLxUJMTAwPPfSQ62zJhg0baGlpYcOGDQCkp6dTUlJCWlqaazt33XUXNpuNbdu2ceHCBcaPH09ubi7PPvusa53KykrWr1/PSy+9RGtrK7fffjsff/yx23ZExPfoE2JFRETEVPRuHRERETEVNSciIiJiKmpORERExFTUnIiIiIipqDkRERERU1FzIiIiIqai5kRERERMRc2JiIiImIqaExERETEVNSciIiJiKmpORERExFT+AzRQwHYwXvUhAAAAAElFTkSuQmCC\n",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 9,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "big_shift_series = (top_4_shifts\n",
+ " >> select(_.title)\n",
+ " >> inner_join(_, tbl_ratings, \"title\")\n",
+ " >> collect()\n",
+ " )\n",
+ "\n",
+ "from plotnine import *\n",
+ "\n",
+ "(big_shift_series\n",
+ " >> ggplot(aes(\"seasonNumber\", \"av_rating\"))\n",
+ " + geom_point()\n",
+ " + geom_line()\n",
+ " + facet_wrap(\"~ title\")\n",
+ " + labs(\n",
+ " title = \"Seasons with Biggest Shifts in Ratings\",\n",
+ " y = \"Average rating\",\n",
+ " x = \"Season\"\n",
+ " )\n",
+ " )"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Do we have full data for each season?"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "# Source: lazy query\n",
+ "# DB Conn: Engine(postgresql://postgres:***@localhost:5433/postgres)\n",
+ "# Preview:\n",
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " title | \n",
+ " titleId | \n",
+ " seasonNumber | \n",
+ " date | \n",
+ " av_rating | \n",
+ " share | \n",
+ " genres | \n",
+ " row | \n",
+ " mismatch | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 | \n",
+ " 7th Heaven | \n",
+ " tt0115083 | \n",
+ " 1 | \n",
+ " 1996-08-26 | \n",
+ " 7.700 | \n",
+ " 0.10 | \n",
+ " Drama,Family,Romance | \n",
+ " 1 | \n",
+ " False | \n",
+ "
\n",
+ " \n",
+ " 1 | \n",
+ " 7th Heaven | \n",
+ " tt0115083 | \n",
+ " 10 | \n",
+ " 2006-05-08 | \n",
+ " 6.300 | \n",
+ " 0.01 | \n",
+ " Drama,Family,Romance | \n",
+ " 2 | \n",
+ " True | \n",
+ "
\n",
+ " \n",
+ " 2 | \n",
+ " ABC Afterschool Specials | \n",
+ " tt0202179 | \n",
+ " 25 | \n",
+ " 1996-09-12 | \n",
+ " 3.300 | \n",
+ " 0.10 | \n",
+ " Adventure,Comedy,Drama | \n",
+ " 1 | \n",
+ " True | \n",
+ "
\n",
+ " \n",
+ " 3 | \n",
+ " American Gothic | \n",
+ " tt5257744 | \n",
+ " 1 | \n",
+ " 2016-08-05 | \n",
+ " 7.535 | \n",
+ " 0.07 | \n",
+ " Crime,Drama,Mystery | \n",
+ " 1 | \n",
+ " False | \n",
+ "
\n",
+ " \n",
+ " 4 | \n",
+ " American Gothic | \n",
+ " tt0111880 | \n",
+ " 1 | \n",
+ " 1995-09-22 | \n",
+ " 7.800 | \n",
+ " 0.08 | \n",
+ " Drama,Horror,Thriller | \n",
+ " 2 | \n",
+ " True | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
# .. may have more rows
"
+ ],
+ "text/plain": [
+ "# Source: lazy query\n",
+ "# DB Conn: Engine(postgresql://postgres:***@localhost:5433/postgres)\n",
+ "# Preview:\n",
+ " title titleId seasonNumber date av_rating \\\n",
+ "0 7th Heaven tt0115083 1 1996-08-26 7.700 \n",
+ "1 7th Heaven tt0115083 10 2006-05-08 6.300 \n",
+ "2 ABC Afterschool Specials tt0202179 25 1996-09-12 3.300 \n",
+ "3 American Gothic tt5257744 1 2016-08-05 7.535 \n",
+ "4 American Gothic tt0111880 1 1995-09-22 7.800 \n",
+ "\n",
+ " share genres row mismatch \n",
+ "0 0.10 Drama,Family,Romance 1 False \n",
+ "1 0.01 Drama,Family,Romance 2 True \n",
+ "2 0.10 Adventure,Comedy,Drama 1 True \n",
+ "3 0.07 Crime,Drama,Mystery 1 False \n",
+ "4 0.08 Drama,Horror,Thriller 2 True \n",
+ "# .. may have more rows"
+ ]
+ },
+ "execution_count": 10,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "mismatches = (tbl_ratings\n",
+ " >> arrange(_.title, _.seasonNumber)\n",
+ " >> group_by(_.title)\n",
+ " >> mutate(\n",
+ " row = row_number(_),\n",
+ " mismatch = _.row != _.seasonNumber\n",
+ " )\n",
+ " >> filter(_.mismatch.any())\n",
+ " >> ungroup()\n",
+ " )\n",
+ "\n",
+ "\n",
+ "mismatches"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " n | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 | \n",
+ " 54 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " n\n",
+ "0 54"
+ ]
+ },
+ "execution_count": 11,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "mismatches >> distinct(_.title) >> count() >> collect()"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.6.7"
+ },
+ "toc": {
+ "base_numbering": 1,
+ "nav_menu": {},
+ "number_sections": true,
+ "sideBar": true,
+ "skip_h1_title": false,
+ "title_cell": "Table of Contents",
+ "title_sidebar": "Contents",
+ "toc_cell": false,
+ "toc_position": {},
+ "toc_section_display": true,
+ "toc_window_display": false
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/examples/examples-postgres.ipynb b/examples/examples-postgres.ipynb
index bfb3e097..7ea34ac7 100644
--- a/examples/examples-postgres.ipynb
+++ b/examples/examples-postgres.ipynb
@@ -17,7 +17,7 @@
{
"data": {
"text/plain": [
- ""
+ ""
]
},
"execution_count": 1,
@@ -87,7 +87,7 @@
"name": "stdout",
"output_type": "stream",
"text": [
- "SELECT anon_1.id, anon_1.user_id, anon_1.email_address, anon_1.num, anon_1.anon_2 \n",
+ "SELECT anon_1.user_id, anon_1.id, anon_1.email_address, anon_1.num \n",
"FROM (SELECT id, user_id, email_address, num, min(anon_3.id) OVER (PARTITION BY anon_3.user_id) AS anon_2 \n",
"FROM (SELECT id, user_id, email_address, dense_rank() OVER (PARTITION BY addresses.user_id ORDER BY addresses.id) AS num \n",
"FROM addresses) AS anon_3) AS anon_1 \n",
@@ -115,29 +115,27 @@
" \n",
" \n",
" | \n",
- " id | \n",
" user_id | \n",
+ " id | \n",
" email_address | \n",
" num | \n",
- " anon_2 | \n",
"
\n",
" \n",
" \n",
" \n",
" 0 | \n",
- " 2 | \n",
" 1 | \n",
+ " 2 | \n",
" jack@msn.com | \n",
" 2 | \n",
- " 1 | \n",
"
\n",
" \n",
"\n",
""
],
"text/plain": [
- " id user_id email_address num anon_2\n",
- "0 2 1 jack@msn.com 2 1"
+ " user_id id email_address num\n",
+ "0 1 2 jack@msn.com 2"
]
},
"execution_count": 2,
@@ -180,6 +178,26 @@
"#tbl_addresses >> group_by(_, \"user_id\") >> mutate(_, num = dense_rank(_.id)) >> show_query(_)"
]
},
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ ">"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "sql.functions.sum().over"
+ ]
+ },
{
"cell_type": "markdown",
"metadata": {},
@@ -189,7 +207,7 @@
},
{
"cell_type": "code",
- "execution_count": 3,
+ "execution_count": 4,
"metadata": {},
"outputs": [
{
@@ -210,7 +228,7 @@
},
{
"cell_type": "code",
- "execution_count": 4,
+ "execution_count": 5,
"metadata": {},
"outputs": [
{
@@ -232,7 +250,7 @@
},
{
"cell_type": "code",
- "execution_count": 5,
+ "execution_count": 6,
"metadata": {},
"outputs": [
{
@@ -264,7 +282,7 @@
},
{
"cell_type": "code",
- "execution_count": 6,
+ "execution_count": 7,
"metadata": {},
"outputs": [
{
@@ -288,14 +306,14 @@
},
{
"cell_type": "code",
- "execution_count": 7,
+ "execution_count": 8,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
- "SELECT anon_1.id, anon_1.user_id, anon_1.email_address \n",
+ "SELECT anon_1.user_id, anon_1.id, anon_1.email_address \n",
"FROM (SELECT anon_2.id AS id, anon_2.user_id AS user_id, anon_2.email_address AS email_address \n",
"FROM (SELECT addresses.id AS id, addresses.user_id AS user_id, addresses.email_address AS email_address \n",
"FROM addresses) AS anon_2) AS anon_1 \n",
@@ -313,18 +331,18 @@
},
{
"cell_type": "code",
- "execution_count": 8,
+ "execution_count": 9,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
- "SELECT anon_1.id, anon_1.user_id, anon_1.email_address, anon_1.anon_2 \n",
- "FROM (SELECT anon_3.id AS id, anon_3.user_id AS user_id, anon_3.email_address AS email_address, dense_rank() OVER (PARTITION BY anon_3.user_id ORDER BY anon_3.id) AS anon_2 \n",
+ "SELECT anon_1.user_id, anon_1.id, anon_1.email_address \n",
+ "FROM (SELECT anon_2.id AS id, anon_2.user_id AS user_id, anon_2.email_address AS email_address, dense_rank() OVER (PARTITION BY anon_2.user_id ORDER BY anon_2.id) AS anon_3 \n",
"FROM (SELECT addresses.id AS id, addresses.user_id AS user_id, addresses.email_address AS email_address \n",
- "FROM addresses) AS anon_3) AS anon_1 \n",
- "WHERE anon_1.anon_2 > 1\n"
+ "FROM addresses) AS anon_2) AS anon_1 \n",
+ "WHERE anon_1.anon_3 > 1\n"
]
},
{
@@ -348,38 +366,35 @@
" \n",
" \n",
" | \n",
- " id | \n",
" user_id | \n",
+ " id | \n",
" email_address | \n",
- " anon_2 | \n",
"
\n",
" \n",
" \n",
" \n",
" 0 | \n",
- " 2 | \n",
" 1 | \n",
- " jack@msn.com | \n",
" 2 | \n",
+ " jack@msn.com | \n",
"
\n",
" \n",
" 1 | \n",
- " 4 | \n",
" 2 | \n",
+ " 4 | \n",
" wendy@aol.com | \n",
- " 2 | \n",
"
\n",
" \n",
"\n",
""
],
"text/plain": [
- " id user_id email_address anon_2\n",
- "0 2 1 jack@msn.com 2\n",
- "1 4 2 wendy@aol.com 2"
+ " user_id id email_address\n",
+ "0 1 2 jack@msn.com\n",
+ "1 2 4 wendy@aol.com"
]
},
- "execution_count": 8,
+ "execution_count": 9,
"metadata": {},
"output_type": "execute_result"
}
@@ -404,7 +419,7 @@
},
{
"cell_type": "code",
- "execution_count": 9,
+ "execution_count": 10,
"metadata": {},
"outputs": [
{
@@ -461,7 +476,7 @@
"1 1 1.5"
]
},
- "execution_count": 9,
+ "execution_count": 10,
"metadata": {},
"output_type": "execute_result"
}
@@ -479,15 +494,16 @@
},
{
"cell_type": "code",
- "execution_count": 10,
+ "execution_count": 11,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
- "SELECT avg(addresses.id + 1) AS m_id \n",
- "FROM addresses\n"
+ "SELECT avg(anon_1.id2) AS m_id \n",
+ "FROM (SELECT addresses.id AS id, addresses.user_id AS user_id, addresses.email_address AS email_address, addresses.id + 1 AS id2 \n",
+ "FROM addresses) AS anon_1\n"
]
}
],
@@ -504,7 +520,7 @@
},
{
"cell_type": "code",
- "execution_count": 11,
+ "execution_count": 12,
"metadata": {},
"outputs": [
{
@@ -566,7 +582,7 @@
"1 2 0 2"
]
},
- "execution_count": 11,
+ "execution_count": 12,
"metadata": {},
"output_type": "execute_result"
}
@@ -591,16 +607,16 @@
},
{
"cell_type": "code",
- "execution_count": 12,
+ "execution_count": 13,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
- "SELECT anon_1.id AS id_x, anon_1.user_id, anon_1.email_address, anon_2.id AS id_y, anon_2.name, anon_2.fullname \n",
+ "SELECT anon_1.id, anon_1.user_id, anon_1.email_address, anon_2.fullname, anon_2.name \n",
"FROM (SELECT addresses.id AS id, addresses.user_id AS user_id, addresses.email_address AS email_address \n",
- "FROM addresses) AS anon_1 JOIN (SELECT users.id AS id, users.name AS name, users.fullname AS fullname \n",
+ "FROM addresses) AS anon_1 LEFT OUTER JOIN (SELECT users.id AS id, users.name AS name, users.fullname AS fullname \n",
"FROM users) AS anon_2 ON anon_1.user_id = anon_2.id\n"
]
},
@@ -625,12 +641,11 @@
" \n",
" \n",
" | \n",
- " id_x | \n",
+ " id | \n",
" user_id | \n",
" email_address | \n",
- " id_y | \n",
- " name | \n",
" fullname | \n",
+ " name | \n",
"
\n",
" \n",
" \n",
@@ -639,50 +654,46 @@
" 1 | \n",
" 1 | \n",
" jack@yahoo.com | \n",
- " 1 | \n",
- " jack | \n",
" Jack Jones | \n",
+ " jack | \n",
" \n",
" \n",
" 1 | \n",
" 2 | \n",
" 1 | \n",
" jack@msn.com | \n",
- " 1 | \n",
- " jack | \n",
" Jack Jones | \n",
+ " jack | \n",
"
\n",
" \n",
" 2 | \n",
" 3 | \n",
" 2 | \n",
" www@www.org | \n",
- " 2 | \n",
- " wendy | \n",
" Wendy Williams | \n",
+ " wendy | \n",
"
\n",
" \n",
" 3 | \n",
" 4 | \n",
" 2 | \n",
" wendy@aol.com | \n",
- " 2 | \n",
- " wendy | \n",
" Wendy Williams | \n",
+ " wendy | \n",
"
\n",
" \n",
"\n",
""
],
"text/plain": [
- " id_x user_id email_address id_y name fullname\n",
- "0 1 1 jack@yahoo.com 1 jack Jack Jones\n",
- "1 2 1 jack@msn.com 1 jack Jack Jones\n",
- "2 3 2 www@www.org 2 wendy Wendy Williams\n",
- "3 4 2 wendy@aol.com 2 wendy Wendy Williams"
+ " id user_id email_address fullname name\n",
+ "0 1 1 jack@yahoo.com Jack Jones jack\n",
+ "1 2 1 jack@msn.com Jack Jones jack\n",
+ "2 3 2 www@www.org Wendy Williams wendy\n",
+ "3 4 2 wendy@aol.com Wendy Williams wendy"
]
},
- "execution_count": 12,
+ "execution_count": 13,
"metadata": {},
"output_type": "execute_result"
}
@@ -708,7 +719,7 @@
},
{
"cell_type": "code",
- "execution_count": 13,
+ "execution_count": 14,
"metadata": {},
"outputs": [
{
@@ -787,7 +798,7 @@
"3 4 2 wendy@aol.com 1"
]
},
- "execution_count": 13,
+ "execution_count": 14,
"metadata": {},
"output_type": "execute_result"
}
@@ -811,7 +822,7 @@
},
{
"cell_type": "code",
- "execution_count": 14,
+ "execution_count": 15,
"metadata": {},
"outputs": [
{
@@ -867,7 +878,7 @@
"0 1 1 jack@yahoo.com"
]
},
- "execution_count": 14,
+ "execution_count": 15,
"metadata": {},
"output_type": "execute_result"
}
@@ -892,7 +903,7 @@
},
{
"cell_type": "code",
- "execution_count": 15,
+ "execution_count": 16,
"metadata": {},
"outputs": [
{
@@ -971,7 +982,7 @@
"3 4 2 wendy@aol.com 0"
]
},
- "execution_count": 15,
+ "execution_count": 16,
"metadata": {},
"output_type": "execute_result"
}
@@ -997,14 +1008,14 @@
},
{
"cell_type": "code",
- "execution_count": 16,
+ "execution_count": 17,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"█─'__call__'\n",
- "├─\n",
+ "├─\n",
"├─_\n",
"└─█─''\n",
" └─█─'__call__'\n",
@@ -1012,7 +1023,7 @@
" └─{_.id > 1: 'yeah', True: 'no'}"
]
},
- "execution_count": 16,
+ "execution_count": 17,
"metadata": {},
"output_type": "execute_result"
}
@@ -1031,7 +1042,7 @@
},
{
"cell_type": "code",
- "execution_count": 17,
+ "execution_count": 18,
"metadata": {},
"outputs": [
{
@@ -1059,7 +1070,7 @@
},
{
"cell_type": "code",
- "execution_count": 18,
+ "execution_count": 19,
"metadata": {},
"outputs": [
{
@@ -1127,7 +1138,7 @@
"2 3 2 www@www.org"
]
},
- "execution_count": 18,
+ "execution_count": 19,
"metadata": {},
"output_type": "execute_result"
}
@@ -1149,7 +1160,7 @@
},
{
"cell_type": "code",
- "execution_count": 19,
+ "execution_count": 20,
"metadata": {},
"outputs": [
{
@@ -1223,7 +1234,7 @@
"3 4 2 wendy@aol.com"
]
},
- "execution_count": 19,
+ "execution_count": 20,
"metadata": {},
"output_type": "execute_result"
}
@@ -1245,7 +1256,7 @@
},
{
"cell_type": "code",
- "execution_count": 20,
+ "execution_count": 21,
"metadata": {},
"outputs": [
{
@@ -1302,7 +1313,7 @@
"1 1 2"
]
},
- "execution_count": 20,
+ "execution_count": 21,
"metadata": {},
"output_type": "execute_result"
}
@@ -1317,7 +1328,7 @@
},
{
"cell_type": "code",
- "execution_count": 21,
+ "execution_count": 22,
"metadata": {},
"outputs": [
{
@@ -1386,7 +1397,7 @@
"3 wendy@aol.com 1"
]
},
- "execution_count": 21,
+ "execution_count": 22,
"metadata": {},
"output_type": "execute_result"
}
@@ -1416,7 +1427,7 @@
},
{
"cell_type": "code",
- "execution_count": 22,
+ "execution_count": 23,
"metadata": {},
"outputs": [
{
@@ -1465,7 +1476,7 @@
"1 1 2"
]
},
- "execution_count": 22,
+ "execution_count": 23,
"metadata": {},
"output_type": "execute_result"
}
@@ -1495,7 +1506,7 @@
},
{
"cell_type": "code",
- "execution_count": 23,
+ "execution_count": 24,
"metadata": {},
"outputs": [
{
@@ -1518,6 +1529,55 @@
"## SQL escapes"
]
},
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Window functions"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 25,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "SELECT addresses.id, addresses.user_id, addresses.email_address, sum(addresses.user_id) OVER (ORDER BY addresses.id DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cumsum \n",
+ "FROM addresses ORDER BY addresses.id DESC, cumsum\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "# Source: lazy query\n",
+ "# DB Conn: Engine(postgresql://postgres:***@localhost:5433/postgres)\n",
+ "# Preview:\n",
+ " id user_id email_address cumsum\n",
+ "0 4 2 wendy@aol.com 2\n",
+ "1 3 2 www@www.org 4\n",
+ "2 2 1 jack@msn.com 5\n",
+ "3 1 1 jack@yahoo.com 6\n",
+ "# .. may have more rows"
+ ]
+ },
+ "execution_count": 25,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "from siuba.dply.vector import desc\n",
+ "(tbl_addresses\n",
+ " >> arrange(_.id.desc())\n",
+ " >> mutate(cumsum = _.user_id.cumsum())\n",
+ " >> arrange(_.cumsum)\n",
+ " >> show_query()\n",
+ " )"
+ ]
+ },
{
"cell_type": "markdown",
"metadata": {},
@@ -1534,7 +1594,7 @@
},
{
"cell_type": "code",
- "execution_count": 24,
+ "execution_count": 26,
"metadata": {},
"outputs": [
{
@@ -1613,7 +1673,7 @@
"3 4 2 wendy@aol.com 4.0"
]
},
- "execution_count": 24,
+ "execution_count": 26,
"metadata": {},
"output_type": "execute_result"
}
@@ -1635,7 +1695,7 @@
},
{
"cell_type": "code",
- "execution_count": 25,
+ "execution_count": 27,
"metadata": {},
"outputs": [
{
@@ -1698,7 +1758,7 @@
"1 2 wendy Wendy Williams 3"
]
},
- "execution_count": 25,
+ "execution_count": 27,
"metadata": {},
"output_type": "execute_result"
}
@@ -1727,7 +1787,7 @@
},
{
"cell_type": "code",
- "execution_count": 26,
+ "execution_count": 28,
"metadata": {},
"outputs": [
{
@@ -1790,7 +1850,7 @@
"1 2 wendy Wendy Williams 3"
]
},
- "execution_count": 26,
+ "execution_count": 28,
"metadata": {},
"output_type": "execute_result"
}
@@ -1818,7 +1878,7 @@
},
{
"cell_type": "code",
- "execution_count": 27,
+ "execution_count": 29,
"metadata": {},
"outputs": [
{
@@ -1833,7 +1893,7 @@
"# .. may have more rows"
]
},
- "execution_count": 27,
+ "execution_count": 29,
"metadata": {},
"output_type": "execute_result"
}
diff --git a/requirements.txt b/requirements.txt
index cb3bd17b..1507e4ec 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,10 +2,12 @@ numpy==1.16.1
pandas==0.24.1
python-dateutil==2.8.0
pytz==2018.9
+scipy==1.2.1
six==1.12.0
SQLAlchemy==1.2.17
nbval==0.9.1
# tests
+pytest==4.4.2
psycopg2==2.8.2
# only used for iris dataset
scikit-learn==0.20.2
@@ -13,3 +15,5 @@ scikit-learn==0.20.2
nbsphinx==0.4.2
jupytext==1.1.1
gapminder==0.1
+matplotlib==3.1.0
+plotnine==0.5.1
diff --git a/siuba/dply/string.py b/siuba/dply/string.py
new file mode 100644
index 00000000..b4099868
--- /dev/null
+++ b/siuba/dply/string.py
@@ -0,0 +1,33 @@
+import pandas as pd
+import numpy as np
+from functools import singledispatch
+import itertools
+
+from ..siu import Symbolic, create_sym_call,Call
+
+
+def register_symbolic(f):
+ # TODO: don't use singledispatch if it has already been done
+ f = singledispatch(f)
+ @f.register(Symbolic)
+ def _dispatch_symbol(__data, *args, **kwargs):
+ return create_sym_call(f, __data.source, *args, **kwargs)
+
+ return f
+
+def _coerce_to_str(x):
+ if isinstance(x, (pd.Series, np.ndarray)):
+ return x.astype(str)
+ elif not np.ndim(x) < 2:
+ raise ValueError("np.ndim must be less than 2, but is %s" %np.ndim(x))
+
+ return pd.Series(x, dtype = str)
+
+
+@register_symbolic
+def str_c(x, *args, sep = "", collapse = None):
+ all_args = itertools.chain([x], args)
+ strings = list(map(_coerce_to_str, all_args))
+
+ return np.sum(strings, axis = 0)
+
diff --git a/siuba/dply/vector.py b/siuba/dply/vector.py
index 54a07319..dd000e4c 100644
--- a/siuba/dply/vector.py
+++ b/siuba/dply/vector.py
@@ -32,7 +32,7 @@ def cummean(x):
@register_symbolic
def desc(x):
- NotImplementedError("Use minus sign in arrange instead (e.g. -_.somecol)")
+ return x.sort_values()
@register_symbolic
@@ -61,7 +61,7 @@ def row_number(x):
n = x.shape[0]
else:
n = len(x)
- return np.arange(n)
+ return np.arange(1, n + 1)
@register_symbolic
@@ -91,7 +91,7 @@ def lead(x, n = 1, default = None):
@register_symbolic
-def lag():
+def lag(x, n = 1, default = None):
res = x.shift(n)
if default is not None:
diff --git a/siuba/dply/verbs.py b/siuba/dply/verbs.py
index 60e6787a..b5b2fde4 100644
--- a/siuba/dply/verbs.py
+++ b/siuba/dply/verbs.py
@@ -19,9 +19,10 @@
"nest", "unnest",
"expand", "complete",
# Joins ----
- "join", "inner_join", "left_join", "right_join", "semi_join", "full_join",
+ "join", "inner_join", "full_join", "left_join", "right_join", "semi_join", "anti_join",
# TODO: move to vectors
"if_else", "case_when",
+ "collect", "show_query"
)
__all__ = [*DPLY_FUNCTIONS, "Pipeable", "pipe"]
@@ -206,6 +207,20 @@ def raise_type_error(f):
types = ", ".join(map(str, f.registry.keys()))
))
+# Collect and show_query =========
+
+@pipe_no_args
+@singledispatch2((DataFrame, DataFrameGroupBy))
+def collect(__data, *args, **kwargs):
+ # simply return DataFrame, since requires no execution
+ return __data
+
+
+@pipe_no_args
+@singledispatch2((DataFrame, DataFrameGroupBy))
+def show_query(__data, simplify = False):
+ print("No query to show for a DataFrame")
+ return __data
# Mutate ======================================================================
@@ -538,6 +553,11 @@ def var_create(*args):
@singledispatch2(DataFrame)
def select(__data, *args, **kwargs):
+ if kwargs:
+ raise NotImplementedError(
+ "Using kwargs in select not currently supported. "
+ "Use _.newname == _.oldname instead"
+ )
var_list = var_create(*args)
od = var_select(__data.columns, *var_list)
@@ -557,13 +577,15 @@ def _select(__data, *args, **kwargs):
@singledispatch2(DataFrame)
def rename(__data, **kwargs):
# TODO: allow names with spaces, etc..
- col_names = {v:k for k,v in kwargs.items()}
+ col_names = {simple_varname(v):k for k,v in kwargs.items()}
+ if None in col_names:
+ raise ValueError("Rename needs column name (e.g. 'a' or _.a), but received %s"%col_names[None])
return __data.rename(columns = col_names)
@rename.register(DataFrameGroupBy)
def _rename(__data, **kwargs):
- raise Exception("Selecting columns of grouped DataFrame currently not allowed")
+ raise NotImplementedError("Selecting columns of grouped DataFrame currently not allowed")
@@ -616,6 +638,11 @@ def arrange(__data, *args):
.drop(tmp_colnames, axis = 1)
+@arrange.register(DataFrameGroupBy)
+def _arrange(__data, *args):
+ raise NotImplementedError("TODO: arrange with grouped DataFrame")
+
+
# Distinct ====================================================================
@@ -890,6 +917,9 @@ def semi_join(left, right = None, on = None):
return left.merge(right.loc[:,on_cols], how = 'inner', on = on_cols)
+@singledispatch2(pd.DataFrame)
+def anti_join(left, right = None, on = None):
+ raise NotImplementedError("anti_join not currently implemented")
left_join = partial(join, how = "left")
right_join = partial(join, how = "right")
diff --git a/siuba/siu.py b/siuba/siu.py
index 3dd4463b..9d5b38e8 100644
--- a/siuba/siu.py
+++ b/siuba/siu.py
@@ -502,6 +502,9 @@ def slice_to_call(x):
return strip_symbolic(x)
+def str_to_getitem_call(x):
+ return Call("__getitem__", MetaArg("_"), x)
+
def strip_symbolic(x):
if isinstance(x, Symbolic):
diff --git a/siuba/sql/dialects/postgresql.py b/siuba/sql/dialects/postgresql.py
index 124e3e01..31b01ed8 100644
--- a/siuba/sql/dialects/postgresql.py
+++ b/siuba/sql/dialects/postgresql.py
@@ -1,14 +1,42 @@
# sqlvariant, allow defining 3 namespaces to override defaults
-from ..translate import base_scalar, base_agg, base_win, SqlTranslator
+from ..translate import (
+ base_scalar, base_agg, base_win, SqlTranslator,
+ win_agg, sql_scalar
+ )
import sqlalchemy.sql.sqltypes as sa_types
from sqlalchemy import sql
+def sql_log(col, base = None):
+ if base is None:
+ return sql.func.ln(col)
+ return sql.func.log(col)
+
def sql_round(col, n):
return sql.func.round(sql.cast(col, sa_types.Numeric()), n)
+def sql_str_contains(col, pat, case, *args, **kwargs):
+ if args or kwargs:
+ raise NotImplementedError("Only pat and case arg of contains allowed.")
+
+ infix = "~" if case else "~*"
+
+ return col.op(infix, pat)
+
+# handle when others is a list?
+def sql_str_cat(col, others, sep, join = None):
+ if join is not None:
+ raise NotImplementedError("join argument of cat not supported")
+
+
scalar = SqlTranslator(
base_scalar,
- round = sql_round
+ log = sql_log,
+ round = sql_round,
+ contains = sql_str_contains,
+ year = lambda col: sql.func.extract('year', sql.cast(col, sql.sqltypes.Date)),
+ concat = sql.func.concat,
+ cat = sql.func.concat,
+ str_c = sql.func.concat
)
aggregate = SqlTranslator(
@@ -16,7 +44,10 @@ def sql_round(col, n):
)
window = SqlTranslator(
- base_win
+ base_win,
+ any = win_agg("bool_or"),
+ all = win_agg("bool_and"),
+ lag = win_agg("lag")
)
funcs = dict(scalar = scalar, aggregate = aggregate, window = window)
diff --git a/siuba/sql/dialects/sqlite.py b/siuba/sql/dialects/sqlite.py
index 910c971c..de30b0e2 100644
--- a/siuba/sql/dialects/sqlite.py
+++ b/siuba/sql/dialects/sqlite.py
@@ -1,5 +1,5 @@
# sqlvariant, allow defining 3 namespaces to override defaults
-from ..translate import base_scalar, base_agg, base_win, SqlTranslator, win_agg
+from ..translate import base_scalar, base_agg, base_nowin, SqlTranslator, win_agg
import sqlalchemy.sql.sqltypes as sa_types
from sqlalchemy import sql
@@ -13,7 +13,7 @@
window = SqlTranslator(
# TODO: should check sqlite version, since < 3.25 can't use windows
- base_win,
+ base_nowin,
sd = win_agg("stddev")
)
diff --git a/siuba/sql/translate.py b/siuba/sql/translate.py
index b36cb4ee..b6930336 100644
--- a/siuba/sql/translate.py
+++ b/siuba/sql/translate.py
@@ -1,7 +1,19 @@
+"""
+This module holds default translations from pandas syntax to sql for 3 kinds of operations...
+
+1. scalar - elementwise operations (e.g. array1 + array2)
+2. aggregation - operations that result in a single number (e.g. array1.mean())
+3. window - operations that do calculations across a window
+ (e.g. array1.lag() or array1.expanding().mean())
+
+
+"""
+
from sqlalchemy import sql
from sqlalchemy.sql import sqltypes as types
from functools import singledispatch
from .verbs import case_when, if_else
+import warnings
# TODO: must make these take both tbl, col as args, since hard to find window funcs
def sa_is_window(clause):
@@ -9,35 +21,80 @@ def sa_is_window(clause):
or isinstance(clause, sql.elements.WithinGroup)
-def sa_modify_window(clause, columns, group_by = None, order_by = None):
- cls = clause.__class__ if sa_is_window(clause) else getattr(clause, "over")
+def sa_modify_window(clause, group_by = None, order_by = None):
if group_by:
- partition_by = [columns[name] for name in group_by]
- return cls(**{**clause.__dict__, 'partition_by': partition_by})
+ group_cols = [columns[name] for name in group_by]
+ partition_by = sql.elements.ClauseList(*group_cols)
+ clone = clause._clone()
+ clone.partition_by = partition_by
+
+ return clone
return clause
+from sqlalchemy.sql.elements import Over
+# windowed agg (group by)
+# agg
+# windowed scalar
+# ordered set agg
+
+class CustomOverClause: pass
+
+class AggOver(Over, CustomOverClause):
+ def set_over(self, group_by, order_by = None):
+ self.partition_by = group_by
+ return self
+
+
+class RankOver(Over, CustomOverClause):
+ def set_over(self, group_by, order_by = None):
+ self.partition_by = group_by
+ return self
+
+
+class CumlOver(Over, CustomOverClause):
+ def set_over(self, group_by, order_by):
+ self.partition_by = group_by
+ self.order_by = order_by
+
+ if not len(order_by):
+ warnings.warn(
+ "No order by columns explicitly set in window function. SQL engine"
+ "does not guarantee a row ordering. Recommend using an arrange beforehand.",
+ RuntimeWarning
+ )
+ return self
+
+
+def win_absent(name):
+ def not_implemented(*args, **kwargs):
+ raise NotImplementedError("SQL dialect does not support {}.".format(name))
+
+ return not_implemented
def win_over(name):
sa_func = getattr(sql.func, name)
- return lambda col: sa_func().over(order_by = col)
+ return lambda col: RankOver(sa_func(), order_by = col)
+def win_cumul(name):
+ sa_func = getattr(sql.func, name)
+ return lambda col: CumlOver(sa_func(col), rows = (None,0))
def win_agg(name):
sa_func = getattr(sql.func, name)
- return lambda col: sa_func(col).over()
+ return lambda col: AggOver(sa_func(col))
def sql_agg(name):
sa_func = getattr(sql.func, name)
return lambda col: sa_func(col)
-def sql_scalar(name):
+def sql_scalar(name, *args):
sa_func = getattr(sql.func, name)
- return lambda col: sa_func(col)
+ return lambda col: sa_func(col, *args)
-def sql_colmeth(meth):
+def sql_colmeth(meth, *outerargs):
def f(col, *args):
- return getattr(col, meth)(*args)
+ return getattr(col, meth)(*outerargs, *args)
return f
def sql_astype(col, _type):
@@ -47,7 +104,10 @@ def sql_astype(col, _type):
float: types.Numeric,
bool: types.Boolean
}
- sa_type = mappings[_type]
+ try:
+ sa_type = mappings[_type]
+ except KeyError:
+ raise ValueError("sql astype currently only supports type objects: str, int, float, bool")
return sql.cast(col, sa_type)
base_scalar = dict(
@@ -70,9 +130,10 @@ def sql_astype(col, _type):
# TODO: I think these are postgres specific?
hour = lambda col: sql.func.date_trunc('hour', col),
week = lambda col: sql.func.date_trunc('week', col),
- isna = lambda col: col.is_(None),
- isnull = lambda col: col.is_(None),
+ isna = sql_colmeth("is_", None),
+ isnull = sql_colmeth("is_", None),
# dply.vector funcs ----
+ desc = lambda col: col.desc(),
# TODO: string methods
#str.len,
@@ -83,7 +144,6 @@ def sql_astype(col, _type):
#str_trim func to cut text off sides
# TODO: move to postgres specific
n = lambda col: sql.func.count(),
- sum = sql_scalar("sum"),
# TODO: this is to support a DictCall (e.g. used in case_when)
dict = dict,
# TODO: don't use singledispatch to add sql support to case_when
@@ -93,13 +153,16 @@ def sql_astype(col, _type):
base_agg = dict(
mean = sql_agg("avg"),
+ sum = sql_agg("sum"),
+ min = sql_agg("min"),
+ max = sql_agg("max"),
# TODO: generalize case where doesn't use col
# need better handeling of vector funcs
len = lambda col: sql.func.count()
)
base_win = dict(
- row_number = win_over("row_number"),
+ row_number = lambda col: CumlOver(sql.func.row_number()),
min_rank = win_over("rank"),
rank = win_over("rank"),
dense_rank = win_over("dense_rank"),
@@ -130,12 +193,47 @@ def sql_astype(col, _type):
# cumulative funcs ---
#avg("id") OVER (PARTITION BY "email" ORDER BY "id" ROWS UNBOUNDED PRECEDING)
#cummean = win_agg("
- #cumsum
+ cumsum = win_cumul("sum")
#cummin
#cummax
)
+# based on https://github.com/tidyverse/dbplyr/blob/master/R/backend-.R
+base_nowin = dict(
+ row_number = win_absent("ROW_NUMBER"),
+ min_rank = win_absent("RANK"),
+ rank = win_absent("RANK"),
+ dense_rank = win_absent("DENSE_RANK"),
+ percent_rank = win_absent("PERCENT_RANK"),
+ cume_dist = win_absent("CUME_DIST"),
+ ntile = win_absent("NTILE"),
+ mean = win_absent("AVG"),
+ sd = win_absent("SD"),
+ var = win_absent("VAR"),
+ cov = win_absent("COV"),
+ cor = win_absent("COR"),
+ sum = win_absent("SUM"),
+ min = win_absent("MIN"),
+ max = win_absent("MAX"),
+ median = win_absent("PERCENTILE_CONT"),
+ quantile = win_absent("PERCENTILE_CONT"),
+ n = win_absent("N"),
+ n_distinct = win_absent("N_DISTINCT"),
+ cummean = win_absent("MEAN"),
+ cumsum = win_absent("SUM"),
+ cummin = win_absent("MIN"),
+ cummax = win_absent("MAX"),
+ nth = win_absent("NTH_VALUE"),
+ first = win_absent("FIRST_VALUE"),
+ last = win_absent("LAST_VALUE"),
+ lead = win_absent("LEAD"),
+ lag = win_absent("LAG"),
+ order_by = win_absent("ORDER_BY"),
+ str_flatten = win_absent("STR_FLATTEN"),
+ count = win_absent("COUNT")
+ )
+
funcs = dict(scalar = base_scalar, aggregate = base_agg, window = base_win)
# MISC ===========================================================================
diff --git a/siuba/sql/verbs.py b/siuba/sql/verbs.py
index 722e380c..c2685182 100644
--- a/siuba/sql/verbs.py
+++ b/siuba/sql/verbs.py
@@ -1,6 +1,6 @@
from siuba.dply.verbs import (
singledispatch2,
- pipe_no_args,
+ show_query, collect,
simple_varname,
select, VarList, var_select,
mutate,
@@ -10,18 +10,18 @@
count,
group_by, ungroup,
case_when,
- join, left_join, right_join, inner_join,
+ join, left_join, right_join, inner_join, semi_join, anti_join,
head,
rename,
distinct,
if_else
)
-from .translate import sa_modify_window, sa_is_window
+from .translate import sa_modify_window, sa_is_window, CustomOverClause
from .utils import get_dialect_funcs
from sqlalchemy import sql
import sqlalchemy
-from siuba.siu import Call, CallTreeLocal
+from siuba.siu import Call, CallTreeLocal, str_to_getitem_call, Lazy
# TODO: currently needed for select, but can we remove pandas?
from pandas import Series
import pandas as pd
@@ -57,17 +57,26 @@ class WindowReplacer(CallListener):
TODO: could replace with a sqlalchemy transformer
"""
- def __init__(self, columns, group_by, window_cte = None):
+ def __init__(self, columns, group_by, order_by, window_cte = None):
self.columns = columns
self.group_by = group_by
+ self.order_by = order_by
self.window_cte = window_cte
self.windows = []
def exit(self, node):
# evaluate
col_expr = node(self.columns)
- if sa_is_window(col_expr):
- label = sa_modify_window(col_expr, self.columns, self.group_by).label(None)
+ if isinstance(col_expr, CustomOverClause):
+ group_by = sql.elements.ClauseList(
+ *[self.columns[name] for name in self.group_by]
+ )
+ order_by = sql.elements.ClauseList(
+ *_create_order_by_clause(self.columns, *self.order_by)
+ )
+
+ label = col_expr.set_over(group_by, order_by).label(None)
+ #label = sa_modify_window(col_expr, self.columns, self.group_by).label(None)
self.windows.append(label)
@@ -81,8 +90,8 @@ def exit(self, node):
return col_expr
-def track_call_windows(call, columns, group_by, window_cte = None):
- listener = WindowReplacer(columns, group_by, window_cte)
+def track_call_windows(call, columns, group_by, order_by, window_cte = None):
+ listener = WindowReplacer(columns, group_by, order_by, window_cte)
col = listener.enter(call)
return col, listener.windows
@@ -93,15 +102,22 @@ def lift_inner_cols(tbl):
return sql.base.ImmutableColumnCollection(data, cols)
-def has_windows(clause):
- windows = []
- append_win = lambda col: windows.append(col)
+def col_expr_requires_cte(call, sel):
+ """Return whether a variable assignment needs a CTE"""
+
+ call_vars = set(call.op_vars(attr_calls = False))
- sql.util.visitors.traverse(clause, {}, {"over": append_win})
- if len(windows):
- return True
+ columns = lift_inner_cols(sel)
+ sel_labs = set(k for k,v in columns.items() if isinstance(v, sql.elements.Label))
+
+ return ( len(sel._group_by_clause)
+ or len(sel._order_by_clause)
+ or not sel_labs.isdisjoint(call_vars)
+ )
- return False
+def get_missing_columns(call, columns):
+ missing_cols = set(call.op_vars(attr_calls = False)) - set(columns.keys())
+ return missing_cols
def compile_el(tbl, el):
compiled = el.compile(
@@ -110,6 +126,14 @@ def compile_el(tbl, el):
)
return compiled
+# Misc utilities --------------------------------------------------------------
+
+def ordered_union(x, y):
+ dx = {el: True for el in x}
+ dy = {el: True for el in y}
+
+ return tuple({**dx, **dy})
+
@@ -140,22 +164,25 @@ def __init__(
self.rm_attr = rm_attr
self.call_sub_attr = call_sub_attr
- def append_op(self, op):
- return self.__class__(
- self.source,
- self.tbl,
- self.ops + [op],
- self.group_by,
- self.order_by,
- self.funcs,
- self.rm_attr,
- self.call_sub_attr
- )
+ def append_op(self, op, **kwargs):
+ cpy = self.copy(**kwargs)
+ cpy.ops = cpy.ops + [op]
+ return cpy
def copy(self, **kwargs):
return self.__class__(**{**self.__dict__, **kwargs})
- def shape_call(self, call, window = True):
+ def shape_call(self, call, window = True, str_accessors = False):
+ # TODO: error if mutate receives a literal value?
+ if str_accessors and isinstance(call, str):
+ # verbs that can use strings as accessors, like group_by, or
+ # arrange, need to convert those strings into a getitem call
+ return str_to_get_item_call(call)
+ elif not isinstance(call, Call):
+ # verbs that use literal strings, need to convert them to a call
+ # that returns a sqlalchemy "literal" object
+ return Lazy(sql.literal(call))
+
f_dict1 = self.funcs['scalar']
f_dict2 = self.funcs['window' if window else 'aggregate']
@@ -172,25 +199,54 @@ def track_call_windows(self, call, columns = None, window_cte = None):
"""Returns tuple of (new column expression, list of window exprs)"""
columns = self.last_op.columns if columns is None else columns
- return track_call_windows(call, columns, self.group_by, window_cte)
+ return track_call_windows(call, columns, self.group_by, self.order_by, window_cte)
+
+ def get_ordered_col_names(self):
+ ungrouped = [k for k in self.last_op.columns.keys() if k not in self.group_by]
+ return list(self.group_by) + ungrouped
@property
def last_op(self):
return self.ops[-1] if len(self.ops) else None
- def __repr__(self):
- tbl_small = self.append_op(self.last_op.limit(5))
-
- # makes sure to get engine, even if sqlalchemy connection obj
- engine = self.source.engine
+ def _get_preview(self):
+ # need to make prev op a cte, so we don't override any previous limit
+ new_sel = sql.select([self.last_op.alias()]).limit(5)
+ tbl_small = self.append_op(new_sel)
+ return collect(tbl_small)
- return ("# Source: lazy query\n"
+ def __repr__(self):
+ template = (
+ "# Source: lazy query\n"
"# DB Conn: {}\n"
"# Preview:\n{}\n"
"# .. may have more rows"
- .format(repr(engine), repr(collect(tbl_small)))
)
+ return template.format(repr(self.source.engine), repr(self._get_preview()))
+
+ def _repr_html_(self):
+ template = (
+ ""
+ "
"
+ "# Source: lazy query\n"
+ "# DB Conn: {}\n"
+ "# Preview:\n"
+ "
"
+ "{}"
+ "
# .. may have more rows
"
+ "
"
+ )
+
+ data = self._get_preview()
+ html_data = getattr(data, '_repr_html_', lambda: repr(data))()
+ return template.format(self.source.engine, html_data)
+
+
+def _repr_grouped_df_html_(self):
+ return "(grouped data frame)
" + self._selected_obj._repr_html_() + "
"
+
+
# Main Funcs
# =============================================================================
@@ -210,9 +266,8 @@ def use_simple_names():
finally:
deregister(sql.compiler._CompileLabel)
-@pipe_no_args
-@singledispatch2(LazyTbl)
-def show_query(tbl, simplify = False):
+@show_query.register(LazyTbl)
+def _show_query(tbl, simplify = False):
query = tbl.last_op #if not simplify else
compile_query = lambda: query.compile(
dialect = tbl.source.dialect,
@@ -231,9 +286,9 @@ def show_query(tbl, simplify = False):
return tbl
# collect ----------
-@pipe_no_args
-@singledispatch2(LazyTbl)
-def collect(__data, as_df = True):
+
+@collect.register(LazyTbl)
+def _collect(__data, as_df = True):
# TODO: maybe remove as_df options, always return dataframe
# normally can just pass the sql objects to execute, but for some reason
# psycopg2 completes about incomplete template.
@@ -248,15 +303,15 @@ def collect(__data, as_df = True):
return __data.source.execute(compiled).fetchall()
-@collect.register(pd.DataFrame)
-def _collect(__data, *args, **kwargs):
- # simply return DataFrame, since requires no execution
- return __data
-
@select.register(LazyTbl)
def _select(__data, *args, **kwargs):
# see https://stackoverflow.com/questions/25914329/rearrange-columns-in-sqlalchemy-select-object
+ if kwargs:
+ raise NotImplementedError(
+ "Using kwargs in select not currently supported. "
+ "Use _.newname == _.oldname instead"
+ )
last_op = __data.last_op
columns = {c.key: c for c in last_op.inner_columns}
@@ -282,14 +337,13 @@ def _filter(__data, *args, **kwargs):
# 1 for window/aggs, and 1 for the where clause
sel = __data.last_op.alias()
win_sel = sql.select([sel], from_obj = sel)
- #fil_sel = sql.select([win_sel], from_obj = win_sel)
conds = []
windows = []
for arg in args:
if isinstance(arg, Call):
new_call = __data.shape_call(arg)
- var_cols = new_call.op_vars(attr_calls = False)
+ #var_cols = new_call.op_vars(attr_calls = False)
col_expr, win_cols = __data.track_call_windows(
new_call,
@@ -297,8 +351,6 @@ def _filter(__data, *args, **kwargs):
window_cte = win_sel
)
- #if sa_is_window(col_expr):
- # col_expr = sa_modify_window(col_expr, columns, __data.group_by)
conds.append(col_expr)
else:
conds.append(arg)
@@ -309,9 +361,10 @@ def _filter(__data, *args, **kwargs):
win_alias = win_sel.alias()
bool_clause = sql.util.ClauseAdapter(win_alias).traverse(bool_clause)
-
- sel = sql.select([win_alias], from_obj = win_alias, whereclause = bool_clause)
- return __data.append_op(sel)
+
+ orig_cols = [win_alias.columns[k] for k in __data.get_ordered_col_names()]
+ filt_sel = sql.select(orig_cols, from_obj = win_alias, whereclause = bool_clause)
+ return __data.append_op(filt_sel)
@mutate.register(LazyTbl)
@@ -342,17 +395,14 @@ def _mutate_select(sel, colname, func, labs, __data):
function handles whether to add a column to the existing select statement,
or to use it as a subquery.
"""
- #colname, func
- replace_col = colname in sel.columns
+ replace_col = False
# Call objects let us check whether column expr used a derived column
# e.g. SELECT a as b, b + 1 as c raises an error in SQL, so need subquery
call_vars = func.op_vars(attr_calls = False)
- if isinstance(func, Call) and labs.isdisjoint(call_vars):
+ if labs.isdisjoint(call_vars):
# New column may be able to modify existing select
+ replace_col = colname in sel.columns
columns = lift_inner_cols(sel)
- # replacing an existing column, so strip it from select statement
- if replace_col:
- sel = sel.with_only_columns([v for k,v in columns.items() if k != colname])
else:
# anything else requires a subquery
@@ -363,6 +413,12 @@ def _mutate_select(sel, colname, func, labs, __data):
# evaluate call expr on columns, making sure to use group vars
new_col, windows = __data.track_call_windows(func, columns)
+ # replacing an existing column, so strip it from select statement
+ if replace_col:
+ replaced = {**columns}
+ replaced[colname] = new_col.label(colname)
+ return sel.with_only_columns(list(replaced.values()))
+
return sel.column(new_col.label(colname))
@@ -371,20 +427,35 @@ def _arrange(__data, *args):
last_op = __data.last_op
cols = lift_inner_cols(last_op)
+ new_calls = tuple(
+ __data.shape_call(expr, window = False) if callable(expr) else expr
+ for expr in args
+ )
+
+ sort_cols = _create_order_by_clause(cols, *new_calls)
+
+ order_by = __data.order_by + new_calls
+ return __data.append_op(last_op.order_by(*sort_cols), order_by = order_by)
+
+
+# TODO: consolidate / pull expr handling funcs into own file?
+def _create_order_by_clause(columns, *args):
sort_cols = []
for arg in args:
# simple named column
if isinstance(arg, str):
- sort_cols.append(cols[arg])
+ sort_cols.append(columns[arg])
# an expression
elif callable(arg):
- f, asc = _call_strip_ascending(arg)
- col_op = f(cols) if asc else f(cols).desc()
+ #f, asc = _call_strip_ascending(arg)
+ #col_op = f(cols) if asc else f(cols).desc()
+ col_op = arg(columns)
sort_cols.append(col_op)
else:
raise NotImplementedError("Must be string or callable")
- return __data.append_op(last_op.order_by(*sort_cols))
+ return sort_cols
+
@count.register(LazyTbl)
@@ -440,18 +511,24 @@ def _summarize(__data, **kwargs):
# - filter is fine, since it uses a CTE
# - need to detect any window functions...
sel = __data.last_op._clone()
- labs = set(k for k,v in sel.columns.items() if isinstance(v, sql.elements.Label))
+
+ new_calls = {k: __data.shape_call(expr, window = False) for k, expr in kwargs.items()}
+ needs_cte = [col_expr_requires_cte(call, sel) for call in new_calls.values()]
# create select statement ----
- if len(sel._group_by_clause):
- # current select stmt has window functions, so need to make it subquery
+ if any(needs_cte):
+ # need a cte, due to alias cols or existing group by
+ # current select stmt has group by clause, so need to make it subquery
cte = sel.alias()
columns = cte.columns
sel = sql.select(from_obj = cte)
else:
# otherwise, can alter the existing select statement
columns = lift_inner_cols(sel)
+ old_froms = sel.froms
+
sel = sel.with_only_columns([])
+ sel.append_from(*old_froms)
# add group by columns ----
group_cols = [columns[k] for k in __data.group_by]
@@ -462,22 +539,34 @@ def _summarize(__data, **kwargs):
# add each aggregate column ----
# TODO: can't do summarize(b = mean(a), c = b + mean(a))
# since difficult for c to refer to agg and unagg cols in SQL
- for k, expr in kwargs.items():
- new_call = __data.shape_call(expr, window = False)
- col = new_call(columns).label(k)
+ for k, expr in new_calls.items():
+ missing_cols = get_missing_columns(expr, columns)
+ if missing_cols:
+ raise NotImplementedError(
+ "Summarize cannot find the following columns: %s. "
+ "Note that it cannot refer to variables defined earlier in the "
+ "same summarize call." % missing_cols
+ )
+
+ col = expr(columns).label(k)
sel.append_column(col)
- # TODO: is a simple method on __data for doing this...
- new_data = __data.append_op(sel)
- new_data.group_by = None
+ new_data = __data.append_op(sel, group_by = tuple(), order_by = tuple())
return new_data
@group_by.register(LazyTbl)
-def _group_by(__data, *args):
- cols = __data.last_op.columns
- groups = [simple_varname(arg) for arg in args]
+def _group_by(__data, *args, add = False, **kwargs):
+ if kwargs:
+ data = mutate(__data, **kwargs)
+ else:
+ data = __data
+
+ cols = data.last_op.columns
+
+ # put kwarg grouping vars last, so similar order to function call
+ groups = tuple(simple_varname(arg) for arg in args) + tuple(kwargs)
if None in groups:
raise NotImplementedError("Complex expressions not supported in sql group_by")
@@ -485,11 +574,15 @@ def _group_by(__data, *args):
if unmatched:
raise KeyError("group_by specifies columns missing from table: %s" %unmatched)
- return __data.copy(group_by = groups)
+ if add:
+ groups = ordered_union(data.group_by, groups)
+
+ return data.copy(group_by = groups)
+
@ungroup.register(LazyTbl)
def _ungroup(__data):
- return __data.copy(group_by = None)
+ return __data.copy(group_by = tuple())
@case_when.register(sql.base.ImmutableColumnCollection)
@@ -523,14 +616,28 @@ def _case_when(__data, cases):
from collections.abc import Mapping
-def _joined_cols(left_cols, right_cols, shared_keys):
+def _joined_cols(left_cols, right_cols, on_keys, full = False):
+ """Return labeled columns, according to selection rules for joins.
+
+ Rules:
+ 1. For join keys, keep left table's column
+ 2. When keys have the same labels, add suffix
+ """
# TODO: remove sets, so uses stable ordering
# when left and right cols have same name, suffix with _x / _y
- shared_labs = set(left_cols.keys()) \
- .intersection(right_cols.keys()) \
- .difference(shared_keys)
+ keep_right = set(right_cols.keys()) - set(on_keys.values())
+ shared_labs = set(left_cols.keys()).intersection(keep_right)
- right_cols_no_keys = {k: v for k, v in right_cols.items() if k not in shared_keys}
+ right_cols_no_keys = {k: right_cols[k] for k in keep_right}
+
+ # for an outer join, have key columns coalesce values
+ if full:
+ left_cols = {**left_cols}
+ for lk, rk in on_keys.items():
+ col = sql.functions.coalesce(left_cols[lk], right_cols[rk])
+ left_cols[lk] = col.label(lk)
+
+ # create labels ----
l_labs = _relabeled_cols(left_cols, shared_labs, "_x")
r_labs = _relabeled_cols(right_cols_no_keys, shared_labs, "_y")
@@ -548,19 +655,102 @@ def _relabeled_cols(columns, keys, suffix):
@join.register(LazyTbl)
-def _join(left, right, on = None, how = None):
+def _join(left, right, on = None, how = "inner"):
# Needs to be on the table, not the select
left_sel = left.last_op.alias()
right_sel = right.last_op.alias()
+
+ # handle arguments ----
+ on = _validate_join_arg_on(on)
+ how = _validate_join_arg_how(how)
+ if how == "right":
+ # switch joins, since sqlalchemy doesn't have right join arg
+ # see https://stackoverflow.com/q/11400307/1144523
+ left_sel, right_sel = right_sel, left_sel
+
+ # create join conditions ----
+ bool_clause = _create_join_conds(left_sel, right_sel, on)
+
+ # create join ----
+ join = left_sel.join(
+ right_sel,
+ onclause = bool_clause,
+ isouter = how != "inner",
+ full = how == "full"
+ )
+
+ # note, shared_keys assumes on is a mapping...
+ shared_keys = [k for k,v in on.items() if k == v]
+ labeled_cols = _joined_cols(
+ left_sel.columns,
+ right_sel.columns,
+ on_keys = on,
+ full = how == "full"
+ )
+
+ sel = sql.select(labeled_cols, from_obj = join)
+ return left.append_op(sel)
+
+
+@semi_join.register(LazyTbl)
+def _semi_join(left, right = None, on = None):
+
+ left_sel = left.last_op.alias()
+ right_sel = right.last_op.alias()
+
+ # handle arguments ----
+ on = _validate_join_arg_on(on)
+
+ # create join conditions ----
+ bool_clause = _create_join_conds(left_sel, right_sel, on)
+
+ # create inner join ----
+ join = left_sel.join(right_sel, onclause = bool_clause)
+
+ # only keep left hand select's columns ----
+ sel = sql.select(left_sel.columns, from_obj = join)
+ return left.append_op(sel)
+
+
+@anti_join.register(LazyTbl)
+def _anti_join(left, right = None, on = None):
+ left_sel = left.last_op.alias()
+ right_sel = right.last_op.alias()
+
+ # handle arguments ----
+ on = _validate_join_arg_on(on)
+
+ # create join conditions ----
+ bool_clause = _create_join_conds(left_sel, right_sel, on)
+
+ # create inner join ----
+ not_exists = ~sql.exists([1], from_obj = right_sel).where(bool_clause)
+ sel = sql.select(left_sel.columns, from_obj = left_sel).where(not_exists)
+ return left.append_op(sel)
+
+
+def _validate_join_arg_on(on):
if on is None:
raise NotImplementedError("on arg must currently be dict")
+ elif isinstance(on, str):
+ on = {on: on}
elif isinstance(on, (list, tuple)):
on = dict(zip(on, on))
if not isinstance(on, Mapping):
- raise Exception("on must be a Mapping (e.g. dict)")
+ raise TypeError("on must be a Mapping (e.g. dict)")
+
+ return on
+
+def _validate_join_arg_how(how):
+ how_options = ("inner", "left", "right", "full")
+ if how not in how_options:
+ raise ValueError("how argument needs to be one of %s" %how_options)
+
+ return how
+def _create_join_conds(left_sel, right_sel, on):
left_cols = left_sel.columns #lift_inner_cols(left_sel)
right_cols = right_sel.columns #lift_inner_cols(right_sel)
@@ -569,21 +759,8 @@ def _join(left, right, on = None, how = None):
col_expr = left_cols[l] == right_cols[r]
conds.append(col_expr)
-
- bool_clause = sql.and_(*conds)
- join = left_sel.join(right_sel, onclause = bool_clause)
+ return sql.and_(*conds)
- # note, shared_keys assumes on is a mapping...
- shared_keys = [k for k,v in on.items() if k == v]
- labeled_cols = _joined_cols(
- left_sel.columns,
- right_sel.columns,
- shared_keys = shared_keys
- )
-
- sel = sql.select(labeled_cols, from_obj = join)
- return left.append_op(sel)
-
# Head ------------------------------------------------------------------------
@@ -602,7 +779,12 @@ def _rename(__data, **kwargs):
columns = lift_inner_cols(sel)
# old_keys uses dict as ordered set
- old_to_new = {v:k for k,v in kwargs.items()}
+ old_to_new = {simple_varname(v):k for k,v in kwargs.items()}
+
+ if None in old_to_new:
+ raise KeyError("positional arguments must be simple column, "
+ "e.g. _.colname or _['colname']"
+ )
labs = [c.label(old_to_new[k]) if k in old_to_new else c for k,c in columns.items()]
@@ -617,7 +799,7 @@ def _rename(__data, **kwargs):
def _distinct(__data, *args, _keep_all = False, **kwargs):
if (args or kwargs) and _keep_all:
raise NotImplementedError("Distinct with variables specified in sql requires _keep_all = False")
-
+
inner_sel = mutate(__data, **kwargs).last_op if kwargs else __data.last_op
# TODO: this is copied from the df distinct version
@@ -626,16 +808,26 @@ def _distinct(__data, *args, _keep_all = False, **kwargs):
cols.update(kwargs)
if None in cols:
- raise Exception("positional arguments must be simple column, "
+ raise KeyError("positional arguments must be simple column, "
"e.g. _.colname or _['colname']"
)
- if not cols: cols = list(inner_sel.columns.keys())
+ # use all columns by default
+ if not cols:
+ cols = list(inner_sel.columns.keys())
- sel_cols = lift_inner_cols(inner_sel)
- distinct_cols = [sel_cols[k] for k in cols]
+ if not len(inner_sel._order_by_clause):
+ # select distinct has to include any columns in the order by clause,
+ # so can only safely modify existing statement when there's no order by
+ sel_cols = lift_inner_cols(inner_sel)
+ distinct_cols = [sel_cols[k] for k in cols]
+ sel = inner_sel.with_only_columns(distinct_cols).distinct()
+ else:
+ # fallback to cte
+ cte = inner_sel.alias()
+ distinct_cols = [cte.columns[k] for k in cols]
+ sel = sql.select(distinct_cols, from_obj = cte).distinct()
- sel = inner_sel.with_only_columns(distinct_cols).distinct()
return __data.append_op(sel)
diff --git a/siuba/tests/conftest.py b/siuba/tests/conftest.py
index 3fc39a01..e2247ee2 100644
--- a/siuba/tests/conftest.py
+++ b/siuba/tests/conftest.py
@@ -1,6 +1,25 @@
import pytest
+from .helpers import assert_equal_query, Backend, SqlBackend, data_frame
def pytest_addoption(parser):
parser.addoption(
"--dbs", action="store", default="sqlite", help="databases tested against (comma separated)"
)
+
+params_backend = [
+ pytest.param(lambda: SqlBackend("postgresql"), id = "postgresql", marks=pytest.mark.postgresql),
+ pytest.param(lambda: SqlBackend("sqlite"), id = "sqlite", marks=pytest.mark.sqlite),
+ pytest.param(lambda: Backend("pandas"), id = "pandas", marks=pytest.mark.pandas)
+ ]
+
+@pytest.fixture(params = params_backend, scope = "session")
+def backend(request):
+ return request.param()
+
+@pytest.fixture(autouse=True)
+def skip_backend(request, backend):
+ if request.node.get_closest_marker('skip_backend'):
+ mark_args = request.node.get_closest_marker('skip_backend').args
+ if backend.name in mark_args:
+ pytest.skip('skipped on backend: {}'.format(backend.name))
+
diff --git a/siuba/tests/helpers.py b/siuba/tests/helpers.py
index 10782f25..8168e7c6 100644
--- a/siuba/tests/helpers.py
+++ b/siuba/tests/helpers.py
@@ -1,46 +1,89 @@
from sqlalchemy import create_engine, types
from siuba.sql import LazyTbl, collect
+from siuba.dply.verbs import ungroup
from pandas.testing import assert_frame_equal
+import pandas as pd
+import os
+import numpy as np
-class DbConRegistry:
- table_name_indx = 0
+def data_frame(**kwargs):
+ fixed = {k: [v] if not np.ndim(v) else v for k,v in kwargs.items()}
+ return pd.DataFrame(fixed)
+
+BACKEND_CONFIG = {
+ "postgresql": {
+ "dialect": "postgresql",
+ "dbname": ["SB_TEST_PGDATABASE", "postgres"],
+ "port": ["SB_TEST_PGPORT", "5433"],
+ "user": ["SB_TEST_PGUSER", "postgres"],
+ "password": ["SB_TEST_PGPASSWORD", ""],
+ "host": ["SB_TEST_PGHOST", "localhost"],
+ },
+ "sqlite": {
+ "dialect": "sqlite",
+ "dbname": ":memory:",
+ "port": "0",
+ "user": "",
+ "password": "",
+ "host": ""
+ }
+ }
+
+class Backend:
+ def __init__(self, name):
+ self.name = name
+
+ def dispose(self):
+ pass
+
+ def load_df(self, df = None, **kwargs):
+ if df is None and kwargs:
+ df = pd.DataFrame(kwargs)
+ elif df is not None and kwargs:
+ raise ValueError("Cannot pass kwargs, and a DataFrame")
+
+ return df
+
+ def __repr__(self):
+ return "{0}({1})".format(self.__class__.__name__, repr(self.name))
- def __init__(self):
- self.connections = {}
- def register(self, name, engine):
- self.connections[name] = engine
+class SqlBackend(Backend):
+ table_name_indx = 0
+ sa_conn_fmt = "{dialect}://{user}:{password}@{host}:{port}/{dbname}"
+
+ def __init__(self, name):
+ cnfg = BACKEND_CONFIG[name]
+ params = {k: os.environ.get(*v) if isinstance(v, (list)) else v for k,v in cnfg.items()}
- def remove(self, name):
- con = self.connections[name]
- con.close()
- del self.connections[name]
+ self.name = name
+ self.engine = create_engine(self.sa_conn_fmt.format(**params))
- return con
+ def dispose(self):
+ self.engine.dispose()
@classmethod
def unique_table_name(cls):
cls.table_name_indx += 1
return "siuba_{0:03d}".format(cls.table_name_indx)
- def load_df(self, df):
- out = []
- for k, engine in self.connections.items():
- lazy_tbl = copy_to_sql(df, self.unique_table_name(), engine)
- out.append(lazy_tbl)
- return out
+ def load_df(self, df = None, **kwargs):
+ df = super().load_df(df, **kwargs)
+ return copy_to_sql(df, self.unique_table_name(), self.engine)
+
def assert_frame_sort_equal(a, b):
"""Tests that DataFrames are equal, even if rows are in different order"""
- sorted_a = a.sort_values(by = a.columns.tolist()).reset_index(drop = True)
- sorted_b = b.sort_values(by = b.columns.tolist()).reset_index(drop = True)
+ df_a = ungroup(a)
+ df_b = ungroup(b)
+ sorted_a = df_a.sort_values(by = df_a.columns.tolist()).reset_index(drop = True)
+ sorted_b = df_b.sort_values(by = df_b.columns.tolist()).reset_index(drop = True)
assert_frame_equal(sorted_a, sorted_b)
-def assert_equal_query(tbls, lazy_query, target):
- for tbl in tbls:
- out = collect(lazy_query(tbl))
- assert_frame_sort_equal(out, target)
+def assert_equal_query(tbl, lazy_query, target):
+ out = collect(lazy_query(tbl))
+ assert_frame_sort_equal(out, target)
PREFIX_TO_TYPE = {
@@ -61,7 +104,40 @@ def auto_types(df):
def copy_to_sql(df, name, engine):
+ if isinstance(engine, str):
+ engine = create_engine(engine)
+
df.to_sql(name, engine, dtype = auto_types(df), index = False, if_exists = "replace")
return LazyTbl(engine, name)
+
+
+from functools import wraps
+import pytest
-
+def backend_notimpl(*names):
+ def outer(f):
+ @wraps(f)
+ def wrapper(backend, *args, **kwargs):
+ if backend.name in names:
+ with pytest.raises(NotImplementedError):
+ f(backend, *args, **kwargs)
+ pytest.xfail("Not implemented!")
+ else:
+ return f(backend, *args, **kwargs)
+ return wrapper
+ return outer
+
+def backend_sql(msg):
+ # allow decorating without an extra call
+ if callable(msg):
+ return backend_sql(None)(msg)
+
+ def outer(f):
+ @wraps(f)
+ def wrapper(backend, *args, **kwargs):
+ if not isinstance(backend, SqlBackend):
+ pytest.skip(msg)
+ else:
+ return f(backend, *args, **kwargs)
+ return wrapper
+ return outer
diff --git a/siuba/tests/test_sql_verbs_distinct.py b/siuba/tests/test_sql_verbs_distinct.py
deleted file mode 100644
index f02d3586..00000000
--- a/siuba/tests/test_sql_verbs_distinct.py
+++ /dev/null
@@ -1,79 +0,0 @@
-"""
-Note: this test file was heavily influenced by its dbplyr counterpart.
-
-https://github.com/tidyverse/dbplyr/blob/master/tests/testthat/test-verb-distinct.R
-"""
-
-from siuba.sql import LazyTbl, collect
-from siuba import _, distinct
-import pandas as pd
-import os
-
-import pytest
-from sqlalchemy import create_engine
-
-from .helpers import assert_equal_query, DbConRegistry
-
-DATA = pd.DataFrame({
- "x": [1,1,1,1],
- "y": [1,1,2,2],
- "z": [1,2,1,2]
- })
-
-@pytest.fixture(scope = "module")
-def dbs(request):
- dialects = set(request.config.getoption("--dbs").split(","))
- dbs = DbConRegistry()
-
- if "sqlite" in dialects:
- dbs.register("sqlite", create_engine("sqlite:///:memory:"))
- if "postgresql" in dialects:
- port = os.environ.get("PGPORT", "5433")
- dbs.register("postgresql", create_engine('postgresql://postgres:@localhost:%s/postgres'%port))
-
-
- yield dbs
-
- # cleanup
- for engine in dbs.connections.values():
- engine.dispose()
-
-@pytest.fixture(scope = "module")
-def dfs(dbs):
- yield dbs.load_df(DATA)
-
-def test_distinct_no_args(dfs):
- assert_equal_query(dfs, distinct(), DATA.drop_duplicates())
- assert_equal_query(dfs, distinct(), distinct(DATA))
-
-def test_distinct_one_arg(dfs):
- assert_equal_query(
- dfs,
- distinct(_.y),
- DATA.drop_duplicates(['y'])[['y']].reset_index(drop = True)
- )
-
- assert_equal_query(dfs, distinct(_.y), distinct(DATA, _.y))
-
-def test_distinct_keep_all_not_impl(dfs):
- # TODO: should just mock LazyTbl
- for tbl in dfs:
- with pytest.raises(NotImplementedError):
- distinct(tbl, _.y, _keep_all = True) >> collect()
-
-
-@pytest.mark.xfail
-def test_distinct_via_group_by(dfs):
- # NotImplemented
- assert False
-
-def test_distinct_kwargs(dfs):
- dst = DATA.drop_duplicates(['y', 'x']) \
- .rename(columns = {'x': 'a'}) \
- .reset_index(drop = True)[['y', 'a']]
-
- assert_equal_query(dfs, distinct(_.y, a = _.x), dst)
-
-
-
-
diff --git a/siuba/tests/test_verb_arrange.py b/siuba/tests/test_verb_arrange.py
new file mode 100644
index 00000000..4119f02c
--- /dev/null
+++ b/siuba/tests/test_verb_arrange.py
@@ -0,0 +1,30 @@
+from siuba.dply.verbs import simple_varname
+from siuba import _, filter, group_by, arrange, mutate
+from siuba.dply.vector import row_number, desc
+import pandas as pd
+
+import pytest
+
+from .helpers import assert_equal_query, data_frame, backend_notimpl, backend_sql
+
+
+@backend_sql
+@backend_notimpl("sqlite")
+def test_no_arrange_before_cuml_window_warning(backend):
+ data = data_frame(x = range(1, 5), g = [1,1,2,2])
+ dfs = backend.load_df(data)
+ with pytest.warns(RuntimeWarning):
+ dfs >> mutate(y = _.x.cumsum())
+
+@backend_sql
+def test_arranges_back_to_back(backend):
+ data = data_frame(x = range(1, 5), g = [1,1,2,2])
+ dfs = backend.load_df(data)
+
+ lazy_tbl = dfs >> arrange(_.x) >> arrange(_.g)
+ order_by_vars = tuple(simple_varname(call) for call in lazy_tbl.order_by)
+
+ assert order_by_vars == ("x", "g")
+ assert [c.name for c in lazy_tbl.last_op._order_by_clause] == ["x", "g"]
+
+
diff --git a/siuba/tests/test_verb_distinct.py b/siuba/tests/test_verb_distinct.py
new file mode 100644
index 00000000..b3bca08a
--- /dev/null
+++ b/siuba/tests/test_verb_distinct.py
@@ -0,0 +1,77 @@
+"""
+Note: this test file was heavily influenced by its dbplyr counterpart.
+
+https://github.com/tidyverse/dbplyr/blob/master/tests/testthat/test-verb-distinct.R
+"""
+
+from siuba.sql import LazyTbl, collect
+from siuba import _, distinct, group_by, summarize, arrange, mutate
+from .helpers import assert_equal_query, backend_sql
+import pandas as pd
+import os
+
+import pytest
+from sqlalchemy import create_engine
+
+DATA = pd.DataFrame({
+ "x": [1,2,3,4,5],
+ "y": [5,4,3,2,1]
+ })
+
+@pytest.fixture(scope = "module")
+def df(backend):
+ yield backend.load_df(DATA)
+
+def test_distinct_no_args(df):
+ assert_equal_query(df, distinct(), DATA.drop_duplicates())
+ assert_equal_query(df, distinct(), distinct(DATA))
+
+def test_distinct_one_arg(df):
+ assert_equal_query(
+ df,
+ distinct(_.y),
+ DATA.drop_duplicates(['y'])[['y']].reset_index(drop = True)
+ )
+
+ assert_equal_query(df, distinct(_.y), distinct(DATA, _.y))
+
+@backend_sql
+def test_distinct_keep_all_not_impl(backend, df):
+ # TODO: should just mock LazyTbl
+ with pytest.raises(NotImplementedError):
+ distinct(df, _.y, _keep_all = True) >> collect()
+
+
+@pytest.mark.xfail
+def test_distinct_via_group_by(df):
+ # NotImplemented
+ assert False
+
+
+def test_distinct_after_summarize(df):
+ query = group_by(g = _.x) >> summarize(z = (_.y - _.y).min()) >> distinct(_.z)
+
+ assert_equal_query(df, query, pd.DataFrame({'z': [0]}))
+
+def test_distinct_after_arrange(df):
+ query = arrange(_.x) >> distinct(_.y)
+
+ assert_equal_query(df, query, pd.DataFrame({'y': [5,4,3,2,1]}))
+
+
+def test_distinct_of_mutate_col(df):
+ query = mutate(z = _.x + 1) >> distinct(_.z)
+
+ assert_equal_query(df, query, pd.DataFrame({'z': [2,3,4,5,6]}))
+
+
+def test_distinct_kwargs(df):
+ dst = DATA.drop_duplicates(['y', 'x']) \
+ .rename(columns = {'x': 'a'}) \
+ .reset_index(drop = True)[['y', 'a']]
+
+ assert_equal_query(df, distinct(_.y, a = _.x), dst)
+
+
+
+
diff --git a/siuba/tests/test_verb_filter.py b/siuba/tests/test_verb_filter.py
new file mode 100644
index 00000000..50a9669b
--- /dev/null
+++ b/siuba/tests/test_verb_filter.py
@@ -0,0 +1,78 @@
+"""
+Note: this test file was heavily influenced by its dbplyr counterpart.
+
+https://github.com/tidyverse/dbplyr/blob/master/tests/testthat/test-verb-filter.R
+"""
+
+from siuba import _, filter, group_by, arrange
+from siuba.dply.vector import row_number, desc
+import pandas as pd
+
+import pytest
+
+from .helpers import assert_equal_query, data_frame, backend_notimpl, backend_sql
+
+DATA = pd.DataFrame({
+ "x": [1,1,1,1],
+ "y": [1,1,2,2],
+ "z": [1,2,1,2]
+ })
+
+
+def test_filter_basic(backend):
+ df = data_frame(x = [1,2,3,4,5], y = [5,4,3,2,1])
+ dfs = backend.load_df(df)
+
+ assert_equal_query(dfs, filter(_.x > 3), df[lambda _: _.x > 3])
+
+
+@backend_sql("TODO: pandas - grouped col should be first after mutate")
+@backend_notimpl("sqlite")
+def test_filter_via_group_by(backend):
+ df = data_frame(
+ x = range(1, 11),
+ g = [1]*5 + [2]*5
+ )
+
+ dfs = backend.load_df(df)
+
+ assert_equal_query(
+ dfs,
+ group_by(_.g) >> filter(row_number(_) < 3),
+ data_frame(g = [1,1,2,2], x = [1,2,6,7])
+ )
+
+
+@backend_sql("TODO: pandas - grouped col should be first after mutate")
+@backend_notimpl("sqlite")
+def test_filter_via_group_by_agg(backend):
+ dfs = backend.load_df(x = range(1,11), g = [1]*5 + [2]*5)
+
+ assert_equal_query(
+ dfs,
+ group_by(_.g) >> filter(_.x > _.x.mean()),
+ data_frame(g = [1, 1, 2, 2], x = [4, 5, 9, 10])
+ )
+
+@backend_sql("TODO: pandas - implement arrange over group by")
+@backend_notimpl("sqlite")
+def test_filter_via_group_by_arrange(backend):
+ dfs = backend.load_df(x = [3,2,1] + [2,3,4], g = [1]*3 + [2]*3)
+
+ assert_equal_query(
+ dfs,
+ group_by(_.g) >> arrange(_.x) >> filter(_.x.cumsum() > 3),
+ data_frame(g = [1, 2, 2], x = [3, 3, 4])
+ )
+
+@backend_sql("TODO: pandas - implement arrange over group by")
+@backend_notimpl("sqlite")
+def test_filter_via_group_by_desc_arrange(backend):
+ dfs = backend.load_df(x = [3,2,1] + [2,3,4], g = [1]*3 + [2]*3)
+
+ assert_equal_query(
+ dfs,
+ group_by(_.g) >> arrange(desc(_.x)) >> filter(_.x.cumsum() > 3),
+ data_frame(g = [1, 1, 2, 2, 2], x = [2, 1, 4, 3, 2])
+ )
+
diff --git a/siuba/tests/test_verb_group_by.py b/siuba/tests/test_verb_group_by.py
new file mode 100644
index 00000000..0df83bf4
--- /dev/null
+++ b/siuba/tests/test_verb_group_by.py
@@ -0,0 +1,54 @@
+"""
+Note: this test file was heavily influenced by its dbplyr counterpart.
+
+https://github.com/tidyverse/dbplyr/blob/master/tests/testthat/test-verb-group_by.R
+"""
+
+from siuba import _, group_by, ungroup, summarize
+from siuba.dply.vector import row_number, n
+
+import pytest
+from .helpers import assert_equal_query, data_frame, backend_notimpl, SqlBackend
+from string import ascii_lowercase
+
+DATA = data_frame(x = [1,2,3], y = [9,8,7], g = ['a', 'a', 'b'])
+
+@pytest.fixture(scope = "module")
+def df(backend):
+ if not isinstance(backend, SqlBackend):
+ pytest.skip("TODO: generalize tests to pandas")
+ return backend.load_df(DATA)
+
+
+def test_group_by_no_add(df):
+ gdf = group_by(df, _.x, _.y)
+ assert gdf.group_by == ("x", "y")
+
+def test_group_by_override(df):
+ gdf = df >> group_by(_.x, _.y) >> group_by(_.g)
+ assert gdf.group_by == ("g",)
+
+def test_group_by_add(df):
+ gdf = group_by(df, _.x) >> group_by(_.y, add = True)
+
+ assert gdf.group_by == ("x", "y")
+
+def test_group_by_ungroup(df):
+ q1 = df >> group_by(_.g)
+ assert q1.group_by == ("g",)
+
+ q2 = q1 >> ungroup()
+ assert q2.group_by == tuple()
+
+
+@pytest.mark.skip("TODO: need to test / validate joins first")
+def test_group_by_before_joins(df):
+ assert False
+
+def test_group_by_performs_mutate(df):
+ assert_equal_query(
+ df,
+ group_by(z = _.x + _.y) >> summarize(n = n(_)),
+ data_frame(z = 10, n = 3)
+ )
+
diff --git a/siuba/tests/test_verb_join.py b/siuba/tests/test_verb_join.py
new file mode 100644
index 00000000..9ffc3a51
--- /dev/null
+++ b/siuba/tests/test_verb_join.py
@@ -0,0 +1,137 @@
+"""
+Note: this test file was heavily influenced by its dbplyr counterpart.
+
+https://github.com/tidyverse/dbplyr/blob/master/tests/testthat/test-verb-group_by.R
+"""
+
+from siuba import (
+ _, group_by,
+ join, inner_join, left_join, right_join, full_join,
+ semi_join, anti_join
+ )
+from siuba.dply.vector import row_number, n
+from siuba.sql.verbs import collect
+
+import pytest
+from .helpers import assert_equal_query, assert_frame_sort_equal, data_frame, backend_notimpl, backend_sql
+
+
+DF1 = data_frame(
+ ii = [1,2,3,4],
+ x = ["a", "b", "c", "d"]
+ )
+
+DF2 = data_frame(
+ ii = [1,2,26],
+ y = ["a", "b", "z"]
+ )
+
+DF3 = data_frame(
+ ii = [26],
+ z = ["z"]
+ )
+
+@pytest.fixture(scope = "module")
+def df1(backend):
+ return backend.load_df(DF1)
+
+@pytest.fixture(scope = "module")
+def df2(backend):
+ return backend.load_df(DF2)
+
+@pytest.fixture(scope = "module")
+def df2_jj(backend):
+ return backend.load_df(DF2.rename(columns = {"ii": "jj"}))
+
+@pytest.fixture(scope = "module")
+def df3(backend):
+ return backend.load_df(DF3)
+
+
+
+@backend_sql("TODO: pandas")
+def test_join_diff_vars_keeps_left(backend, df1, df2_jj):
+ out = inner_join(df1, df2_jj, {"ii": "jj"}) >> collect()
+
+ assert out.columns.tolist() == ["ii", "x", "y"]
+
+def test_join_on_str_arg(df1, df2):
+ out = inner_join(df1, df2, "ii") >> collect()
+
+ target = DF1.iloc[:2,].assign(y = ["a", "b"])
+ assert_frame_sort_equal(out, target)
+
+def test_join_on_list_arg(backend):
+ # TODO: how to validate how cols are being matched up?
+ data = DF1.assign(jj = lambda d: d.ii)
+ df_a = backend.load_df(data)
+ df_b = backend.load_df(DF2.assign(jj = lambda d: d.ii))
+ out = inner_join(df_a, df_b, ["ii", "jj"]) >> collect()
+
+ assert_frame_sort_equal(out, data.iloc[:2, :].assign(y = ["a", "b"]))
+
+@pytest.mark.skip("TODO: note, unsure of this syntax")
+def test_join_on_same_col_multiple_times():
+ data = data_frame(ii = [1,2,3], jj = [1,2, 9])
+ df_a = backend.load_df(data)
+ df_b = backend.load_df(data_frame(ii = [1,2,3]))
+
+ out = inner_join(df_a, df_b, {("ii", "jj"): "ii"}) >> collect()
+ # keeps all but last row
+ assert_frame_sort_equal(out, data.iloc[:2,])
+
+def test_join_on_missing_col(df1, df2):
+ with pytest.raises(KeyError):
+ inner_join(df1, df2, {"ABCDEF": "ii"})
+
+ with pytest.raises(KeyError):
+ inner_join(df1, df2, {"ii": "ABCDEF"})
+
+def test_join_suffixes_dupe_names(df1):
+ out = inner_join(df1, df1, {"ii": "ii"}) >> collect()
+ non_index_cols = DF1.columns[DF1.columns != "ii"]
+ assert all((non_index_cols + "_x").isin(out))
+ assert all((non_index_cols + "_y").isin(out))
+
+
+
+# Test basic join types -------------------------------------------------------
+
+def test_basic_left_join(df1, df2):
+ out = left_join(df1, df2, {"ii": "ii"}) >> collect()
+ target = DF1.assign(y = ["a", "b", None, None])
+ assert_frame_sort_equal(out, target)
+
+@backend_sql("TODO: pandas returns columns in rev name order")
+def test_basic_right_join(backend, df1, df2):
+ # same as left join, but flip df arguments
+ out = right_join(df2, df1, {"ii": "ii"}) >> collect()
+ target = DF1.assign(y = ["a", "b", None, None])
+ assert_frame_sort_equal(out, target)
+
+def test_basic_inner_join(df1, df2):
+ out = inner_join(df1, df2, {"ii": "ii"}) >> collect()
+ target = DF1.iloc[:2,:].assign(y = ["a", "b"])
+ assert_frame_sort_equal(out, target)
+
+@backend_sql("TODO: pandas - full should be converted to 'outer'")
+@pytest.mark.skip_backend("sqlite")
+def test_basic_full_join(backend, df1, df2):
+ out = full_join(df1, df2, {"ii": "ii"}) >> collect()
+ target = DF1.merge(DF2, on = "ii", how = "outer")
+ assert_frame_sort_equal(out, target)
+
+@backend_sql("TODO: pandas - key error?")
+def test_basic_semi_join(backend, df1, df2):
+ assert_frame_sort_equal(
+ semi_join(df1, df2, {"ii": "ii"}) >> collect(),
+ DF1.iloc[:2,]
+ )
+
+@backend_sql("TODO: pandas - implement anti join")
+def test_basic_anti_join(backend, df1, df2):
+ assert_frame_sort_equal(
+ anti_join(df1, df2, on = {"ii": "ii"}) >> collect(),
+ DF1.iloc[2:,]
+ )
+
diff --git a/siuba/tests/test_verb_mutate.py b/siuba/tests/test_verb_mutate.py
new file mode 100644
index 00000000..da92bbb7
--- /dev/null
+++ b/siuba/tests/test_verb_mutate.py
@@ -0,0 +1,112 @@
+"""
+Note: this test file was heavily influenced by its dbplyr counterpart.
+
+https://github.com/tidyverse/dbplyr/blob/master/tests/testthat/test-verb-mutate.R
+"""
+
+from siuba import _, mutate, select, group_by, summarize, filter
+from siuba.dply.vector import row_number
+
+import pytest
+from .helpers import assert_equal_query, data_frame, backend_notimpl, backend_sql
+from string import ascii_lowercase
+
+DATA = data_frame(a = [1,2,3], b = [9,8,7])
+
+@pytest.fixture(scope = "module")
+def dfs(backend):
+ return backend.load_df(DATA)
+
+@pytest.mark.parametrize("query, output", [
+ (mutate(x = _.a + _.b), DATA.assign(x = [10, 10, 10])),
+ pytest.param( mutate(x = _.a + _.b) >> summarize(ttl = _.x.sum()), data_frame(ttl = 30.0), marks = pytest.mark.skip("TODO: failing sqlite?")),
+ (mutate(x = _.a + 1, y = _.b - 1), DATA.assign(x = [2,3,4], y = [8,7,6])),
+ (mutate(x = _.a + 1) >> mutate(y = _.b - 1), DATA.assign(x = [2,3,4], y = [8,7,6])),
+ (mutate(x = _.a + 1, y = _.x + 1), DATA.assign(x = [2,3,4], y = [3,4,5]))
+ ])
+def test_mutate_basic(dfs, query, output):
+ assert_equal_query(dfs, query, output)
+
+@pytest.mark.parametrize("query, output", [
+ (mutate(x = 1), DATA.assign(x = 1)),
+ (mutate(x = "a"), DATA.assign(x = "a")),
+ (mutate(x = 1.2), DATA.assign(x = 1.2))
+ ])
+def test_mutate_literal(dfs, query, output):
+ assert_equal_query(dfs, query, output)
+
+
+def test_select_mutate_filter(dfs):
+ assert_equal_query(
+ dfs,
+ select(_.x == _.a) >> mutate(y = _.x * 2) >> filter(_.y == 2),
+ data_frame(x = 1, y = 2)
+ )
+
+@pytest.mark.skip("TODO: check most recent vars for efficient mutate (#41)")
+def test_mutate_smart_nesting(dfs):
+ # y and z both use x, so should create only 1 extra query
+ lazy_tbl = dfs >> mutate(x = _.a + 1, y = _.x + 1, z = _.x + 1)
+
+ query = lazy_tbl.last_op.fromclause
+
+ assert query is lazy_tbl.ops[0]
+ assert isinstance(query.fromclause, sqlalchemy.Table )
+
+
+@pytest.mark.skip("TODO: does pandas backend preserve order? (#42)")
+def test_mutate_reassign_column_ordering(dfs):
+ assert_equal_query(
+ dfs,
+ mutate(c = 3, a = 1, b = 2),
+ data_frame(a = 1, b = 2, c = 3)
+ )
+
+
+@backend_sql
+@backend_notimpl("sqlite")
+def test_mutate_window_funcs(backend):
+ data = data_frame(x = range(1, 5), g = [1,1,2,2])
+ dfs = backend.load_df(data)
+ assert_equal_query(
+ dfs,
+ group_by(_.g) >> mutate(row_num = row_number(_).astype(float)),
+ data.assign(row_num = [1.0, 2, 1, 2])
+ )
+
+
+@backend_notimpl("sqlite")
+def test_mutate_using_agg_expr(backend):
+ data = data_frame(x = range(1, 5), g = [1,1,2,2])
+ dfs = backend.load_df(data)
+ assert_equal_query(
+ dfs,
+ group_by(_.g) >> mutate(y = _.x - _.x.mean()),
+ data.assign(y = [-.5, .5, -.5, .5])
+ )
+
+@backend_sql # TODO: pandas outputs a int column
+@backend_notimpl("sqlite")
+def test_mutate_using_cuml_agg(backend):
+ data = data_frame(x = range(1, 5), g = [1,1,2,2])
+ dfs = backend.load_df(data)
+
+ # cuml window without arrange before generates warning
+ with pytest.warns(None):
+ assert_equal_query(
+ dfs,
+ group_by(_.g) >> mutate(y = _.x.cumsum()),
+ data.assign(y = [1.0, 3, 3, 7])
+ )
+
+def test_mutate_overwrites_prev(backend):
+ # TODO: check that query doesn't generate a CTE
+ dfs = backend.load_df(data_frame(x = range(1, 5), g = [1,1,2,2]))
+ assert_equal_query(
+ dfs,
+ mutate(x = _.x + 1) >> mutate(x = _.x + 1),
+ data_frame(x = [3,4,5,6], g = [1,1,2,2])
+ )
+
+
+
diff --git a/siuba/tests/test_verb_select.py b/siuba/tests/test_verb_select.py
new file mode 100644
index 00000000..91b63889
--- /dev/null
+++ b/siuba/tests/test_verb_select.py
@@ -0,0 +1,56 @@
+"""
+Note: this test file was heavily influenced by its dbplyr counterpart.
+
+https://github.com/tidyverse/dbplyr/blob/master/tests/testthat/test-verb-select.R
+"""
+
+from siuba import _, mutate, select, group_by, rename
+
+import pytest
+from .helpers import assert_equal_query, data_frame, backend_notimpl, backend_sql
+from string import ascii_lowercase
+
+DATA = data_frame(a = 1, b = 2, c = 3)
+
+@pytest.fixture(scope = "module")
+def dfs(backend):
+ return backend.load_df(DATA)
+
+@pytest.mark.parametrize("query, output", [
+ ( select(_.c), data_frame(c = 3) ),
+ ( select(_.b == _.c), data_frame(b = 3) ),
+ ( select(_["a":"c"]), data_frame(a = 1, b = 2, c = 3) ),
+ ( select(_[_.a:_.c]), data_frame(a = 1, b = 2, c = 3) ),
+ ( select(_.a, _.b) >> select(_.b), data_frame(b = 2) ),
+ ( mutate(a = _.b + _.c) >> select(_.a), data_frame(a = 5) ),
+ pytest.param( group_by(_.a) >> select(_.b), data_frame(b = 2, a = 1), marks = pytest.mark.xfail),
+ ])
+def test_select_siu(dfs, query, output):
+ assert_equal_query(dfs, query, output)
+
+
+@pytest.mark.skip("TODO: #63")
+def test_select_kwargs(dfs):
+ assert_equal_query(dfs, select(x = _.a), data_frame(x = 1))
+
+
+# Rename ----------------------------------------------------------------------
+
+@pytest.mark.parametrize("query, output", [
+ ( rename(A = _.a), data_frame(A = 1, b = 2, c = 3) ),
+ ( rename(A = "a"), data_frame(A = 1, b = 2, c = 3) ),
+ ( rename(A = _.a, B = _.c), data_frame(A = 1, b = 2, B = 3) ),
+ ( rename(A = "a", B = "c"), data_frame(A = 1, b = 2, B = 3) )
+ ])
+def test_rename_siu(dfs, query, output):
+ assert_equal_query(dfs, query, output)
+
+
+@backend_sql("TODO: pandas - grouped df rename")
+@pytest.mark.parametrize("query, output", [
+ ( group_by(_.a) >> rename(z = _.a), data_frame(z = 1, b = 2, c = 3) ),
+ ( group_by(_.a) >> rename(z = "a"), data_frame(z = 1, b = 2, c = 3) )
+ ])
+def test_grouped_rename_siu(backend, dfs, query, output):
+ assert_equal_query(dfs, query, output)
+
diff --git a/siuba/tests/test_verb_summarize.py b/siuba/tests/test_verb_summarize.py
new file mode 100644
index 00000000..91062443
--- /dev/null
+++ b/siuba/tests/test_verb_summarize.py
@@ -0,0 +1,100 @@
+"""
+Note: this test file was heavily influenced by its dbplyr counterpart.
+
+https://github.com/tidyverse/dbplyr/blob/master/tests/testthat/test-verb-mutate.R
+"""
+
+from siuba import _, mutate, select, group_by, summarize, filter
+from siuba.dply.vector import row_number, n
+
+import pytest
+from .helpers import assert_equal_query, data_frame, backend_notimpl, backend_sql
+from string import ascii_lowercase
+
+DATA = data_frame(x = [1,2,3,4], g = ['a', 'a', 'b', 'b'])
+
+@pytest.fixture(scope = "module")
+def df(backend):
+ return backend.load_df(DATA)
+
+@pytest.fixture(scope = "module")
+def df_float(backend):
+ return backend.load_df(DATA.assign(x = lambda d: d.x.astype(float)))
+
+@pytest.fixture(scope = "module")
+def gdf(df):
+ return df >> group_by(_.g)
+
+
+@pytest.mark.parametrize("query, output", [
+ (summarize(y = n(_)), data_frame(y = 4)),
+ (summarize(y = _.x.min()), data_frame(y = 1)),
+ ])
+def test_summarize_ungrouped(df, query, output):
+ assert_equal_query(df, query, output)
+
+
+@pytest.mark.skip("TODO: should return 1 row (#63)")
+def test_ungrouped_summarize_literal(df, query, output):
+ assert_equal_query(df, summarize(y = 1), data_frame(y = 1))
+
+
+@backend_notimpl("sqlite")
+def test_summarize_after_mutate_cuml_win(backend, df_float):
+ assert_equal_query(
+ df_float,
+ mutate(y = _.x.cumsum()) >> summarize(z = _.y.max()),
+ data_frame(z = [10.])
+ )
+
+
+@backend_sql
+def test_summarize_keeps_group_vars(backend, gdf):
+ q = gdf >> summarize(n = n(_))
+ assert list(q.last_op.c.keys()) == ["g", "n"]
+
+
+@pytest.mark.parametrize("query, output", [
+ (summarize(y = 1), data_frame(g = ['a', 'b'], y = [1, 1])),
+ (summarize(y = n(_)), data_frame(g = ['a', 'b'], y = [2,2])),
+ (summarize(y = _.x.min()), data_frame(g = ['a', 'b'], y = [1, 3])),
+ # TODO: same issue as above
+ #(mutate(y = _.x.cumsum()) >> summarize(z = _.y.max()), data_frame(y = [3, 7]))
+ ])
+def test_summarize_grouped(gdf, query, output):
+ assert_equal_query(gdf, query, output)
+
+
+@pytest.mark.skip("TODO: (#48)")
+def test_summarize_removes_1_grouping(backend):
+ data = data_frame(a = 1, b = 2, c = 3)
+ df = backend.load_df(data)
+
+ q1 = df >> group_by(_.a, _.b) >> summarize(n = n(_))
+ assert q1.group_by == ("a")
+
+ q2 = q1 >> summarize(n = n(_))
+ assert not len(q2.group_by)
+
+
+@backend_sql("TODO: pandas - need to implement or raise this warning")
+def test_summarize_no_same_call_var_refs(backend, df):
+ with pytest.raises(NotImplementedError):
+ df >> summarize(y = _.x.min(), z = _.y + 1)
+
+
+@backend_sql
+def test_summarize_removes_order_vars(backend, df):
+ lazy_tbl = df >> summarize(n = n(_))
+
+ assert not len(lazy_tbl.order_by)
+
+
+@pytest.mark.skip("TODO (see #50)")
+def test_summarize_unnamed_args(df):
+ assert_equal_query(
+ df,
+ summarize(n(_)),
+ pd.DataFrame({'n(_)': 4})
+ )
+
diff --git a/siuba/tests/test_verb_utils.py b/siuba/tests/test_verb_utils.py
new file mode 100644
index 00000000..3ec00c4b
--- /dev/null
+++ b/siuba/tests/test_verb_utils.py
@@ -0,0 +1,20 @@
+from siuba.sql.verbs import collect, show_query, LazyTbl
+from siuba.dply.verbs import Pipeable
+from .helpers import data_frame
+import pandas as pd
+
+import pytest
+
+@pytest.fixture(scope = "module")
+def df(backend):
+ return backend.load_df(data_frame(x = [1,2,3]))
+
+def test_show_query(df):
+ assert isinstance(show_query(df), df.__class__)
+ assert isinstance(df >> show_query(), df.__class__)
+ assert isinstance(show_query(), Pipeable)
+
+def test_collect(df):
+ assert isinstance(collect(df), pd.DataFrame)
+ assert isinstance(df >> collect(), pd.DataFrame)
+ assert isinstance(collect(), Pipeable)