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", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
titleIdseasonNumbertitledateav_ratingsharegenres
0tt2879552111.22.632016-03-108.48900.51Drama,Mystery,Sci-Fi
1tt3148266112 Monkeys2015-02-278.34070.46Adventure,Drama,Mystery
2tt3148266212 Monkeys2016-05-308.81960.25Adventure,Drama,Mystery
3tt3148266312 Monkeys2017-05-199.03690.19Adventure,Drama,Mystery
4tt3148266412 Monkeys2018-06-269.13630.38Adventure,Drama,Mystery
\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", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
titleIdseasonNumbertitledateav_ratingsharegenres
0tt01182761Buffy the Vampire Slayer1997-04-147.962911.70Action,Drama,Fantasy
1tt01182762Buffy the Vampire Slayer1997-12-318.419119.41Action,Drama,Fantasy
2tt01182763Buffy the Vampire Slayer1999-01-298.623317.12Action,Drama,Fantasy
3tt01182764Buffy the Vampire Slayer2000-01-198.220516.19Action,Drama,Fantasy
4tt01182765Buffy the Vampire Slayer2001-01-128.302811.99Action,Drama,Fantasy
5tt01182766Buffy the Vampire Slayer2002-01-298.10088.45Action,Drama,Fantasy
6tt01182767Buffy the Vampire Slayer2003-01-188.04609.89Action,Drama,Fantasy
\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", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
avg_rating
08.239343
\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", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
titleavg_ratingdate_range
0Friends from College6.8751002017 - 2017
1Better Things8.1331502017 - 2016
2How to Get Away with Murder8.7623402018 - 2014
3Dexter8.5824002013 - 2006
4Queen of the South8.5747332018 - 2016
\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", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
titlemax_shift
0Third Watch4.8500
1Are You Afraid of the Dark?2.3430
2Lethal Weapon2.3070
3Law & Order: Special Victims Unit2.0508
\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", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
titletitleIdseasonNumberdateav_ratingsharegenresrowmismatch
07th Heaventt011508311996-08-267.7000.10Drama,Family,Romance1False
17th Heaventt0115083102006-05-086.3000.01Drama,Family,Romance2True
2ABC Afterschool Specialstt0202179251996-09-123.3000.10Adventure,Comedy,Drama1True
3American Gothictt525774412016-08-057.5350.07Crime,Drama,Mystery1False
4American Gothictt011188011995-09-227.8000.08Drama,Horror,Thriller2True
\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", + " \n", + " \n", + " \n", + "
n
054
\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)