From 56f6ae823684bf339dc99f6d95e8957b3a37e685 Mon Sep 17 00:00:00 2001 From: Kian-Meng Ang Date: Tue, 26 Sep 2023 15:21:04 +0800 Subject: [PATCH 1/9] Add description to Tox's environments (#696) --- tox.ini | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tox.ini b/tox.ini index 69d2e7e3..2f0e6893 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,13 @@ [tox] envlist = py38,py39,py310,py311,pypy3 + [testenv] +description = testing against {basepython} deps = -r requirements-dev.txt commands = coverage run coverage xml + [gh-actions] python = 3.8: py38 From 349fe062815db4c839b5dc3aa674ea1e030331ab Mon Sep 17 00:00:00 2001 From: Kian-Meng Ang Date: Wed, 27 Sep 2023 05:49:20 +0800 Subject: [PATCH 2/9] Fix typos (#695) --- README.rst | 2 +- pypika/enums.py | 2 +- pypika/queries.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 3d90bf9b..4a53b2e7 100644 --- a/README.rst +++ b/README.rst @@ -510,7 +510,7 @@ Example of a correlated subquery in the `SELECT` Unions """""" -Both ``UNION`` and ``UNION ALL`` are supported. ``UNION DISTINCT`` is synonomous with "UNION`` so |Brand| does not +Both ``UNION`` and ``UNION ALL`` are supported. ``UNION DISTINCT`` is synonymous with "UNION`` so |Brand| does not provide a separate function for it. Unions require that queries have the same number of ``SELECT`` clauses so trying to cast a unioned query to string will throw a ``SetOperationException`` if the column sizes are mismatched. diff --git a/pypika/enums.py b/pypika/enums.py index 751889c4..82febc34 100644 --- a/pypika/enums.py +++ b/pypika/enums.py @@ -139,7 +139,7 @@ class Dialects(Enum): ORACLE = "oracle" MSSQL = "mssql" MYSQL = "mysql" - POSTGRESQL = "postgressql" + POSTGRESQL = "postgresql" REDSHIFT = "redshift" SQLLITE = "sqllite" SNOWFLAKE = "snowflake" diff --git a/pypika/queries.py b/pypika/queries.py index 2adc1b15..5d09d226 100644 --- a/pypika/queries.py +++ b/pypika/queries.py @@ -1564,8 +1564,8 @@ def on_field(self, *fields: Any) -> QueryBuilder: criterion = None for field in fields: - consituent = Field(field, table=self.query._from[0]) == Field(field, table=self.item) - criterion = consituent if criterion is None else criterion & consituent + constituent = Field(field, table=self.query._from[0]) == Field(field, table=self.item) + criterion = constituent if criterion is None else criterion & constituent self.query.do_join(JoinOn(self.item, self.how, criterion)) return self.query From 5087884bba6e09bbc79c3c9228caf426d79e890b Mon Sep 17 00:00:00 2001 From: Trayan Azarov Date: Wed, 27 Sep 2023 00:55:15 +0300 Subject: [PATCH 3/9] feat: Support creating/dropping indices on tables (#753) * feat: Support creating/dropping indices on tables * feat: Support creating/dropping indices on tables - Added str and repr overrides * feat: Support creating/dropping indices on tables - Docs update - README.rst * feat: Support creating/dropping indices on tables - Fixed typo in docs - Removed unnecessary str() in test_create.py's CreateIndexTests * feat: Support creating/dropping indices on tables - Removed return statements for @builder methods to align with rest of the code - Removed unused dialect param * feat: Support creating/dropping indices on tables - Fixing linting errors * feat: Support creating/dropping indices on tables - Fixing linting errors --- README.rst | 94 +++++++++++++++++++++++++++++++++++++ pypika/queries.py | 87 +++++++++++++++++++++++++++++++++- pypika/tests/test_create.py | 47 ++++++++++++++++++- pypika/tests/test_drop.py | 7 +++ 4 files changed, 231 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 4a53b2e7..534d76d5 100644 --- a/README.rst +++ b/README.rst @@ -1274,6 +1274,100 @@ This produces: CREATE TABLE "names" AS (SELECT "last_name","first_name" FROM "person") +Managing Table Indices +^^^^^^^^^^^^^^^^^^^^^^ + +Create Indices +"""""""""""""""" + +The entry point for creating indices is ``pypika.Query.create_index``. +An index name (as ``str``) or a ``pypika.terms.Index`` a table (as ``str`` or ``pypika.Table``) and +columns (as ``pypika.Column``) must be specified. + +.. code-block:: python + + my_index = Index("my_index") + person = Table("person") + stmt = Query \ + .create_index(my_index) \ + .on(person) \ + .columns(person.first_name, person.last_name) + +This produces: + +.. code-block:: sql + + CREATE INDEX my_index + ON person (first_name, last_name) + +It is also possible to create a unique index + +.. code-block:: python + + my_index = Index("my_index") + person = Table("person") + stmt = Query \ + .create_index(my_index) \ + .on(person) \ + .columns(person.first_name, person.last_name) \ + .unique() + +This produces: + +.. code-block:: sql + + CREATE UNIQUE INDEX my_index + ON person (first_name, last_name) + +It is also possible to create an index if it does not exist + +.. code-block:: python + + my_index = Index("my_index") + person = Table("person") + stmt = Query \ + .create_index(my_index) \ + .on(person) \ + .columns(person.first_name, person.last_name) \ + .if_not_exists() + +This produces: + +.. code-block:: sql + + CREATE INDEX IF NOT EXISTS my_index + ON person (first_name, last_name) + +Drop Indices +"""""""""""""""" + +Then entry point for dropping indices is ``pypika.Query.drop_index``. +It takes either ``str`` or ``pypika.terms.Index`` as an argument. + +.. code-block:: python + + my_index = Index("my_index") + stmt = Query.drop_index(my_index) + +This produces: + +.. code-block:: sql + + DROP INDEX my_index + +It is also possible to drop an index if it exists + +.. code-block:: python + + my_index = Index("my_index") + stmt = Query.drop_index(my_index).if_exists() + +This produces: + +.. code-block:: sql + + DROP INDEX IF EXISTS my_index + .. _tutorial_end: diff --git a/pypika/queries.py b/pypika/queries.py index 5d09d226..76eeea25 100644 --- a/pypika/queries.py +++ b/pypika/queries.py @@ -1,11 +1,10 @@ from copy import copy from functools import reduce -from typing import Any, List, Optional, Sequence, Tuple as TypedTuple, Type, Union, Set +from typing import Any, List, Optional, Sequence, Tuple as TypedTuple, Type, Union from pypika.enums import Dialects, JoinType, ReferenceOption, SetOperation from pypika.terms import ( ArithmeticExpression, - Criterion, EmptyCriterion, Field, Function, @@ -384,6 +383,14 @@ def create_table(cls, table: Union[str, Table]) -> "CreateQueryBuilder": """ return CreateQueryBuilder().create_table(table) + @classmethod + def create_index(cls, index: Union[str, Index]) -> "CreateIndexBuilder": + """ + Query builder entry point. Initializes query building and sets the index name to be created. When using this + function, the query becomes a CREATE statement. + """ + return CreateIndexBuilder().create_index(index) + @classmethod def drop_database(cls, database: Union[Database, Table]) -> "DropQueryBuilder": """ @@ -432,6 +439,14 @@ def drop_view(cls, view: str) -> "DropQueryBuilder": """ return DropQueryBuilder().drop_view(view) + @classmethod + def drop_index(cls, index: Union[str, Index]) -> "DropQueryBuilder": + """ + Query builder entry point. Initializes query building and sets the index name to be dropped. When using this + function, the query becomes a DROP statement. + """ + return DropQueryBuilder().drop_index(index) + @classmethod def into(cls, table: Union[Table, str], **kwargs: Any) -> "QueryBuilder": """ @@ -2042,6 +2057,70 @@ def __repr__(self) -> str: return self.__str__() +class CreateIndexBuilder: + def __init__(self) -> None: + self._index = None + self._columns = [] + self._table = None + self._wheres = None + self._is_unique = False + self._if_not_exists = False + + @builder + def create_index(self, index: Union[str, Index]) -> "CreateIndexBuilder": + self._index = index + + @builder + def columns(self, *columns: Union[str, TypedTuple[str, str], Column]) -> "CreateIndexBuilder": + for column in columns: + if isinstance(column, str): + column = Column(column) + elif isinstance(column, tuple): + column = Column(column_name=column[0], column_type=column[1]) + self._columns.append(column) + + @builder + def on(self, table: Union[Table, str]) -> "CreateIndexBuilder": + self._table = table + + @builder + def where(self, criterion: Union[Term, EmptyCriterion]) -> "CreateIndexBuilder": + """ + Partial index where clause. + """ + if self._wheres: + self._wheres &= criterion + else: + self._wheres = criterion + + @builder + def unique(self) -> "CreateIndexBuilder": + self._is_unique = True + + @builder + def if_not_exists(self) -> "CreateIndexBuilder": + self._if_not_exists = True + + def get_sql(self) -> str: + if not self._columns or len(self._columns) == 0: + raise AttributeError("Cannot create index without columns") + if not self._table: + raise AttributeError("Cannot create index without table") + columns_str = ", ".join([c.name for c in self._columns]) + unique_str = "UNIQUE" if self._is_unique else "" + if_not_exists_str = "IF NOT EXISTS" if self._if_not_exists else "" + base_sql = f"CREATE {unique_str} INDEX {if_not_exists_str} {self._index} ON {self._table}({columns_str})" + if self._wheres: + base_sql += f" WHERE {self._wheres}" + return base_sql.replace(" ", " ") + + def __str__(self) -> str: + return self.get_sql() + + def __repr__(self) -> str: + return self.__str__() + + class DropQueryBuilder: """ Query builder used to build DROP queries. @@ -2081,6 +2160,10 @@ def drop_user(self, user: str) -> "DropQueryBuilder": def drop_view(self, view: str) -> "DropQueryBuilder": self._set_target('VIEW', view) + @builder + def drop_index(self, index: str) -> "DropQueryBuilder": + self._set_target('INDEX', index) + @builder def if_exists(self) -> "DropQueryBuilder": self._if_exists = True diff --git a/pypika/tests/test_create.py b/pypika/tests/test_create.py index e081a44b..32507654 100644 --- a/pypika/tests/test_create.py +++ b/pypika/tests/test_create.py @@ -1,7 +1,7 @@ import unittest -from pypika import Column, Columns, Query, Tables -from pypika.terms import ValueWrapper +from pypika import Column, Columns, Query, Tables, Table +from pypika.terms import ValueWrapper, Index from pypika.enums import ReferenceOption @@ -165,3 +165,46 @@ def test_create_table_with_select_and_columns_fails(self): def test_create_table_as_select_not_query_raises_error(self): with self.assertRaises(TypeError): Query.create_table(self.new_table).as_select("abc") + + +class CreateIndexTests(unittest.TestCase): + new_index = Index("abc") + new_table = Table("my_table") + columns = (new_table.a, new_table.b) + where = new_table.a > 1 + + def test_create_index_simple(self) -> None: + q = Query.create_index(self.new_index).on(self.new_table).columns(*self.columns) + self.assertEqual( + f'CREATE INDEX "{self.new_index.name}" ON "{self.new_table.get_table_name()}"({", ".join([c.name for c in self.columns])})', + q.get_sql(), + ) + + def test_create_index_where(self) -> None: + q = Query.create_index(self.new_index).on(self.new_table).columns(*self.columns).where(self.where) + self.assertEqual( + f'CREATE INDEX "{self.new_index.name}" ON "{self.new_table.get_table_name()}"({", ".join([c.name for c in self.columns])}) WHERE {self.where.get_sql()}', + q.get_sql(), + ) + + def test_create_index_unique(self) -> None: + q = Query.create_index(self.new_index).on(self.new_table).columns(*self.columns).unique() + self.assertEqual( + f'CREATE UNIQUE INDEX "{self.new_index.name}" ON "{self.new_table.get_table_name()}"({", ".join([c.name for c in self.columns])})', + q.get_sql(), + ) + + def test_create_index_if_not_exists(self) -> None: + q = Query.create_index(self.new_index).on(self.new_table).columns(*self.columns).if_not_exists() + self.assertEqual( + f'CREATE INDEX IF NOT EXISTS "{self.new_index.name}" ON "{self.new_table.get_table_name()}"({", ".join([c.name for c in self.columns])})', + q.get_sql(), + ) + + def test_create_index_without_columns_raises_error(self) -> None: + with self.assertRaises(AttributeError): + Query.create_index(self.new_index).on(self.new_table).get_sql() + + def test_create_index_without_table_raises_error(self) -> None: + with self.assertRaises(AttributeError): + Query.create_index(self.new_index).columns(*self.columns).get_sql() diff --git a/pypika/tests/test_drop.py b/pypika/tests/test_drop.py index ef5b1322..265ae735 100644 --- a/pypika/tests/test_drop.py +++ b/pypika/tests/test_drop.py @@ -40,3 +40,10 @@ def test_drop_view(self): self.assertEqual('DROP VIEW "myview"', str(q1)) self.assertEqual('DROP VIEW IF EXISTS "myview"', str(q2)) + + def test_drop_index(self): + q1 = Query.drop_index("myindex") + q2 = Query.drop_index("myindex").if_exists() + + self.assertEqual('DROP INDEX "myindex"', str(q1)) + self.assertEqual('DROP INDEX IF EXISTS "myindex"', str(q2)) From 351981a695733668e122f2ded70a38e2dfb2320c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=C3=ADn=20K=C5=99=C3=AD=C5=BE?= <15214494+antoninkriz@users.noreply.github.com> Date: Tue, 26 Sep 2023 23:59:43 +0200 Subject: [PATCH 4/9] Fix: Allow the usage of `frozenset` in the `isin(...)` and `notin(...)` functions (#744) * Allow the usage of frozenset in the isin(...) and notin(...) functions * Added more tests for the isin function --- pypika/terms.py | 6 +++--- pypika/tests/test_criterions.py | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/pypika/terms.py b/pypika/terms.py index 2139ea64..b25af5c5 100644 --- a/pypika/terms.py +++ b/pypika/terms.py @@ -184,12 +184,12 @@ def as_of(self, expr: str) -> "BasicCriterion": def all_(self) -> "All": return All(self) - def isin(self, arg: Union[list, tuple, set, "Term"]) -> "ContainsCriterion": - if isinstance(arg, (list, tuple, set)): + def isin(self, arg: Union[list, tuple, set, frozenset, "Term"]) -> "ContainsCriterion": + if isinstance(arg, (list, tuple, set, frozenset)): return ContainsCriterion(self, Tuple(*[self.wrap_constant(value) for value in arg])) return ContainsCriterion(self, arg) - def notin(self, arg: Union[list, tuple, set, "Term"]) -> "ContainsCriterion": + def notin(self, arg: Union[list, tuple, set, frozenset, "Term"]) -> "ContainsCriterion": return self.isin(arg).negate() def bin_regex(self, pattern: str) -> "BasicCriterion": diff --git a/pypika/tests/test_criterions.py b/pypika/tests/test_criterions.py index 37b94e8a..fca2ceee 100644 --- a/pypika/tests/test_criterions.py +++ b/pypika/tests/test_criterions.py @@ -438,6 +438,12 @@ def test__function_isin(self): self.assertEqual('COALESCE("foo",0) IN (0,1)', str(c1)) self.assertEqual('COALESCE("isin"."foo",0) IN (0,1)', str(c2)) + for t in (list, tuple, set, frozenset): + c_type = fn.Coalesce(Field("foo"), 0).isin(t([0, 1])) + self.assertEqual('COALESCE("foo",0) IN (0,1)', str(c_type)) + + self.assertRaises(AttributeError, lambda: str(fn.Coalesce(Field("foo"), 0).isin('SHOULD_FAIL'))) + def test__in_unicode(self): c1 = Field("foo").isin([u"a", u"b"]) c2 = Field("foo", table=self.t).isin([u"a", u"b"]) From f46ec8522c36911bff76f0c36a00206ce2e31049 Mon Sep 17 00:00:00 2001 From: Devin Slauenwhite Date: Fri, 29 Sep 2023 10:13:28 -0400 Subject: [PATCH 5/9] fix(doc): update PseudoColumn module import. (#698) --- docs/3_advanced.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/3_advanced.rst b/docs/3_advanced.rst index d6ae1e5e..2dcf1e42 100644 --- a/docs/3_advanced.rst +++ b/docs/3_advanced.rst @@ -51,7 +51,8 @@ The pseudo-column can change from database to database, so here it's possible to .. code-block:: python - from pypika import Query, PseudoColumn + from pypika import Query + from pypika.terms import PseudoColumn CurrentDate = PseudoColumn('current_date') From 44dcc9d4d37998cb4b21d343f36f5f4005000988 Mon Sep 17 00:00:00 2001 From: Guillaume Tassery Date: Wed, 11 Oct 2023 00:41:26 +0700 Subject: [PATCH 6/9] Add support of SAMPLE ClickHouse clause (#707) Co-authored-by: Guillaume Tassery --- pypika/dialects.py | 17 ++++++++++++++++- pypika/tests/dialects/test_clickhouse.py | 10 ++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/pypika/dialects.py b/pypika/dialects.py index 6e151d68..8894e562 100644 --- a/pypika/dialects.py +++ b/pypika/dialects.py @@ -781,6 +781,16 @@ def drop_view(self, view: str) -> "ClickHouseDropQueryBuilder": class ClickHouseQueryBuilder(QueryBuilder): QUERY_CLS = ClickHouseQuery + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._sample = None + self._sample_offset = None + + @builder + def sample(self, sample: int, offset: Optional[int] = None) -> "ClickHouseQueryBuilder": + self._sample = sample + self._sample_offset = offset + @staticmethod def _delete_sql(**kwargs: Any) -> str: return 'ALTER TABLE' @@ -792,7 +802,12 @@ def _from_sql(self, with_namespace: bool = False, **kwargs: Any) -> str: selectable = ",".join(clause.get_sql(subquery=True, with_alias=True, **kwargs) for clause in self._from) if self._delete_from: return " {selectable} DELETE".format(selectable=selectable) - return " FROM {selectable}".format(selectable=selectable) + clauses = [selectable] + if self._sample is not None: + clauses.append(f"SAMPLE {self._sample}") + if self._sample_offset is not None: + clauses.append(f"OFFSET {self._sample_offset}") + return " FROM {clauses}".format(clauses=" ".join(clauses)) def _set_sql(self, **kwargs: Any) -> str: return " UPDATE {set}".format( diff --git a/pypika/tests/dialects/test_clickhouse.py b/pypika/tests/dialects/test_clickhouse.py index 1a8b8b8b..8da62701 100644 --- a/pypika/tests/dialects/test_clickhouse.py +++ b/pypika/tests/dialects/test_clickhouse.py @@ -13,6 +13,16 @@ def test_use_AS_keyword_for_alias(self): query = ClickHouseQuery.from_(t).select(t.foo.as_('f1'), t.bar.as_('f2')) self.assertEqual(str(query), 'SELECT "foo" AS "f1","bar" AS "f2" FROM "abc"') + def test_use_SAMPLE_keyword(self): + t = Table('abc') + query = ClickHouseQuery.from_(t).select(t.foo).sample(10) + self.assertEqual(str(query), 'SELECT "foo" FROM "abc" SAMPLE 10') + + def test_use_SAMPLE_with_offset_keyword(self): + t = Table('abc') + query = ClickHouseQuery.from_(t).select(t.foo).sample(10, 5) + self.assertEqual(str(query), 'SELECT "foo" FROM "abc" SAMPLE 10 OFFSET 5') + class ClickHouseDeleteTests(TestCase): table_abc = Table("abc") From ded17ebda5bee8334430d1eda1ec3bf1a9385f76 Mon Sep 17 00:00:00 2001 From: Sevdimali Date: Tue, 10 Oct 2023 22:15:00 +0400 Subject: [PATCH 7/9] Update 3_advanced.rst (#642) import bugs fixed From 627b60ac760445e496d6aff890ccc7ab3990176c Mon Sep 17 00:00:00 2001 From: Will Dean <57733339+wd60622@users.noreply.github.com> Date: Mon, 16 Oct 2023 17:26:04 +0200 Subject: [PATCH 8/9] Contributing guidelines (#758) * add another common location * code block but also ticks * add command to build docs * switch to rst format * link to new file * update the contributing section * add some more text in the steps * add missing modules to docs * pin jinja2 and add support for numpydoc style * reference numpydoc --- .gitignore | 1 + CONTRIBUTING.rst | 101 ++++++++++++++++++++++++++++++ Makefile | 23 +++++++ README.rst | 10 ++- docs/6_contributing.rst | 1 + docs/api/pypika.analytics.rst | 7 +++ docs/api/pypika.dialects.rst | 7 +++ docs/api/pypika.pseudocolumns.rst | 7 +++ docs/api/pypika.rst | 4 +- docs/conf.py | 1 + docs/index.rst | 5 ++ pypika/__init__.py | 36 +++++++---- pypika/queries.py | 21 +++++-- requirements-dev.txt | 2 + 14 files changed, 207 insertions(+), 19 deletions(-) create mode 100644 CONTRIBUTING.rst create mode 100644 Makefile create mode 100644 docs/6_contributing.rst create mode 100644 docs/api/pypika.analytics.rst create mode 100644 docs/api/pypika.dialects.rst create mode 100644 docs/api/pypika.pseudocolumns.rst diff --git a/.gitignore b/.gitignore index f7aa02f4..2ef18313 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ target/ # virtualenv venv/ ENV/ +.venv/ # OS X .DS_Store diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 00000000..efbb4724 --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,101 @@ +Guidelines for Contributing +=========================== + +PyPika welcomes contributions in all forms. These may be bugs, feature requests, documentation, or examples. Please feel free to: + +#. Submitting an issue +#. Opening a pull request +#. Helping with outstanding issues and pull requests + +Open an issue +------------- + +If you find a bug or have a feature request, please `open an issue `_ on GitHub. Please just check that the issue doesn't already exist before opening a new one. + +Local development steps +----------------------- + +Create a forked branch of the repo +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Do this once but keep it up to date + +#. `Fork the kayak/PyPika repo GitHub `_ +#. Clone forked repo and set upstream + + .. code-block:: bash + + git clone git@github.com:/pypika.git + cd pypika + git remote add upstream git@github.com:kayak/pypika.git + +Setup local development environment +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +#. Setup up python virtual environment + + Create and activate the environment. Here is an example using ``venv`` from the standard library: + + .. code-block:: bash + + python -m venv .venv + source .venv/bin/activate + +#. Install python dependencies for development + + With the virtual environment activated, install the development requirements, pre-commit, and the package itself: + + .. code-block:: bash + + make install + +#. Run the tests + + The unit tests are run with ``unittest`` via ``tox``. To run the tests locally: + + .. code-block:: bash + + make test + + These tests will also run on GitHub Actions when you open a pull request. + +#. Build the docs locally + + Our docs are built with Sphinx. To build the docs locally: + + .. code-block:: bash + + make docs.build + + Open the docs in your browser. For instance, on macOS: + + .. code-block:: bash + + open docs/_build/index.html + +Pull Request checklist +---------------------- + +Please check that your pull request meets the following criteria: + +- Unit tests pass +- pre-commit hooks pass +- Docstring and examples and checking for correct render in the docs + +Documentation +------------- + +Documentation is built with Sphinx and hosted on `Read the Docs `_. The latest builds are displayed on their site `here `_. + +The code documentation is to be written in the docstrings of the code itself or in the various ``.rst`` files in project root or the ``docs/`` directory. + +The docstrings can be in either `Numpy `_ or `Sphinx `_ format. + +Automations +----------- + +We use `pre-commit `_ to automate format checks. Install the pre-commit hooks with the ``make install`` command described above. + +GitHub Actions runs both format checks and unit tests on every pull request. + +**NOTE:** The hosted documentation is built separately from the GitHub Actions workflow. To build the docs locally, use the ``make docs.build`` command described above. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..aec52ac1 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +# You can set these variables from the command line. +SPHINXBUILD = sphinx-build +SOURCEDIR = docs +BUILDDIR = docs/_build + + +help: ## Show this help. + @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//' + +install: ## Install development dependencies + pip install -r requirements-dev.txt pre-commit -e . + pre-commit install + +test: ## Run tests + tox + +docs.build: ## Build the documentation + $(SPHINXBUILD) $(SOURCEDIR) $(BUILDDIR) -b html -E + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)." + +docs.clean: ## Remove the generated documentation + rm -rf $(BUILDDIR) diff --git a/README.rst b/README.rst index 534d76d5..f36f34b5 100644 --- a/README.rst +++ b/README.rst @@ -1370,9 +1370,17 @@ This produces: .. _tutorial_end: +.. _contributing_start: -.. _license_start: +Contributing +------------ + +We welcome community contributions to |Brand|. Please see the `contributing guide <6_contributing.html>`_ to more info. +.. _contributing_end: + + +.. _license_start: License ------- diff --git a/docs/6_contributing.rst b/docs/6_contributing.rst new file mode 100644 index 00000000..3bdd7dc2 --- /dev/null +++ b/docs/6_contributing.rst @@ -0,0 +1 @@ +.. include:: ../CONTRIBUTING.rst \ No newline at end of file diff --git a/docs/api/pypika.analytics.rst b/docs/api/pypika.analytics.rst new file mode 100644 index 00000000..36ae82ed --- /dev/null +++ b/docs/api/pypika.analytics.rst @@ -0,0 +1,7 @@ +pypika.analytics module +======================= + +.. automodule:: pypika.analytics + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/api/pypika.dialects.rst b/docs/api/pypika.dialects.rst new file mode 100644 index 00000000..0c10e70a --- /dev/null +++ b/docs/api/pypika.dialects.rst @@ -0,0 +1,7 @@ +pypika.dialects module +====================== + +.. automodule:: pypika.dialects + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/api/pypika.pseudocolumns.rst b/docs/api/pypika.pseudocolumns.rst new file mode 100644 index 00000000..dc578333 --- /dev/null +++ b/docs/api/pypika.pseudocolumns.rst @@ -0,0 +1,7 @@ +pypika.pseudocolumns module +=========================== + +.. automodule:: pypika.pseudocolumns + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/api/pypika.rst b/docs/api/pypika.rst index bff77c90..26282846 100644 --- a/docs/api/pypika.rst +++ b/docs/api/pypika.rst @@ -3,9 +3,11 @@ pypika package .. toctree:: - + pypika.analytics + pypika.dialects pypika.enums pypika.functions + pypika.pseudocolumns pypika.queries pypika.terms pypika.utils diff --git a/docs/conf.py b/docs/conf.py index 9f49b4fa..57f8d39d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -39,6 +39,7 @@ 'sphinx.ext.coverage', 'sphinx.ext.imgmath', 'sphinx.ext.viewcode', + 'numpydoc', ] # Add any paths that contain templates here, relative to this directory. diff --git a/docs/index.rst b/docs/index.rst index 9d7a3c45..72e39ea1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,6 +9,10 @@ PyPika - Python Query Builder :start-after: _appendix_start: :end-before: _appendix_end: +.. include:: ../README.rst + :start-after: _contributing_start: + :end-before: _contributing_end: + Contents -------- @@ -20,6 +24,7 @@ Contents 3_advanced 4_extending 5_reference + 6_contributing Indices and tables ------------------ diff --git a/pypika/__init__.py b/pypika/__init__.py index 538bdfbd..a875ae00 100644 --- a/pypika/__init__.py +++ b/pypika/__init__.py @@ -1,35 +1,49 @@ """ PyPika is divided into a couple of modules, primarily the ``queries`` and ``terms`` modules. -pypika.queries --------------- +pypika.analytics +---------------- -This is where the ``Query`` class can be found which is the core class in PyPika. Also, other top level classes such -as ``Table`` can be found here. ``Query`` is a container that holds all of the ``Term`` types together and also -serializes the builder to a string. +Wrappers for SQL analytic functions -pypika.terms +pypika.dialects +--------------- + +This contains all of the dialect specific implementations of the ``Query`` class. + +pypika.enums ------------ -This module contains the classes which represent individual parts of queries that extend the ``Term`` base class. +Enumerated values are kept in this package which are used as options for Queries and Terms. pypika.functions ---------------- Wrappers for common SQL functions are stored in this package. -pypika.enums ------------- +pypika.pseudocolumns +-------------------- -Enumerated values are kept in this package which are used as options for Queries and Terms. +Wrappers for common SQL pseudocolumns are stored in this package. + +pypika.queries +-------------- + +This is where the ``Query`` class can be found which is the core class in PyPika. Also, other top level classes such +as ``Table`` can be found here. ``Query`` is a container that holds all of the ``Term`` types together and also +serializes the builder to a string. +pypika.terms +------------ + +This module contains the classes which represent individual parts of queries that extend the ``Term`` base class. pypika.utils ------------ This contains all of the utility classes such as exceptions and decorators. - """ + # noinspection PyUnresolvedReferences from pypika.dialects import ( ClickHouseQuery, diff --git a/pypika/queries.py b/pypika/queries.py index 76eeea25..223c3c95 100644 --- a/pypika/queries.py +++ b/pypika/queries.py @@ -350,6 +350,15 @@ class Query: pattern. This class is immutable. + + Examples + -------- + Simple query + + .. code-block:: python + + from pypika import Query, Field + q = Query.from_('customers').select('*').where(Field("id") == 1) """ @classmethod @@ -367,7 +376,7 @@ def from_(cls, table: Union[Selectable, str], **kwargs: Any) -> "QueryBuilder": An instance of a Table object or a string table name. - :returns QueryBuilder + :return: QueryBuilder """ return cls._builder(**kwargs).from_(table) @@ -458,7 +467,7 @@ def into(cls, table: Union[Table, str], **kwargs: Any) -> "QueryBuilder": An instance of a Table object or a string table name. - :returns QueryBuilder + :return QueryBuilder """ return cls._builder(**kwargs).into(table) @@ -478,7 +487,7 @@ def select(cls, *terms: Union[int, float, str, bool, Term], **kwargs: Any) -> "Q A list of terms to select. These can be any type of int, float, str, bool, or Term. They cannot be a Field unless the function ``Query.from_`` is called first. - :returns QueryBuilder + :return: QueryBuilder """ return cls._builder(**kwargs).select(*terms) @@ -493,7 +502,7 @@ def update(cls, table: Union[str, Table], **kwargs) -> "QueryBuilder": An instance of a Table object or a string table name. - :returns QueryBuilder + :return: QueryBuilder """ return cls._builder(**kwargs).update(table) @@ -507,7 +516,7 @@ def Table(cls, table_name: str, **kwargs) -> _TableClass: A string table name. - :returns Table + :return: Table """ kwargs["query_cls"] = cls return Table(table_name, **kwargs) @@ -523,7 +532,7 @@ def Tables(cls, *names: Union[TypedTuple[str, str], str], **kwargs: Any) -> List A list of string table names, or name and alias tuples. - :returns Table + :return: Table """ kwargs["query_cls"] = cls return make_tables(*names, **kwargs) diff --git a/requirements-dev.txt b/requirements-dev.txt index 3926afa7..e8f8d7fa 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,6 +2,8 @@ Sphinx==1.6.5 sphinx-rtd-theme==0.2.4 bumpversion==0.5.3 +jinja2==3.0.3 +numpydoc==1.1.0 # Testing parameterized==0.7.0 From 8841520e906970d76c5ed81c7dd5d154f0d5259d Mon Sep 17 00:00:00 2001 From: Will Dean <57733339+wd60622@users.noreply.github.com> Date: Sun, 10 Dec 2023 12:09:47 -0500 Subject: [PATCH 9/9] add pipe operator on QueryBuilder (#759) * some draft work * add docstring * add test * add to docstring * add explicit string * add pipe section --- README.rst | 52 ++++++++++++++++++++++++++++++++++++++ pypika/queries.py | 48 +++++++++++++++++++++++++++++++++++ pypika/tests/test_query.py | 38 +++++++++++++++++++++++++++- 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index f36f34b5..31bb94ff 100644 --- a/README.rst +++ b/README.rst @@ -1368,6 +1368,58 @@ This produces: DROP INDEX IF EXISTS my_index + +Chaining Functions +^^^^^^^^^^^^^^^^^^ + +The ``QueryBuilder.pipe`` method gives a more readable alternative while chaining functions. + +.. code-block:: python + + # This + ( + query + .pipe(func1, *args) + .pipe(func2, **kwargs) + .pipe(func3) + ) + + # Is equivalent to this + func3(func2(func1(query, *args), **kwargs)) + +Or for a more concrete example: + +.. code-block:: python + + from pypika import Field, Query, functions as fn + from pypika.queries import QueryBuilder + + def filter_days(query: QueryBuilder, col, num_days: int) -> QueryBuilder: + if isinstance(col, str): + col = Field(col) + + return query.where(col > fn.Now() - num_days) + + def count_groups(query: QueryBuilder, *groups) -> QueryBuilder: + return query.groupby(*groups).select(*groups, fn.Count("*").as_("n_rows")) + + base_query = Query.from_("table") + + query = ( + base_query + .pipe(filter_days, "date", num_days=7) + .pipe(count_groups, "col1", "col2") + ) + +This produces: + +.. code-block:: sql + + SELECT "col1","col2",COUNT(*) n_rows + FROM "table" + WHERE "date">NOW()-7 + GROUP BY "col1","col2" + .. _tutorial_end: .. _contributing_start: diff --git a/pypika/queries.py b/pypika/queries.py index 223c3c95..c51c6b2b 100644 --- a/pypika/queries.py +++ b/pypika/queries.py @@ -1560,6 +1560,54 @@ def _set_sql(self, **kwargs: Any) -> str: ) ) + def pipe(self, func, *args, **kwargs): + """Call a function on the current object and return the result. + + Example usage: + + .. code-block:: python + + from pypika import Query, functions as fn + from pypika.queries import QueryBuilder + + def rows_by_group(query: QueryBuilder, *groups) -> QueryBuilder: + return ( + query + .select(*groups, fn.Count("*").as_("n_rows")) + .groupby(*groups) + ) + + base_query = Query.from_("table") + + col1_agg = base_query.pipe(rows_by_group, "col1") + col2_agg = base_query.pipe(rows_by_group, "col2") + col1_col2_agg = base_query.pipe(rows_by_group, "col1", "col2") + + Makes chaining functions together easier, especially when the functions are + defined elsewhere. For example, you could define a function that filters + rows by a date range and then group by a set of columns: + + + .. code-block:: python + + from datetime import datetime, timedelta + + from pypika import Field + + def days_since(query: QueryBuilder, n_days: int) -> QueryBuilder: + return ( + query + .where("date" > fn.Date(datetime.now().date() - timedelta(days=n_days))) + ) + + ( + base_query + .pipe(days_since, n_days=7) + .pipe(rows_by_group, "col1", "col2") + ) + """ + return func(self, *args, **kwargs) + class Joiner: def __init__( diff --git a/pypika/tests/test_query.py b/pypika/tests/test_query.py index 460b7d77..ae8dc015 100644 --- a/pypika/tests/test_query.py +++ b/pypika/tests/test_query.py @@ -1,6 +1,6 @@ import unittest -from pypika import Case, Query, Tables, Tuple, functions +from pypika import Case, Query, Tables, Tuple, functions, Field from pypika.dialects import ( ClickHouseQuery, ClickHouseQueryBuilder, @@ -204,3 +204,39 @@ def test_query_builders_have_reference_to_correct_query_class(self): with self.subTest('OracleQueryBuilder'): self.assertEqual(OracleQuery, OracleQueryBuilder.QUERY_CLS) + + def test_pipe(self) -> None: + base_query = Query.from_("test") + + def select(query: QueryBuilder) -> QueryBuilder: + return query.select("test1", "test2") + + def count_group(query: QueryBuilder, *groups) -> QueryBuilder: + return query.groupby(*groups).select(*groups, functions.Count("*")) + + for func, args, kwargs, expected_str in [ + (select, [], {}, 'SELECT "test1","test2" FROM "test"'), + ( + count_group, + ["test1", "test2"], + {}, + 'SELECT "test1","test2",COUNT(*) FROM "test" GROUP BY "test1","test2"', + ), + (count_group, ["test1"], {}, 'SELECT "test1",COUNT(*) FROM "test" GROUP BY "test1"'), + ]: + result_str = str(base_query.pipe(func, *args, **kwargs)) + self.assertEqual(result_str, str(func(base_query, *args, **kwargs))) + self.assertEqual(result_str, expected_str) + + def where_clause(query: QueryBuilder, num_days: int) -> QueryBuilder: + return query.where(Field("date") > functions.Now() - num_days) + + result_str = str(base_query.pipe(select).pipe(where_clause, num_days=1)) + self.assertEqual( + result_str, + str(select(where_clause(base_query, num_days=1))), + ) + self.assertEqual( + result_str, + 'SELECT "test1","test2" FROM "test" WHERE "date">NOW()-1', + )