From 89c98d3229b00cbc96d67bcdc94e98a13a8111e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sat, 7 Jan 2023 22:50:35 +0100 Subject: [PATCH 01/79] Add unit test reports --- .gitlab-ci.yml | 7 +++++-- tox.ini | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 139a54e..d634800 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -49,12 +49,15 @@ check-dist: - .cache/pip before_script: - pip install tox + artifacts: + reports: + junit: junit.xml test-tox-python: extends: .test-tox image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/python:${PYTHON_VERSION} script: - - tox -e "py${PYTHON_VERSION/./}" + - tox -e "py${PYTHON_VERSION/./}" -vv -- -v --output-file junit.xml parallel: matrix: - PYTHON_VERSION: ["3.5", "3.6", "3.7", "3.8", "3.9", "3.10"] @@ -63,4 +66,4 @@ test-tox-pypy: extends: .test-tox image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/pypy:3 script: - - tox -e pypy3 + - tox -e pypy3 -vv -- -v --output-file junit.xml diff --git a/tox.ini b/tox.ini index 917c277..1c7b6de 100644 --- a/tox.ini +++ b/tox.ini @@ -8,8 +8,9 @@ envlist = py35, py36, py37, py38, py39, py310, pypy3 [testenv] commands = - coverage run -m unittest discover -s sql.tests + coverage run -m xmlrunner discover -s sql.tests {posargs} coverage report --include=./sql/* --omit=*/tests/* deps = coverage + unittest-xml-reporting passenv = * From 154e56e8dfcd061a413d8917c456ca0cbff8e944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 8 Jan 2023 04:06:19 +0100 Subject: [PATCH 02/79] Add coverage reports --- .gitlab-ci.yml | 4 ++++ tox.ini | 7 +++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d634800..07ac0f5 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -49,9 +49,13 @@ check-dist: - .cache/pip before_script: - pip install tox + coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/' artifacts: reports: junit: junit.xml + coverage_report: + coverage_format: cobertura + path: coverage.xml test-tox-python: extends: .test-tox diff --git a/tox.ini b/tox.ini index 1c7b6de..358ee2e 100644 --- a/tox.ini +++ b/tox.ini @@ -7,9 +7,12 @@ envlist = py35, py36, py37, py38, py39, py310, pypy3 [testenv] +usedevelop = true commands = - coverage run -m xmlrunner discover -s sql.tests {posargs} - coverage report --include=./sql/* --omit=*/tests/* + coverage run --omit=*/tests/* -m xmlrunner discover -s sql.tests {posargs} +commands_post = + coverage report --omit=README + coverage xml --omit=README deps = coverage unittest-xml-reporting From a518dd53025f87d52736085dd3df1705f8f744f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Wed, 11 Jan 2023 23:52:45 +0100 Subject: [PATCH 03/79] Simplify workflow rule --- .gitlab-ci.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 07ac0f5..37d4a1b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,10 +1,8 @@ workflow: rules: - - if: $CI_PIPELINE_SOURCE == "trigger" - - if: $CI_PIPELINE_SOURCE == "merge_request_event" - - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS + - if: $CI_COMMIT_BRANCH =~ /^topic\/.*/ && $CI_PIPELINE_SOURCE == "push" when: never - - if: $CI_COMMIT_BRANCH =~ /^branch\/.*/ + - when: always stages: - check From 82e456a70738cbd9cf3da03d4a5d9d25b80b47dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20=C3=89vrard?= Date: Sun, 5 Feb 2023 01:00:51 +0100 Subject: [PATCH 04/79] Run isort and flake8 on all the files --- .gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 37d4a1b..df787bd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -18,12 +18,12 @@ stages: check-flake8: extends: .check script: - - hg diff --rev s0 | flake8 --diff + - flake8 check-isort: extends: .check script: - - isort -m VERTICAL_GRID -p trytond -c `hg status --no-status --added --modified --rev s0` + - isort -m VERTICAL_GRID -c . check-dist: extends: .check From 88a15c22a1e618f073181ac0ccac61c0894d329c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20=C3=89vrard?= Date: Fri, 3 Feb 2023 16:57:05 +0100 Subject: [PATCH 05/79] Restore Python 3.5 classifier --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 9264e56..5c88acf 100644 --- a/setup.py +++ b/setup.py @@ -40,6 +40,7 @@ def get_version(): 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', From 36dfdf6b696b4afa5f8bdd6468ca8d603d0b7dea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20=C3=89vrard?= Date: Fri, 3 Feb 2023 16:57:27 +0100 Subject: [PATCH 06/79] Add support for Python 3.11 --- .gitlab-ci.yml | 2 +- setup.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index df787bd..69e52e9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -62,7 +62,7 @@ test-tox-python: - tox -e "py${PYTHON_VERSION/./}" -vv -- -v --output-file junit.xml parallel: matrix: - - PYTHON_VERSION: ["3.5", "3.6", "3.7", "3.8", "3.9", "3.10"] + - PYTHON_VERSION: ["3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] test-tox-pypy: extends: .test-tox diff --git a/setup.py b/setup.py index 5c88acf..a7eac65 100644 --- a/setup.py +++ b/setup.py @@ -46,6 +46,7 @@ def get_version(): 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', 'Topic :: Database', 'Topic :: Software Development :: Libraries :: Python Modules', ], From a8ddabc6ecb6d66791ee8281c1cab6d85c2a4375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Thu, 2 Mar 2023 22:12:22 +0100 Subject: [PATCH 07/79] Update URLs for Heptapod migration --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index a7eac65..2288366 100644 --- a/setup.py +++ b/setup.py @@ -23,11 +23,11 @@ def get_version(): description='Library to write SQL queries', long_description=read('README'), author='Tryton', - author_email='python-sql@tryton.org', + author_email='foundation@tryton.org', url='https://pypi.org/project/python-sql/', download_url='https://downloads.tryton.org/python-sql/', project_urls={ - "Bug Tracker": 'https://python-sql.tryton.org/', + "Bug Tracker": 'https://bugs.tryton.org/python-sql', "Forum": 'https://discuss.tryton.org/tags/python-sql', "Source Code": 'https://code.tryton.org/python-sql', }, From c30b12c11deb50f959e3282a801b28092702147d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Thu, 2 Mar 2023 22:13:08 +0100 Subject: [PATCH 08/79] Use reStructuredText extension for README --- MANIFEST.in | 2 +- README => README.rst | 0 setup.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename README => README.rst (100%) diff --git a/MANIFEST.in b/MANIFEST.in index b1a2e8f..db31cdb 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,3 @@ include COPYRIGHT -include README +include README.rst include CHANGELOG diff --git a/README b/README.rst similarity index 100% rename from README rename to README.rst diff --git a/setup.py b/setup.py index 2288366..52184c9 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ def get_version(): setup(name='python-sql', version=get_version(), description='Library to write SQL queries', - long_description=read('README'), + long_description=read('README.rst'), author='Tryton', author_email='foundation@tryton.org', url='https://pypi.org/project/python-sql/', From 13fdee6cd79810ecd083e899d38683bcff3438d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Thu, 25 May 2023 17:53:34 +0200 Subject: [PATCH 09/79] Do not use alias in returning expression SQLite does not support alias in RETURNING expression and anyway only columns of the table can be used. Closes #86 --- CHANGELOG | 2 ++ sql/__init__.py | 27 ++++++++++++++------------- sql/tests/test_insert.py | 6 +++--- sql/tests/test_update.py | 4 ++-- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index a583b16..d114eef 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +* Do not use alias in returning expression + Version 1.4.0 - 2022-05-02 * Use unittest discover * Use only column name for INSERT and UPDATE diff --git a/sql/__init__.py b/sql/__init__.py index 868ef45..5959097 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -719,14 +719,16 @@ def returning(self, value): assert isinstance(value, list) self._returning = value - @staticmethod - def _format(value, param=None): + def _format(self, value, param=None): if param is None: param = Flavor.get().param - if isinstance(value, Expression): + if isinstance(value, Column) and value.table == self.table: + return value.column_name + elif isinstance(value, Expression): return str(value) elif isinstance(value, Select): - return '(%s)' % value + with AliasManager(): + return '(%s)' % value else: return param @@ -737,16 +739,16 @@ def __str__(self): # Get columns without alias columns = ', '.join(c.column_name for c in self.columns) columns = ' (' + columns + ')' + returning = '' + if self.returning: + returning = ' RETURNING ' + ', '.join( + map(self._format, self.returning)) with AliasManager(): if isinstance(self.values, Query): values = ' %s' % str(self.values) # TODO manage DEFAULT elif self.values is None: values = ' DEFAULT VALUES' - returning = '' - if self.returning: - returning = ' RETURNING ' + ', '.join( - map(self._format, self.returning)) return (self._with_str() + 'INSERT INTO %s AS "%s"' % (self.table, self.table.alias) + columns + values + returning) @@ -800,7 +802,10 @@ def __str__(self): assert all(col.table == self.table for col in self.columns) # Get columns without alias columns = [c.column_name for c in self.columns] - + returning = '' + if self.returning: + returning = ' RETURNING ' + ', '.join( + map(self._format, self.returning)) with AliasManager(): from_ = '' if self.from_: @@ -810,10 +815,6 @@ def __str__(self): where = '' if self.where: where = ' WHERE ' + str(self.where) - returning = '' - if self.returning: - returning = ' RETURNING ' + ', '.join( - map(self._format, self.returning)) return (self._with_str() + 'UPDATE %s AS "%s" SET ' % (self.table, self.table.alias) + values + from_ + where + returning) diff --git a/sql/tests/test_insert.py b/sql/tests/test_insert.py index 1921bf8..059db4b 100644 --- a/sql/tests/test_insert.py +++ b/sql/tests/test_insert.py @@ -49,7 +49,7 @@ def test_insert_returning(self): [['foo', 'bar']], returning=[self.table.c1, self.table.c2]) self.assertEqual(str(query), 'INSERT INTO "t" AS "a" ("c1", "c2") VALUES (%s, %s) ' - 'RETURNING "a"."c1", "a"."c2"') + 'RETURNING "c1", "c2"') self.assertEqual(tuple(query.params), ('foo', 'bar')) def test_insert_returning_select(self): @@ -59,7 +59,7 @@ def test_insert_returning_select(self): returning=[ t2.select(t2.c, where=(t2.c1 == t1.c) & (t2.c2 == 'bar'))]) self.assertEqual(str(query), - 'INSERT INTO "t1" AS "b" ("c") VALUES (%s) ' + 'INSERT INTO "t1" AS "a" ("c") VALUES (%s) ' 'RETURNING (SELECT "a"."c" FROM "t2" AS "a" ' 'WHERE (("a"."c1" = "b"."c") AND ("a"."c2" = %s)))') self.assertEqual(tuple(query.params), ('foo', 'bar')) @@ -92,7 +92,7 @@ def test_insert_in_with(self): self.assertEqual(str(query), 'WITH "a" AS (' 'INSERT INTO "t" AS "b" ("c1") VALUES (%s) ' - 'RETURNING "b"."id") ' + 'RETURNING "id") ' 'UPDATE "t1" AS "c" SET "c" = "a"."id" FROM "a" AS "a"') self.assertEqual(tuple(query.params), ('foo',)) diff --git a/sql/tests/test_update.py b/sql/tests/test_update.py index 89f03f1..e1bee32 100644 --- a/sql/tests/test_update.py +++ b/sql/tests/test_update.py @@ -42,7 +42,7 @@ def test_update_returning(self): query = self.table.update([self.table.c], ['foo'], returning=[self.table.c]) self.assertEqual(str(query), - 'UPDATE "t" AS "a" SET "c" = %s RETURNING "a"."c"') + 'UPDATE "t" AS "a" SET "c" = %s RETURNING "c"') self.assertEqual(query.params, ('foo',)) def test_update_returning_select(self): @@ -52,7 +52,7 @@ def test_update_returning_select(self): returning=[ t2.select(t2.c, where=(t2.c1 == t1.c) & (t2.c2 == 'bar'))]) self.assertEqual(str(query), - 'UPDATE "t1" AS "b" SET "c" = %s ' + 'UPDATE "t1" AS "a" SET "c" = %s ' 'RETURNING (SELECT "a"."c" FROM "t2" AS "a" ' 'WHERE (("a"."c1" = "b"."c") AND ("a"."c2" = %s)))') self.assertEqual(query.params, ('foo', 'bar')) From f30406d4efb7c2a747661f99124047c69f13aa88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Fri, 16 Jun 2023 17:13:43 +0200 Subject: [PATCH 10/79] Prepare release --- CHANGELOG | 1 + COPYRIGHT | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index d114eef..78d7837 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +Version 1.4.1 - 2023-06-16 * Do not use alias in returning expression Version 1.4.0 - 2022-05-02 diff --git a/COPYRIGHT b/COPYRIGHT index 2b1ca76..d45a6e0 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -1,6 +1,6 @@ -Copyright (c) 2011-2022, Cédric Krier -Copyright (c) 2013-2021, Nicolas Évrard -Copyright (c) 2011-2022, B2CK +Copyright (c) 2011-2023, Cédric Krier +Copyright (c) 2013-2023, Nicolas Évrard +Copyright (c) 2011-2023, B2CK All rights reserved. Redistribution and use in source and binary forms, with or without From f91490a8576c588066b8eda65ca9dab527c8dffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Fri, 16 Jun 2023 17:14:07 +0200 Subject: [PATCH 11/79] Added tag 1.4.1 for changeset e71bbae3398c --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index aefc730..a2bd52b 100644 --- a/.hgtags +++ b/.hgtags @@ -14,3 +14,4 @@ b2bcc0f71f6881316c11330c07de34113f088888 1.2.1 1c38ffeacbb82a9ff6ae3568cdc017dbbeddff5d 1.2.2 edc03ee84f0ac96d403d8f984d59fffa3274cd2f 1.3.0 a317c40a4d60089ba9e465fbd64b78df24f9e890 1.4.0 +e71bbae3398cb6a0e72f97a0cada9fcdee2bddea 1.4.1 From 6d7ad470205755b59dd5f77a809e18a887a99362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Fri, 16 Jun 2023 17:18:45 +0200 Subject: [PATCH 12/79] Increase version number --- sql/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/__init__.py b/sql/__init__.py index 5959097..9420240 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -7,7 +7,7 @@ from itertools import chain from threading import current_thread, local -__version__ = '1.4.1' +__version__ = '1.4.2' __all__ = ['Flavor', 'Table', 'Values', 'Literal', 'Column', 'Join', 'Asc', 'Desc', 'NullsFirst', 'NullsLast', 'format2numeric'] From 98ace00aa072d2bd307f2bdab8ab423ee204710d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 18 Jun 2023 10:34:46 +0200 Subject: [PATCH 13/79] Restore usage of alias in returning expression Backed out changeset 3af6e0288aed Closes #86 --- CHANGELOG | 2 ++ sql/__init__.py | 27 +++++++++++++-------------- sql/tests/test_insert.py | 6 +++--- sql/tests/test_update.py | 4 ++-- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 78d7837..cdf8357 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +* Restore usage of alias in returning expression + Version 1.4.1 - 2023-06-16 * Do not use alias in returning expression diff --git a/sql/__init__.py b/sql/__init__.py index 9420240..c8f3a00 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -719,16 +719,14 @@ def returning(self, value): assert isinstance(value, list) self._returning = value - def _format(self, value, param=None): + @staticmethod + def _format(value, param=None): if param is None: param = Flavor.get().param - if isinstance(value, Column) and value.table == self.table: - return value.column_name - elif isinstance(value, Expression): + if isinstance(value, Expression): return str(value) elif isinstance(value, Select): - with AliasManager(): - return '(%s)' % value + return '(%s)' % value else: return param @@ -739,16 +737,16 @@ def __str__(self): # Get columns without alias columns = ', '.join(c.column_name for c in self.columns) columns = ' (' + columns + ')' - returning = '' - if self.returning: - returning = ' RETURNING ' + ', '.join( - map(self._format, self.returning)) with AliasManager(): if isinstance(self.values, Query): values = ' %s' % str(self.values) # TODO manage DEFAULT elif self.values is None: values = ' DEFAULT VALUES' + returning = '' + if self.returning: + returning = ' RETURNING ' + ', '.join( + map(self._format, self.returning)) return (self._with_str() + 'INSERT INTO %s AS "%s"' % (self.table, self.table.alias) + columns + values + returning) @@ -802,10 +800,7 @@ def __str__(self): assert all(col.table == self.table for col in self.columns) # Get columns without alias columns = [c.column_name for c in self.columns] - returning = '' - if self.returning: - returning = ' RETURNING ' + ', '.join( - map(self._format, self.returning)) + with AliasManager(): from_ = '' if self.from_: @@ -815,6 +810,10 @@ def __str__(self): where = '' if self.where: where = ' WHERE ' + str(self.where) + returning = '' + if self.returning: + returning = ' RETURNING ' + ', '.join( + map(self._format, self.returning)) return (self._with_str() + 'UPDATE %s AS "%s" SET ' % (self.table, self.table.alias) + values + from_ + where + returning) diff --git a/sql/tests/test_insert.py b/sql/tests/test_insert.py index 059db4b..1921bf8 100644 --- a/sql/tests/test_insert.py +++ b/sql/tests/test_insert.py @@ -49,7 +49,7 @@ def test_insert_returning(self): [['foo', 'bar']], returning=[self.table.c1, self.table.c2]) self.assertEqual(str(query), 'INSERT INTO "t" AS "a" ("c1", "c2") VALUES (%s, %s) ' - 'RETURNING "c1", "c2"') + 'RETURNING "a"."c1", "a"."c2"') self.assertEqual(tuple(query.params), ('foo', 'bar')) def test_insert_returning_select(self): @@ -59,7 +59,7 @@ def test_insert_returning_select(self): returning=[ t2.select(t2.c, where=(t2.c1 == t1.c) & (t2.c2 == 'bar'))]) self.assertEqual(str(query), - 'INSERT INTO "t1" AS "a" ("c") VALUES (%s) ' + 'INSERT INTO "t1" AS "b" ("c") VALUES (%s) ' 'RETURNING (SELECT "a"."c" FROM "t2" AS "a" ' 'WHERE (("a"."c1" = "b"."c") AND ("a"."c2" = %s)))') self.assertEqual(tuple(query.params), ('foo', 'bar')) @@ -92,7 +92,7 @@ def test_insert_in_with(self): self.assertEqual(str(query), 'WITH "a" AS (' 'INSERT INTO "t" AS "b" ("c1") VALUES (%s) ' - 'RETURNING "id") ' + 'RETURNING "b"."id") ' 'UPDATE "t1" AS "c" SET "c" = "a"."id" FROM "a" AS "a"') self.assertEqual(tuple(query.params), ('foo',)) diff --git a/sql/tests/test_update.py b/sql/tests/test_update.py index e1bee32..89f03f1 100644 --- a/sql/tests/test_update.py +++ b/sql/tests/test_update.py @@ -42,7 +42,7 @@ def test_update_returning(self): query = self.table.update([self.table.c], ['foo'], returning=[self.table.c]) self.assertEqual(str(query), - 'UPDATE "t" AS "a" SET "c" = %s RETURNING "c"') + 'UPDATE "t" AS "a" SET "c" = %s RETURNING "a"."c"') self.assertEqual(query.params, ('foo',)) def test_update_returning_select(self): @@ -52,7 +52,7 @@ def test_update_returning_select(self): returning=[ t2.select(t2.c, where=(t2.c1 == t1.c) & (t2.c2 == 'bar'))]) self.assertEqual(str(query), - 'UPDATE "t1" AS "a" SET "c" = %s ' + 'UPDATE "t1" AS "b" SET "c" = %s ' 'RETURNING (SELECT "a"."c" FROM "t2" AS "a" ' 'WHERE (("a"."c1" = "b"."c") AND ("a"."c2" = %s)))') self.assertEqual(query.params, ('foo', 'bar')) From d631426f7ee7582c068b7124a89c9cff7ea0e197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 18 Jun 2023 10:15:44 +0200 Subject: [PATCH 14/79] Format returning expression of Delete --- sql/__init__.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/sql/__init__.py b/sql/__init__.py index c8f3a00..0aa7d01 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -882,6 +882,17 @@ def returning(self, value): assert isinstance(value, list) self._returning = value + @staticmethod + def _format(value, param=None): + if param is None: + param = Flavor.get().param + if isinstance(value, Expression): + return str(value) + elif isinstance(value, Select): + return '(%s)' % value + else: + return param + def __str__(self): with AliasManager(exclude=[self.table]): only = ' ONLY' if self.only else '' @@ -890,7 +901,8 @@ def __str__(self): where = ' WHERE ' + str(self.where) returning = '' if self.returning: - returning = ' RETURNING ' + ', '.join(map(str, self.returning)) + returning = ' RETURNING ' + ', '.join( + map(self._format, self.returning)) return (self._with_str() + 'DELETE FROM%s %s' % (only, self.table) + where + returning) From 310ec3e869a94c8ba4d29ea7472d4f3a5fe42cdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 25 Jun 2023 11:03:55 +0200 Subject: [PATCH 15/79] Prepare release --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index cdf8357..bcf8923 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +Version 1.4.2 - 2023-06-25 * Restore usage of alias in returning expression Version 1.4.1 - 2023-06-16 From 3c9d2d3ef7ab3803230011fde4d1646935c271bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 25 Jun 2023 11:04:06 +0200 Subject: [PATCH 16/79] Added tag 1.4.2 for changeset fcb64787b51d --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index a2bd52b..3a72176 100644 --- a/.hgtags +++ b/.hgtags @@ -15,3 +15,4 @@ b2bcc0f71f6881316c11330c07de34113f088888 1.2.1 edc03ee84f0ac96d403d8f984d59fffa3274cd2f 1.3.0 a317c40a4d60089ba9e465fbd64b78df24f9e890 1.4.0 e71bbae3398cb6a0e72f97a0cada9fcdee2bddea 1.4.1 +fcb64787b51db2068061eb4aa13825abc1134916 1.4.2 From 5dd171f83e650c78eb43980e913df7062d3b135f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 25 Jun 2023 11:06:31 +0200 Subject: [PATCH 17/79] Increase version number --- sql/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/__init__.py b/sql/__init__.py index 0aa7d01..3184ceb 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -7,7 +7,7 @@ from itertools import chain from threading import current_thread, local -__version__ = '1.4.2' +__version__ = '1.4.3' __all__ = ['Flavor', 'Table', 'Values', 'Literal', 'Column', 'Join', 'Asc', 'Desc', 'NullsFirst', 'NullsLast', 'format2numeric'] From 7cdb54e40a60204475a37a8498a1c5b96c5a90e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sat, 22 Jul 2023 19:42:45 +0200 Subject: [PATCH 18/79] Always run checks --- .gitlab-ci.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 69e52e9..502b1b6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -10,9 +10,6 @@ stages: .check: stage: check - rules: - - if: $CI_MERGE_REQUEST_ID != null - when: always image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/tryton/ci check-flake8: @@ -35,8 +32,6 @@ check-dist: .test: stage: test - rules: - - when: always .test-tox: extends: .test From a98ae31760047013c984f9e78227ba16dc4dd21d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sat, 7 Oct 2023 15:09:00 +0200 Subject: [PATCH 19/79] Add support for Python 3.12 --- .gitlab-ci.yml | 2 +- CHANGELOG | 2 ++ setup.py | 1 + tox.ini | 2 +- 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 502b1b6..3fce2a1 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -57,7 +57,7 @@ test-tox-python: - tox -e "py${PYTHON_VERSION/./}" -vv -- -v --output-file junit.xml parallel: matrix: - - PYTHON_VERSION: ["3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] + - PYTHON_VERSION: ["3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] test-tox-pypy: extends: .test-tox diff --git a/CHANGELOG b/CHANGELOG index bcf8923..f2203ea 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +* Add support for Python 3.12 + Version 1.4.2 - 2023-06-25 * Restore usage of alias in returning expression diff --git a/setup.py b/setup.py index 52184c9..a823dec 100644 --- a/setup.py +++ b/setup.py @@ -47,6 +47,7 @@ def get_version(): 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', 'Topic :: Database', 'Topic :: Software Development :: Libraries :: Python Modules', ], diff --git a/tox.ini b/tox.ini index 358ee2e..a28c222 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = py35, py36, py37, py38, py39, py310, pypy3 +envlist = py35, py36, py37, py38, py39, py310, py311, py312, pypy3 [testenv] usedevelop = true From 593600f0912e0eab4309cdf592a4f67ba75eb41b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 10 Dec 2023 23:52:59 +0100 Subject: [PATCH 20/79] Render common table expression in combining query Closes #89 --- CHANGELOG | 1 + sql/__init__.py | 16 ++++++++++------ sql/tests/test_combining_query.py | 14 +++++++++++++- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index f2203ea..6b27358 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Render common table expression in combining query * Add support for Python 3.12 Version 1.4.2 - 2023-06-25 diff --git a/sql/__init__.py b/sql/__init__.py index 3184ceb..de4b4fe 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -932,17 +932,21 @@ def __init__(self, *queries, **kwargs): def __str__(self): with AliasManager(): operator = ' %s %s' % (self._operator, 'ALL ' if self.all_ else '') - return (operator.join(map(str, self.queries)) + self._order_by_str + return ( + self._with_str() + + operator.join(map(str, self.queries)) + self._order_by_str + self._limit_offset_str) @property def params(self): p = [] - for q in self.queries: - p.extend(q.params) - if self.order_by: - for expression in self.order_by: - p.extend(expression.params) + with AliasManager(): + p.extend(self._with_params()) + for q in self.queries: + p.extend(q.params) + if self.order_by: + for expression in self.order_by: + p.extend(expression.params) return tuple(p) diff --git a/sql/tests/test_combining_query.py b/sql/tests/test_combining_query.py index 2d6ca47..ff7c680 100644 --- a/sql/tests/test_combining_query.py +++ b/sql/tests/test_combining_query.py @@ -2,7 +2,7 @@ # this repository contains the full copyright notices and license terms. import unittest -from sql import Table, Union +from sql import Table, Union, With class TestUnion(unittest.TestCase): @@ -21,6 +21,18 @@ def test_union2(self): 'SELECT * FROM "t1" AS "a" UNION SELECT * FROM "t2" AS "b"') self.assertEqual(tuple(query.params), ()) + def test_union_with(self): + table = Table('t') + with_ = With() + with_.query = table.select(table.id, where=table.id == 1) + query = Union(self.q1, self.q2, with_=with_) + + self.assertEqual(str(query), + 'WITH "a" AS (' + 'SELECT "b"."id" FROM "t" AS "b" WHERE ("b"."id" = %s)) ' + 'SELECT * FROM "t1" AS "c" UNION SELECT * FROM "t2" AS "d"') + self.assertEqual(tuple(query.params), (1,)) + def test_union3(self): query = Union(self.q1, self.q2, self.q3) self.assertEqual(str(query), From d4e39a69cf8c77fb21ec83e9ef8ab3a1ac092c55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sat, 30 Dec 2023 18:19:27 +0100 Subject: [PATCH 21/79] Prepare release --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 6b27358..d5779da 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +Version 1.4.3 - 2023-12-30 * Render common table expression in combining query * Add support for Python 3.12 From bb879d7656ca62dd2377e663ffe2ee40e55168fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sat, 30 Dec 2023 18:23:52 +0100 Subject: [PATCH 22/79] Added tag 1.4.3 for changeset 111e3e868653 --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index 3a72176..c403651 100644 --- a/.hgtags +++ b/.hgtags @@ -16,3 +16,4 @@ edc03ee84f0ac96d403d8f984d59fffa3274cd2f 1.3.0 a317c40a4d60089ba9e465fbd64b78df24f9e890 1.4.0 e71bbae3398cb6a0e72f97a0cada9fcdee2bddea 1.4.1 fcb64787b51db2068061eb4aa13825abc1134916 1.4.2 +111e3e86865360f83a65c04fa48c55f3d2957ee3 1.4.3 From 71ac81adf423e20d59a205361d2f0c9700f3f35e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sat, 30 Dec 2023 18:24:11 +0100 Subject: [PATCH 23/79] Increase version number --- sql/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/__init__.py b/sql/__init__.py index de4b4fe..e9f0e56 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -7,7 +7,7 @@ from itertools import chain from threading import current_thread, local -__version__ = '1.4.3' +__version__ = '1.4.4' __all__ = ['Flavor', 'Table', 'Values', 'Literal', 'Column', 'Join', 'Asc', 'Desc', 'NullsFirst', 'NullsLast', 'format2numeric'] From bc6b084478aa5776ac04ad128e75754f57985a00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Wed, 17 Jan 2024 10:45:06 +0100 Subject: [PATCH 24/79] Reduce tox verbosity --- .gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3fce2a1..2246f77 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -54,7 +54,7 @@ test-tox-python: extends: .test-tox image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/python:${PYTHON_VERSION} script: - - tox -e "py${PYTHON_VERSION/./}" -vv -- -v --output-file junit.xml + - tox -e "py${PYTHON_VERSION/./}" -- -v --output-file junit.xml parallel: matrix: - PYTHON_VERSION: ["3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] @@ -63,4 +63,4 @@ test-tox-pypy: extends: .test-tox image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/pypy:3 script: - - tox -e pypy3 -vv -- -v --output-file junit.xml + - tox -e pypy3 -- -v --output-file junit.xml From 46a7f24e479fc024086d310eb9f2466e52fb2710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 28 Jan 2024 16:59:40 +0100 Subject: [PATCH 25/79] Ignore README with rst extension in test coverage --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index a28c222..f65a90c 100644 --- a/tox.ini +++ b/tox.ini @@ -11,8 +11,8 @@ usedevelop = true commands = coverage run --omit=*/tests/* -m xmlrunner discover -s sql.tests {posargs} commands_post = - coverage report --omit=README - coverage xml --omit=README + coverage report --omit=README.rst + coverage xml --omit=README.rst deps = coverage unittest-xml-reporting From dc106f3b6acc0f1c25ac12d3a20edb586ff1322d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 14 Jan 2024 20:55:57 +0100 Subject: [PATCH 26/79] Add GROUPING SETS, CUBE, and ROLLUP Closes #56 --- CHANGELOG | 2 ++ sql/__init__.py | 78 ++++++++++++++++++++++++++++++++++++++-- sql/tests/test_select.py | 48 ++++++++++++++++++++++++- 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index d5779da..de3c89c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +* Add GROUPING SETS, CUBE, and ROLLUP + Version 1.4.3 - 2023-12-30 * Render common table expression in combining query * Add support for Python 3.12 diff --git a/sql/__init__.py b/sql/__init__.py index e9f0e56..24b4fe1 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -8,8 +8,9 @@ from threading import current_thread, local __version__ = '1.4.4' -__all__ = ['Flavor', 'Table', 'Values', 'Literal', 'Column', 'Join', - 'Asc', 'Desc', 'NullsFirst', 'NullsLast', 'format2numeric'] +__all__ = [ + 'Flavor', 'Table', 'Values', 'Literal', 'Column', 'Grouping', 'Rollup', + 'Cube', 'Join', 'Asc', 'Desc', 'NullsFirst', 'NullsLast', 'format2numeric'] def _escape_identifier(name): @@ -1442,6 +1443,79 @@ def params(self): return (self.expression,) +class Grouping(Expression): + __slots__ = ('_sets',) + + def __init__(self, *sets): + super().__init__() + self.sets = sets + + @property + def sets(self): + return self._sets + + @sets.setter + def sets(self, value): + assert all( + isinstance(col, Expression) for cols in value for col in cols) + self._sets = tuple(tuple(cols) for cols in value) + + def __str__(self): + return 'GROUPING SETS (%s)' % ( + ', '.join( + '(%s)' % ', '.join(str(col) for col in cols) + for cols in self.sets)) + + @property + def params(self): + return sum((col.params for cols in self.sets for col in cols), ()) + + +class Rollup(Expression): + __slots__ = ('_expressions',) + + def __init__(self, *expressions): + super().__init__() + self.expressions = expressions + + @property + def expressions(self): + return self._expressions + + @expressions.setter + def expressions(self, value): + assert all( + isinstance(col, Expression) + or all(isinstance(c, Expression) for c in col) + for col in value) + self._expressions = tuple(value) + + def __str__(self): + def format(col): + if isinstance(col, Expression): + return str(col) + else: + return '(%s)' % ', '.join(str(c) for c in col) + return '%s (%s)' % ( + self.__class__.__name__.upper(), + ', '.join(format(col) for col in self.expressions)) + + @property + def params(self): + p = [] + for col in self.expressions: + if isinstance(col, Expression): + p.extend(col.params) + else: + for c in col: + p.extend(c.params) + return tuple(p) + + +class Cube(Rollup): + pass + + class Window(object): __slots__ = ( '_partition', '_order_by', '_frame', '_start', '_end', '_exclude') diff --git a/sql/tests/test_select.py b/sql/tests/test_select.py index a41332a..ee047d1 100644 --- a/sql/tests/test_select.py +++ b/sql/tests/test_select.py @@ -4,7 +4,9 @@ import warnings from copy import deepcopy -from sql import Flavor, For, Join, Literal, Select, Table, Union, Window, With +from sql import ( + Cube, Flavor, For, Grouping, Join, Literal, Rollup, Select, Table, Union, + Window, With) from sql.aggregate import Max, Min from sql.functions import DatePart, Function, Now, Rank @@ -185,6 +187,50 @@ def test_select_group_by(self): 'SELECT %s FROM "t" AS "a" GROUP BY %s') self.assertEqual(tuple(query.params), ('foo', 'foo')) + def test_select_group_by_grouping_sets(self): + query = self.table.select( + Literal('*'), + group_by=Grouping((self.table.a, self.table.b), (Literal('foo'),))) + self.assertEqual(str(query), + 'SELECT %s FROM "t" AS "a" ' + 'GROUP BY GROUPING SETS (("a"."a", "a"."b"), (%s))') + self.assertEqual(tuple(query.params), ('*', 'foo',)) + + query = self.table.select( + Literal('*'), + group_by=[ + self.table.a, Grouping((self.table.b,), (self.table.c,))]) + self.assertEqual(str(query), + 'SELECT %s FROM "t" AS "a" ' + 'GROUP BY "a"."a", GROUPING SETS (("a"."b"), ("a"."c"))') + self.assertEqual(tuple(query.params), ('*',)) + + def test_select_group_by_rollup(self): + query = self.table.select( + Literal('*'), + group_by=Rollup(self.table.a, self.table.b, Literal('foo'))) + self.assertEqual(str(query), + 'SELECT %s FROM "t" AS "a" ' + 'GROUP BY ROLLUP ("a"."a", "a"."b", %s)') + self.assertEqual(tuple(query.params), ('*', 'foo')) + + query = self.table.select( + Literal('*'), + group_by=Rollup((self.table.a, self.table.b), self.table.c)) + self.assertEqual(str(query), + 'SELECT %s FROM "t" AS "a" ' + 'GROUP BY ROLLUP (("a"."a", "a"."b"), "a"."c")') + self.assertEqual(tuple(query.params), ('*',)) + + def test_select_group_by_cube(self): + query = self.table.select( + Literal('*'), + group_by=Cube(self.table.a, self.table.b)) + self.assertEqual(str(query), + 'SELECT %s FROM "t" AS "a" ' + 'GROUP BY CUBE ("a"."a", "a"."b")') + self.assertEqual(tuple(query.params), ('*',)) + def test_select_having(self): col1 = self.table.col1 col2 = self.table.col2 From 01734e72c5fe053ea9888f67684327bfc5db514b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 14 Jan 2024 13:05:32 +0100 Subject: [PATCH 27/79] Remove default escape char on LIKE and ILIKE SQL-92 does not define a default escape char but some databases comply with the standard and others set a default value. So it is more portable to always define explicitly the escape char. Closes #88 --- CHANGELOG | 2 ++ sql/operators.py | 4 ++-- sql/tests/test_operators.py | 24 ++++++++++++------------ 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index de3c89c..ed4c456 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +* Remove default escape char on LIKE and ILIKE + * Add GROUPING SETS, CUBE, and ROLLUP Version 1.4.3 - 2023-12-30 diff --git a/sql/operators.py b/sql/operators.py index f0feda5..2497ea8 100644 --- a/sql/operators.py +++ b/sql/operators.py @@ -377,7 +377,7 @@ class Like(BinaryOperator): __slots__ = 'escape' _operator = 'LIKE' - def __init__(self, left, right, escape='\\'): + def __init__(self, left, right, escape=None): super().__init__(left, right) assert not escape or len(escape) == 1 self.escape = escape @@ -386,7 +386,7 @@ def __init__(self, left, right, escape='\\'): def params(self): params = super().params if self.escape or Flavor().get().escape_empty: - params += (self.escape,) + params += (self.escape or '',) return params def __str__(self): diff --git a/sql/tests/test_operators.py b/sql/tests/test_operators.py index b126454..ac862e9 100644 --- a/sql/tests/test_operators.py +++ b/sql/tests/test_operators.py @@ -287,8 +287,8 @@ def test_like(self): self.table.c1.like('foo'), ~NotLike(self.table.c1, 'foo'), ~~Like(self.table.c1, 'foo')]: - self.assertEqual(str(like), '("c1" LIKE %s ESCAPE %s)') - self.assertEqual(like.params, ('foo', '\\')) + self.assertEqual(str(like), '("c1" LIKE %s)') + self.assertEqual(like.params, ('foo',)) def test_like_escape(self): like = Like(self.table.c1, 'foo', escape='$') @@ -299,7 +299,7 @@ def test_like_escape_empty_false(self): flavor = Flavor(escape_empty=False) Flavor.set(flavor) try: - like = Like(self.table.c1, 'foo', escape='') + like = Like(self.table.c1, 'foo') self.assertEqual(str(like), '("c1" LIKE %s)') self.assertEqual(like.params, ('foo',)) finally: @@ -309,7 +309,7 @@ def test_like_escape_empty_true(self): flavor = Flavor(escape_empty=True) Flavor.set(flavor) try: - like = Like(self.table.c1, 'foo', escape='') + like = Like(self.table.c1, 'foo') self.assertEqual(str(like), '("c1" LIKE %s ESCAPE %s)') self.assertEqual(like.params, ('foo', '')) finally: @@ -322,8 +322,8 @@ def test_ilike(self): for like in [ILike(self.table.c1, 'foo'), self.table.c1.ilike('foo'), ~NotILike(self.table.c1, 'foo')]: - self.assertEqual(str(like), '("c1" ILIKE %s ESCAPE %s)') - self.assertEqual(like.params, ('foo', '\\')) + self.assertEqual(str(like), '("c1" ILIKE %s)') + self.assertEqual(like.params, ('foo',)) finally: Flavor.set(Flavor()) @@ -332,8 +332,8 @@ def test_ilike(self): try: like = ILike(self.table.c1, 'foo') self.assertEqual( - str(like), '(UPPER("c1") LIKE UPPER(%s) ESCAPE %s)') - self.assertEqual(like.params, ('foo', '\\')) + str(like), '(UPPER("c1") LIKE UPPER(%s))') + self.assertEqual(like.params, ('foo',)) finally: Flavor.set(Flavor()) @@ -343,8 +343,8 @@ def test_not_ilike(self): try: for like in [NotILike(self.table.c1, 'foo'), ~self.table.c1.ilike('foo')]: - self.assertEqual(str(like), '("c1" NOT ILIKE %s ESCAPE %s)') - self.assertEqual(like.params, ('foo', '\\')) + self.assertEqual(str(like), '("c1" NOT ILIKE %s)') + self.assertEqual(like.params, ('foo',)) finally: Flavor.set(Flavor()) @@ -353,8 +353,8 @@ def test_not_ilike(self): try: like = NotILike(self.table.c1, 'foo') self.assertEqual( - str(like), '(UPPER("c1") NOT LIKE UPPER(%s) ESCAPE %s)') - self.assertEqual(like.params, ('foo', '\\')) + str(like), '(UPPER("c1") NOT LIKE UPPER(%s))') + self.assertEqual(like.params, ('foo',)) finally: Flavor.set(Flavor()) From 6bc9c9e05ce986dd2269b21b1d8604bb508b56e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 1 Apr 2024 11:55:52 +0200 Subject: [PATCH 28/79] Remove empty line in CHANGELOG --- CHANGELOG | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index ed4c456..26169ff 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,4 @@ * Remove default escape char on LIKE and ILIKE - * Add GROUPING SETS, CUBE, and ROLLUP Version 1.4.3 - 2023-12-30 From 4c9be690c8619e2ec1c5d8ed74784f53f342e4a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Tue, 23 Apr 2024 19:10:29 +0200 Subject: [PATCH 29/79] Do not test params attribute but catch exception Testing for params attribute multiples the computation badly in case of nested joins. It is better to just catch the AttributeError to avoid introduce a factorial execution time. Closes #90 --- sql/__init__.py | 8 ++++++-- sql/functions.py | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/sql/__init__.py b/sql/__init__.py index 24b4fe1..8358cf5 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -1071,10 +1071,14 @@ def __str__(self): def params(self): p = [] for item in (self.left, self.right): - if hasattr(item, 'params'): + try: p.extend(item.params) - if hasattr(self.condition, 'params'): + except AttributeError: + pass + try: p.extend(self.condition.params) + except AttributeError: + pass return tuple(p) @property diff --git a/sql/functions.py b/sql/functions.py index 2d19db5..83e5bcf 100644 --- a/sql/functions.py +++ b/sql/functions.py @@ -315,8 +315,11 @@ def params(self): for arg in (self.characters, self.string): if isinstance(arg, str): p.append(arg) - elif hasattr(arg, 'params'): - p.extend(arg.params) + else: + try: + p.extend(arg.params) + except AttributeError: + pass return tuple(p) From 2ce2ecbe6045b488b979fd9dab2a9de41815dccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 1 Apr 2024 14:14:33 +0200 Subject: [PATCH 30/79] Add support for UPSERT Closes #57 --- CHANGELOG | 1 + sql/__init__.py | 189 +++++++++++++++++++++++++++++++++++++-- sql/tests/test_insert.py | 95 +++++++++++++++++++- 3 files changed, 276 insertions(+), 9 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 26169ff..281f543 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Support UPSERT * Remove default escape char on LIKE and ILIKE * Add GROUPING SETS, CUBE, and ROLLUP diff --git a/sql/__init__.py b/sql/__init__.py index 8358cf5..6f9d182 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -9,8 +9,9 @@ __version__ = '1.4.4' __all__ = [ - 'Flavor', 'Table', 'Values', 'Literal', 'Column', 'Grouping', 'Rollup', - 'Cube', 'Join', 'Asc', 'Desc', 'NullsFirst', 'NullsLast', 'format2numeric'] + 'Flavor', 'Table', 'Values', 'Literal', 'Column', 'Grouping', 'Conflict', + 'Rollup', 'Cube', 'Excluded', 'Join', 'Asc', 'Desc', 'NullsFirst', + 'NullsLast', 'format2numeric'] def _escape_identifier(name): @@ -664,17 +665,20 @@ def params(self): class Insert(WithQuery): - __slots__ = ('_table', '_columns', '_values', '_returning') + __slots__ = ('_table', '_columns', '_values', '_on_conflict', '_returning') - def __init__(self, table, columns=None, values=None, returning=None, - **kwargs): + def __init__( + self, table, columns=None, values=None, returning=None, + on_conflict=None, **kwargs): self._table = None self._columns = None self._values = None + self._on_conflict = None self._returning = None self.table = table self.columns = columns self.values = values + self.on_conflict = on_conflict self.returning = returning super(Insert, self).__init__(**kwargs) @@ -710,6 +714,17 @@ def values(self, value): value = Values(value) self._values = value + @property + def on_conflict(self): + return self._on_conflict + + @on_conflict.setter + def on_conflict(self, value): + if value is not None: + assert isinstance(value, Conflict) + assert value.table == self.table + self._on_conflict = value + @property def returning(self): return self._returning @@ -744,13 +759,16 @@ def __str__(self): # TODO manage DEFAULT elif self.values is None: values = ' DEFAULT VALUES' + on_conflict = '' + if self.on_conflict: + on_conflict = ' %s' % self.on_conflict returning = '' if self.returning: returning = ' RETURNING ' + ', '.join( map(self._format, self.returning)) return (self._with_str() + 'INSERT INTO %s AS "%s"' % (self.table, self.table.alias) - + columns + values + returning) + + columns + values + on_conflict + returning) @property def params(self): @@ -758,12 +776,149 @@ def params(self): p.extend(self._with_params()) if isinstance(self.values, Query): p.extend(self.values.params) + if self.on_conflict: + p.extend(self.on_conflict.params) if self.returning: for exp in self.returning: p.extend(exp.params) return tuple(p) +class Conflict(object): + __slots__ = ( + '_table', '_indexed_columns', '_index_where', '_columns', '_values', + '_where') + + def __init__( + self, table, indexed_columns=None, index_where=None, + columns=None, values=None, where=None): + self._table = None + self._indexed_columns = None + self._index_where = None + self._columns = None + self._values = None + self._where = None + self.table = table + self.indexed_columns = indexed_columns + self.index_where = index_where + self.columns = columns + self.values = values + self.where = where + + @property + def table(self): + return self._table + + @table.setter + def table(self, value): + assert isinstance(value, Table) + self._table = value + + @property + def indexed_columns(self): + return self._indexed_columns + + @indexed_columns.setter + def indexed_columns(self, value): + if value is not None: + assert all(isinstance(col, Column) for col in value) + assert all(col.table == self.table for col in value) + self._indexed_columns = value + + @property + def index_where(self): + return self._index_where + + @index_where.setter + def index_where(self, value): + from sql.operators import And, Or + if value is not None: + assert isinstance(value, (Expression, And, Or)) + self._index_where = value + + @property + def columns(self): + return self._columns + + @columns.setter + def columns(self, value): + if value is not None: + assert all(isinstance(col, Column) for col in value) + assert all(col.table == self.table for col in value) + self._columns = value + + @property + def values(self): + return self._values + + @values.setter + def values(self, value): + if value is not None: + assert isinstance(value, (list, Select)) + if isinstance(value, list): + value = Values([value]) + self._values = value + + @property + def where(self): + return self._where + + @where.setter + def where(self, value): + from sql.operators import And, Or + if value is not None: + assert isinstance(value, (Expression, And, Or)) + self._where = value + + def __str__(self): + indexed_columns = '' + if self.indexed_columns: + assert all(c.table == self.table for c in self.indexed_columns) + # Get columns without alias + indexed_columns = ', '.join( + c.column_name for c in self.indexed_columns) + indexed_columns = ' (' + indexed_columns + ')' + if self.index_where: + indexed_columns += ' WHERE ' + str(self.index_where) + else: + assert not self.index_where + do = '' + if not self.columns: + assert not self.values + assert not self.where + do = 'NOTHING' + else: + assert all(c.table == self.table for c in self.columns) + # Get columns without alias + do = ', '.join(c.column_name for c in self.columns) + # TODO manage DEFAULT + values = str(self.values) + if values.startswith('VALUES'): + values = values[len('VALUES'):] + else: + values = ' (' + values + ')' + if len(self.columns) == 1: + # PostgreSQL would require ROW expression + # with single column with parenthesis + do = 'UPDATE SET ' + do + ' =' + values + else: + do = 'UPDATE SET (' + do + ') =' + values + if self.where: + do += ' WHERE %s' % self.where + return 'ON CONFLICT' + indexed_columns + ' DO ' + do + + @property + def params(self): + p = [] + if self.index_where: + p.extend(self.index_where.params) + if self.values: + p.extend(self.values.params) + if self.where: + p.extend(self.where.params) + return p + + class Update(Insert): __slots__ = ('_where', '_values', 'from_') @@ -990,9 +1145,11 @@ def __str__(self): def params(self): return () - def insert(self, columns=None, values=None, returning=None, with_=None): + def insert( + self, columns=None, values=None, returning=None, with_=None, + on_conflict=None): return Insert(self, columns=columns, values=values, - returning=returning, with_=with_) + on_conflict=on_conflict, returning=returning, with_=with_) def update(self, columns, values, from_=None, where=None, returning=None, with_=None): @@ -1005,6 +1162,22 @@ def delete(self, only=False, using=None, where=None, returning=None, returning=returning, with_=with_) +class _Excluded(Table): + def __init__(self): + super().__init__('EXCLUDED') + + @property + def alias(self): + return 'EXCLUDED' + + @property + def has_alias(self): + return False + + +Excluded = _Excluded() + + class Join(FromItem): __slots__ = ('_left', '_right', '_condition', '_type_') diff --git a/sql/tests/test_insert.py b/sql/tests/test_insert.py index 1921bf8..d4d83e8 100644 --- a/sql/tests/test_insert.py +++ b/sql/tests/test_insert.py @@ -2,7 +2,7 @@ # this repository contains the full copyright notices and license terms. import unittest -from sql import Table, With +from sql import Conflict, Excluded, Table, With from sql.functions import Abs @@ -103,3 +103,96 @@ def test_schema(self): self.assertEqual(str(query), 'INSERT INTO "default"."t1" AS "a" ("c1") VALUES (%s)') self.assertEqual(tuple(query.params), ('foo',)) + + def test_upsert_nothing(self): + query = self.table.insert( + [self.table.c1], [['foo']], + on_conflict=Conflict(self.table)) + + self.assertEqual(str(query), + 'INSERT INTO "t" AS "a" ("c1") VALUES (%s) ' + 'ON CONFLICT DO NOTHING') + self.assertEqual(tuple(query.params), ('foo',)) + + def test_upsert_indexed_column(self): + query = self.table.insert( + [self.table.c1], [['foo']], + on_conflict=Conflict( + self.table, + indexed_columns=[self.table.c1, self.table.c2])) + + self.assertEqual(str(query), + 'INSERT INTO "t" AS "a" ("c1") VALUES (%s) ' + 'ON CONFLICT ("c1", "c2") DO NOTHING') + self.assertEqual(tuple(query.params), ('foo',)) + + def test_upsert_indexed_column_index_where(self): + query = self.table.insert( + [self.table.c1], [['foo']], + on_conflict=Conflict( + self.table, + indexed_columns=[self.table.c1], + index_where=self.table.c2 == 'bar')) + + self.assertEqual(str(query), + 'INSERT INTO "t" AS "a" ("c1") VALUES (%s) ' + 'ON CONFLICT ("c1") WHERE ("a"."c2" = %s) DO NOTHING') + self.assertEqual(tuple(query.params), ('foo', 'bar')) + + def test_upsert_update(self): + query = self.table.insert( + [self.table.c1], [['baz']], + on_conflict=Conflict( + self.table, + columns=[self.table.c1, self.table.c2], + values=['foo', 'bar'])) + + self.assertEqual(str(query), + 'INSERT INTO "t" AS "a" ("c1") VALUES (%s) ' + 'ON CONFLICT DO UPDATE SET ("c1", "c2") = (%s, %s)') + self.assertEqual(tuple(query.params), ('baz', 'foo', 'bar')) + + def test_upsert_update_where(self): + query = self.table.insert( + [self.table.c1], [['baz']], + on_conflict=Conflict( + self.table, + columns=[self.table.c1], + values=['foo'], + where=self.table.c2 == 'bar')) + + self.assertEqual(str(query), + 'INSERT INTO "t" AS "a" ("c1") VALUES (%s) ' + 'ON CONFLICT DO UPDATE SET "c1" = (%s) ' + 'WHERE ("a"."c2" = %s)') + self.assertEqual(tuple(query.params), ('baz', 'foo', 'bar')) + + def test_upsert_update_subquery(self): + t1 = Table('t1') + t2 = Table('t2') + subquery = t2.select(t2.c1, t2.c2) + query = t1.insert( + [t1.c1], [['baz']], + on_conflict=Conflict( + t1, + columns=[t1.c1, t1.c2], + values=subquery)) + + self.assertEqual(str(query), + 'INSERT INTO "t1" AS "b" ("c1") VALUES (%s) ' + 'ON CONFLICT DO UPDATE SET ("c1", "c2") = ' + '(SELECT "a"."c1", "a"."c2" FROM "t2" AS "a")') + self.assertEqual(tuple(query.params), ('baz',)) + + def test_upsert_update_excluded(self): + query = self.table.insert( + [self.table.c1], [[1]], + on_conflict=Conflict( + self.table, + columns=[self.table.c1], + values=[Excluded.c1 + 2])) + + self.assertEqual(str(query), + 'INSERT INTO "t" AS "a" ("c1") VALUES (%s) ' + 'ON CONFLICT DO UPDATE SET "c1" = (("EXCLUDED"."c1" + %s))') + self.assertEqual(tuple(query.params), (1, 2)) From f88ceee8b1d023571866da655bf59eaa2aa3c3a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 6 May 2024 10:05:50 +0200 Subject: [PATCH 31/79] Add MERGE Closes #57 --- CHANGELOG | 1 + sql/__init__.py | 206 ++++++++++++++++++++++++++++++++++++++++ sql/tests/test_merge.py | 111 ++++++++++++++++++++++ 3 files changed, 318 insertions(+) create mode 100644 sql/tests/test_merge.py diff --git a/CHANGELOG b/CHANGELOG index 281f543..016918e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Add MERGE * Support UPSERT * Remove default escape char on LIKE and ILIKE * Add GROUPING SETS, CUBE, and ROLLUP diff --git a/sql/__init__.py b/sql/__init__.py index 6f9d182..57939f4 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -10,6 +10,8 @@ __version__ = '1.4.4' __all__ = [ 'Flavor', 'Table', 'Values', 'Literal', 'Column', 'Grouping', 'Conflict', + 'Matched', 'MatchedUpdate', 'MatchedDelete', + 'NotMatched', 'NotMatchedInsert', 'Rollup', 'Cube', 'Excluded', 'Join', 'Asc', 'Desc', 'NullsFirst', 'NullsLast', 'format2numeric'] @@ -1075,6 +1077,207 @@ def params(self): return tuple(p) +class Merge(WithQuery): + __slots__ = ('_target', '_source', '_condition', '_whens') + + def __init__(self, target, source, condition, *whens, **kwargs): + self._target = None + self._source = None + self._condition = None + self._whens = None + self.target = target + self.source = source + self.condition = condition + self.whens = whens + super().__init__(**kwargs) + + @property + def target(self): + return self._target + + @target.setter + def target(self, value): + assert isinstance(value, Table) + self._target = value + + @property + def source(self): + return self._source + + @source.setter + def source(self, value): + assert isinstance(value, (Table, SelectQuery, Values)) + self._source = value + + @property + def condition(self): + return self._condition + + @condition.setter + def condition(self, value): + assert isinstance(value, Expression) + self._condition = value + + @property + def whens(self): + return self._whens + + @whens.setter + def whens(self, value): + assert all(isinstance(w, Matched) for w in value) + self._whens = tuple(value) + + def __str__(self): + with AliasManager(): + if isinstance(self.source, (Select, Values)): + source = '(%s)' % self.source + else: + source = self.source + if self.condition: + condition = 'ON %s' % self.condition + else: + condition = '' + return (self._with_str() + + 'MERGE INTO %s AS "%s" ' % (self.target, self.target.alias) + + 'USING %s AS "%s" ' % (source, self.source.alias) + + condition + ' ' + ' '.join(map(str, self.whens))) + + @property + def params(self): + p = [] + p.extend(self._with_params()) + if isinstance(self.source, (SelectQuery, Values)): + p.extend(self.source.params) + if self.condition: + p.extend(self.condition.params) + for match in self.whens: + p.extend(match.params) + return tuple(p) + + +class Matched(object): + __slots__ = ('_condition',) + _when = 'MATCHED' + + def __init__(self, condition=None): + self._condition = None + self.condition = condition + + @property + def condition(self): + return self._condition + + @condition.setter + def condition(self, value): + if value is not None: + assert isinstance(value, Expression) + self._condition = value + + def _then_str(self): + return 'DO NOTHING' + + def __str__(self): + if self.condition is not None: + condition = ' AND ' + str(self.condition) + else: + condition = '' + return 'WHEN ' + self._when + condition + ' THEN ' + self._then_str() + + @property + def params(self): + p = [] + if self.condition: + p.extend(self.condition.params) + return tuple(p) + + +class _MatchedValues(Matched): + __slots__ = ('_columns', '_values') + + def __init__(self, columns, values, **kwargs): + self._columns = columns + self._values = values + self.columns = columns + self.values = values + super().__init__(**kwargs) + + @property + def columns(self): + return self._columns + + @columns.setter + def columns(self, value): + assert all(isinstance(col, Column) for col in value) + self._columns = value + + +class MatchedUpdate(_MatchedValues, Matched): + __slots__ = () + + @property + def values(self): + return self._values + + @values.setter + def values(self, value): + self._values = value + + def _then_str(self): + columns = [c.column_name for c in self.columns] + return 'UPDATE SET ' + ', '.join( + '%s = %s' % (c, Update._format(v)) + for c, v in zip(columns, self.values)) + + @property + def params(self): + p = list(super().params) + for value in self.values: + if isinstance(value, (Expression, Select)): + p.extend(value.params) + else: + p.append(value) + return tuple(p) + + +class MatchedDelete(Matched): + __slots__ = () + + def _then_str(self): + return 'DELETE' + + +class NotMatched(Matched): + __slots__ = () + _when = 'NOT MATCHED' + + +class NotMatchedInsert(_MatchedValues, NotMatched): + __slots__ = () + + @property + def values(self): + return self._values + + @values.setter + def values(self, value): + self._values = Values([value]) + + def _then_str(self): + columns = ', '.join(c.column_name for c in self.columns) + columns = '(' + columns + ')' + if self.values is None: + values = ' DEFAULT VALUES ' + else: + values = ' ' + str(self.values) + return 'INSERT ' + columns + values + + @property + def params(self): + p = list(super().params) + p.extend(self.values.params) + return tuple(p) + + class CombiningQuery(FromItem, SelectQuery): __slots__ = ('queries', 'all_') _operator = '' @@ -1161,6 +1364,9 @@ def delete(self, only=False, using=None, where=None, returning=None, return Delete(self, only=only, using=using, where=where, returning=returning, with_=with_) + def merge(self, source, condition, *whens, with_=None): + return Merge(self, source, condition, *whens, with_=with_) + class _Excluded(Table): def __init__(self): diff --git a/sql/tests/test_merge.py b/sql/tests/test_merge.py new file mode 100644 index 0000000..bfeb820 --- /dev/null +++ b/sql/tests/test_merge.py @@ -0,0 +1,111 @@ +# This file is part of python-sql. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. + +import unittest + +from sql import ( + Matched, MatchedDelete, MatchedUpdate, NotMatched, NotMatchedInsert, Table, + With) + + +class TestMerge(unittest.TestCase): + target = Table('t') + source = Table('s') + + def test_merge(self): + query = self.target.merge( + self.source, self.target.c1 == self.source.c2, Matched()) + self.assertEqual( + str(query), + 'MERGE INTO "t" AS "a" USING "s" AS "b" ' + 'ON ("a"."c1" = "b"."c2") ' + 'WHEN MATCHED THEN DO NOTHING') + self.assertEqual(query.params, ()) + + def test_condition(self): + query = self.target.merge( + self.source, + (self.target.c1 == self.source.c2) & (self.target.c3 == 42), + Matched()) + self.assertEqual( + str(query), + 'MERGE INTO "t" AS "a" USING "s" AS "b" ' + 'ON (("a"."c1" = "b"."c2") AND ("a"."c3" = %s)) ' + 'WHEN MATCHED THEN DO NOTHING') + self.assertEqual(query.params, (42,)) + + def test_matched(self): + query = self.target.merge( + self.source, self.target.c1 == self.source.c2, + Matched((self.source.c3 == 42) + & (self.target.c4 == self.source.c5))) + self.assertEqual( + str(query), + 'MERGE INTO "t" AS "a" USING "s" AS "b" ' + 'ON ("a"."c1" = "b"."c2") ' + 'WHEN MATCHED ' + 'AND (("b"."c3" = %s) AND ("a"."c4" = "b"."c5")) ' + 'THEN DO NOTHING') + self.assertEqual(query.params, (42,)) + + def test_matched_update(self): + query = self.target.merge( + self.source, self.target.c1 == self.source.c2, + MatchedUpdate([self.target.c1], [self.target.c1 + self.source.c2])) + self.assertEqual( + str(query), + 'MERGE INTO "t" AS "a" USING "s" AS "b" ' + 'ON ("a"."c1" = "b"."c2") ' + 'WHEN MATCHED THEN ' + 'UPDATE SET "c1" = ("a"."c1" + "b"."c2")') + self.assertEqual(query.params, ()) + + def test_matched_delete(self): + query = self.target.merge( + self.source, self.target.c1 == self.source.c2, MatchedDelete()) + self.assertEqual( + str(query), + 'MERGE INTO "t" AS "a" USING "s" AS "b" ' + 'ON ("a"."c1" = "b"."c2") ' + 'WHEN MATCHED THEN DELETE') + self.assertEqual(query.params, ()) + + def test_not_matched(self): + query = self.target.merge( + self.source, self.target.c1 == self.source.c2, NotMatched()) + self.assertEqual( + str(query), + 'MERGE INTO "t" AS "a" USING "s" AS "b" ' + 'ON ("a"."c1" = "b"."c2") ' + 'WHEN NOT MATCHED THEN DO NOTHING') + self.assertEqual(query.params, ()) + + def test_not_matched_insert(self): + query = self.target.merge( + self.source, self.target.c1 == self.source.c2, + NotMatchedInsert( + [self.target.c1, self.target.c2], + [self.source.c3, self.source.c4])) + self.assertEqual( + str(query), + 'MERGE INTO "t" AS "a" USING "s" AS "b" ' + 'ON ("a"."c1" = "b"."c2") ' + 'WHEN NOT MATCHED THEN ' + 'INSERT ("c1", "c2") VALUES ("b"."c3", "b"."c4")') + self.assertEqual(query.params, ()) + + def test_with(self): + t1 = Table('t1') + w = With(query=t1.select(where=t1.c2 == 42)) + source = w.select() + + query = self.target.merge( + source, self.target.c1 == source.c2, Matched(), with_=[w]) + self.assertEqual( + str(query), + 'WITH "a" AS (SELECT * FROM "t1" AS "d" WHERE ("d"."c2" = %s)) ' + 'MERGE INTO "t" AS "b" ' + 'USING (SELECT * FROM "a" AS "a") AS "c" ' + 'ON ("b"."c1" = "c"."c2") ' + 'WHEN MATCHED THEN DO NOTHING') + self.assertEqual(query.params, (42,)) From 73486d20d4bf3f8fb676b6a2a574b7dea8372c3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 12 May 2024 08:45:43 +0200 Subject: [PATCH 32/79] Skip alias on INSERT without ON CONFLICT or RETURNING The alias is only useful for the ON CONFLICT or RETURNING clauses but some databases do not support alias because they do not support those clauses. Closes #91 --- CHANGELOG | 1 + sql/__init__.py | 6 +++++- sql/tests/test_insert.py | 14 +++++++------- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 016918e..f2b14d5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Skip alias on INSERT without ON CONFLICT or RETURNING * Add MERGE * Support UPSERT * Remove default escape char on LIKE and ILIKE diff --git a/sql/__init__.py b/sql/__init__.py index 57939f4..bd09f98 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -768,8 +768,12 @@ def __str__(self): if self.returning: returning = ' RETURNING ' + ', '.join( map(self._format, self.returning)) + if on_conflict or returning: + table = '%s AS "%s"' % (self.table, self.table.alias) + else: + table = str(self.table) return (self._with_str() - + 'INSERT INTO %s AS "%s"' % (self.table, self.table.alias) + + 'INSERT INTO %s' % table + columns + values + on_conflict + returning) @property diff --git a/sql/tests/test_insert.py b/sql/tests/test_insert.py index d4d83e8..327ab84 100644 --- a/sql/tests/test_insert.py +++ b/sql/tests/test_insert.py @@ -11,21 +11,21 @@ class TestInsert(unittest.TestCase): def test_insert_default(self): query = self.table.insert() - self.assertEqual(str(query), 'INSERT INTO "t" AS "a" DEFAULT VALUES') + self.assertEqual(str(query), 'INSERT INTO "t" DEFAULT VALUES') self.assertEqual(tuple(query.params), ()) def test_insert_values(self): query = self.table.insert([self.table.c1, self.table.c2], [['foo', 'bar']]) self.assertEqual(str(query), - 'INSERT INTO "t" AS "a" ("c1", "c2") VALUES (%s, %s)') + 'INSERT INTO "t" ("c1", "c2") VALUES (%s, %s)') self.assertEqual(tuple(query.params), ('foo', 'bar')) def test_insert_many_values(self): query = self.table.insert([self.table.c1, self.table.c2], [['foo', 'bar'], ['spam', 'eggs']]) self.assertEqual(str(query), - 'INSERT INTO "t" AS "a" ("c1", "c2") VALUES (%s, %s), (%s, %s)') + 'INSERT INTO "t" ("c1", "c2") VALUES (%s, %s), (%s, %s)') self.assertEqual(tuple(query.params), ('foo', 'bar', 'spam', 'eggs')) def test_insert_subselect(self): @@ -34,14 +34,14 @@ def test_insert_subselect(self): subquery = t2.select(t2.c1, t2.c2) query = t1.insert([t1.c1, t1.c2], subquery) self.assertEqual(str(query), - 'INSERT INTO "t1" AS "b" ("c1", "c2") ' + 'INSERT INTO "t1" ("c1", "c2") ' 'SELECT "a"."c1", "a"."c2" FROM "t2" AS "a"') self.assertEqual(tuple(query.params), ()) def test_insert_function(self): query = self.table.insert([self.table.c], [[Abs(-1)]]) self.assertEqual(str(query), - 'INSERT INTO "t" AS "a" ("c") VALUES (ABS(%s))') + 'INSERT INTO "t" ("c") VALUES (ABS(%s))') self.assertEqual(tuple(query.params), (-1,)) def test_insert_returning(self): @@ -74,7 +74,7 @@ def test_with(self): values=w.select()) self.assertEqual(str(query), 'WITH "a" AS (SELECT * FROM "t1" AS "b") ' - 'INSERT INTO "t" AS "c" ("c1") SELECT * FROM "a" AS "a"') + 'INSERT INTO "t" ("c1") SELECT * FROM "a" AS "a"') self.assertEqual(tuple(query.params), ()) def test_insert_in_with(self): @@ -101,7 +101,7 @@ def test_schema(self): query = t1.insert([t1.c1], [['foo']]) self.assertEqual(str(query), - 'INSERT INTO "default"."t1" AS "a" ("c1") VALUES (%s)') + 'INSERT INTO "default"."t1" ("c1") VALUES (%s)') self.assertEqual(tuple(query.params), ('foo',)) def test_upsert_nothing(self): From 6bb8bfef4b952952b274b59c905d67116921f23a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 13 May 2024 18:51:26 +0200 Subject: [PATCH 33/79] Prepare release --- CHANGELOG | 1 + sql/__init__.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index f2b14d5..9c4fd72 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +Version 1.5.0 - 2024-05-13 * Skip alias on INSERT without ON CONFLICT or RETURNING * Add MERGE * Support UPSERT diff --git a/sql/__init__.py b/sql/__init__.py index bd09f98..bf5619a 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -7,7 +7,7 @@ from itertools import chain from threading import current_thread, local -__version__ = '1.4.4' +__version__ = '1.5.0' __all__ = [ 'Flavor', 'Table', 'Values', 'Literal', 'Column', 'Grouping', 'Conflict', 'Matched', 'MatchedUpdate', 'MatchedDelete', From 1edaadfc2daa216a055fb4413cdf91c1ad8e96f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 13 May 2024 18:51:40 +0200 Subject: [PATCH 34/79] Added tag 1.5.0 for changeset 6f9066b83fe3 --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index c403651..5961899 100644 --- a/.hgtags +++ b/.hgtags @@ -17,3 +17,4 @@ a317c40a4d60089ba9e465fbd64b78df24f9e890 1.4.0 e71bbae3398cb6a0e72f97a0cada9fcdee2bddea 1.4.1 fcb64787b51db2068061eb4aa13825abc1134916 1.4.2 111e3e86865360f83a65c04fa48c55f3d2957ee3 1.4.3 +6f9066b83fe3a8c4699a8555ad1bc406f18974ff 1.5.0 From 0aa2c58186c4705c3fe8932971673879e85131ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 13 May 2024 18:55:32 +0200 Subject: [PATCH 35/79] Increase version number --- sql/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/__init__.py b/sql/__init__.py index bf5619a..51e4298 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -7,7 +7,7 @@ from itertools import chain from threading import current_thread, local -__version__ = '1.5.0' +__version__ = '1.5.1' __all__ = [ 'Flavor', 'Table', 'Values', 'Literal', 'Column', 'Grouping', 'Conflict', 'Matched', 'MatchedUpdate', 'MatchedDelete', From b98c950bab8d48690b4172a5a8d7ba36e4325634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 27 May 2024 18:17:13 +0200 Subject: [PATCH 36/79] Use parameter for limit and offset Closes #92 --- CHANGELOG | 2 ++ sql/__init__.py | 25 +++++++++++++++++++++---- sql/tests/test_select.py | 20 ++++++++++---------- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 9c4fd72..518cb30 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +* Use parameter for limit and offset + Version 1.5.0 - 2024-05-13 * Skip alias on INSERT without ON CONFLICT or RETURNING * Add MERGE diff --git a/sql/__init__.py b/sql/__init__.py index 51e4298..09b5cb3 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -380,13 +380,14 @@ def offset(self, value): @property def _limit_offset_str(self): + param = Flavor.get().param if Flavor.get().limitstyle == 'limit': offset = '' if self.offset: - offset = ' OFFSET %s' % self.offset + offset = ' OFFSET %s' % param limit = '' if self.limit is not None: - limit = ' LIMIT %s' % self.limit + limit = ' LIMIT %s' % param elif self.offset: max_limit = Flavor.get().max_limit if max_limit: @@ -395,12 +396,27 @@ def _limit_offset_str(self): else: offset = '' if self.offset: - offset = ' OFFSET (%s) ROWS' % self.offset + offset = ' OFFSET (%s) ROWS' % param fetch = '' if self.limit is not None: - fetch = ' FETCH FIRST (%s) ROWS ONLY' % self.limit + fetch = ' FETCH FIRST (%s) ROWS ONLY' % param return offset + fetch + @property + def _limit_offset_params(self): + p = [] + if Flavor.get().limitstyle == 'limit': + if self.limit is not None: + p.append(self.limit) + if self.offset: + p.append(self.offset) + else: + if self.offset: + p.append(self.offset) + if self.limit is not None: + p.append(self.limit) + return tuple(p) + def as_(self, output_name): return As(self, output_name) @@ -663,6 +679,7 @@ def params(self): p.extend(self.having.params) for window in self.windows: p.extend(window.params) + p.extend(self._limit_offset_params) return tuple(p) diff --git a/sql/tests/test_select.py b/sql/tests/test_select.py index ee047d1..1f5a181 100644 --- a/sql/tests/test_select.py +++ b/sql/tests/test_select.py @@ -253,13 +253,13 @@ def test_select_limit_offset(self): Flavor.set(Flavor(limitstyle='limit')) query = self.table.select(limit=50, offset=10) self.assertEqual(str(query), - 'SELECT * FROM "t" AS "a" LIMIT 50 OFFSET 10') - self.assertEqual(tuple(query.params), ()) + 'SELECT * FROM "t" AS "a" LIMIT %s OFFSET %s') + self.assertEqual(tuple(query.params), (50, 10)) query.limit = None self.assertEqual(str(query), - 'SELECT * FROM "t" AS "a" OFFSET 10') - self.assertEqual(tuple(query.params), ()) + 'SELECT * FROM "t" AS "a" OFFSET %s') + self.assertEqual(tuple(query.params), (10,)) query.offset = 0 self.assertEqual(str(query), @@ -280,8 +280,8 @@ def test_select_limit_offset(self): query.offset = 10 self.assertEqual(str(query), - 'SELECT * FROM "t" AS "a" LIMIT -1 OFFSET 10') - self.assertEqual(tuple(query.params), ()) + 'SELECT * FROM "t" AS "a" LIMIT -1 OFFSET %s') + self.assertEqual(tuple(query.params), (10,)) finally: Flavor.set(Flavor()) @@ -291,13 +291,13 @@ def test_select_offset_fetch(self): query = self.table.select(limit=50, offset=10) self.assertEqual(str(query), 'SELECT * FROM "t" AS "a" ' - 'OFFSET (10) ROWS FETCH FIRST (50) ROWS ONLY') - self.assertEqual(tuple(query.params), ()) + 'OFFSET (%s) ROWS FETCH FIRST (%s) ROWS ONLY') + self.assertEqual(tuple(query.params), (10, 50)) query.limit = None self.assertEqual(str(query), - 'SELECT * FROM "t" AS "a" OFFSET (10) ROWS') - self.assertEqual(tuple(query.params), ()) + 'SELECT * FROM "t" AS "a" OFFSET (%s) ROWS') + self.assertEqual(tuple(query.params), (10,)) query.offset = 0 self.assertEqual(str(query), From 02ef4c50e226790651ae1ec0c54a32ae1d255f51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 27 May 2024 18:58:04 +0200 Subject: [PATCH 37/79] Use parameter for start and end of WINDOW FRAME --- CHANGELOG | 1 + sql/__init__.py | 10 ++++++++-- sql/tests/test_window.py | 12 ++++++------ 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 518cb30..e56b211 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Use parameter for start and end of WINDOW FRAME * Use parameter for limit and offset Version 1.5.0 - 2024-05-13 diff --git a/sql/__init__.py b/sql/__init__.py index 09b5cb3..edd13a0 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -2009,6 +2009,7 @@ def has_alias(self): return AliasManager.contains(self) def __str__(self): + param = Flavor.get().param partition = '' if self.partition: partition = 'PARTITION BY ' + ', '.join(map(str, self.partition)) @@ -2022,9 +2023,9 @@ def format(frame, direction): elif not frame: return 'CURRENT ROW' elif frame < 0: - return '%s PRECEDING' % -frame + return '%s PRECEDING' % param elif frame > 0: - return '%s FOLLOWING' % frame + return '%s FOLLOWING' % param frame = '' if self.frame: @@ -2045,6 +2046,11 @@ def params(self): if self.order_by: for expression in self.order_by: p.extend(expression.params) + if self.frame: + if self.start: + p.append(abs(self.start)) + if self.end: + p.append(abs(self.end)) return tuple(p) diff --git a/sql/tests/test_window.py b/sql/tests/test_window.py index 6fd1a1b..c67bf3b 100644 --- a/sql/tests/test_window.py +++ b/sql/tests/test_window.py @@ -33,22 +33,22 @@ def test_window_range(self): window.start = -1 self.assertEqual(str(window), 'PARTITION BY "c" RANGE ' - 'BETWEEN 1 PRECEDING AND CURRENT ROW') - self.assertEqual(window.params, ()) + 'BETWEEN %s PRECEDING AND CURRENT ROW') + self.assertEqual(window.params, (1,)) window.start = 0 window.end = 1 self.assertEqual(str(window), 'PARTITION BY "c" RANGE ' - 'BETWEEN CURRENT ROW AND 1 FOLLOWING') - self.assertEqual(window.params, ()) + 'BETWEEN CURRENT ROW AND %s FOLLOWING') + self.assertEqual(window.params, (1,)) window.start = 1 window.end = None self.assertEqual(str(window), 'PARTITION BY "c" RANGE ' - 'BETWEEN 1 FOLLOWING AND UNBOUNDED FOLLOWING') - self.assertEqual(window.params, ()) + 'BETWEEN %s FOLLOWING AND UNBOUNDED FOLLOWING') + self.assertEqual(window.params, (1,)) def test_window_exclude(self): t = Table('t') From 13d6f4875b199857d217c87513d41d235e77193f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Tue, 28 May 2024 18:00:00 +0200 Subject: [PATCH 38/79] Prepare release --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index e56b211..fb7084b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +Version 1.5.1 - 2024-05-28 * Use parameter for start and end of WINDOW FRAME * Use parameter for limit and offset From e11c033f3c94c29d0473aae64fd87c363f013347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Tue, 28 May 2024 18:00:06 +0200 Subject: [PATCH 39/79] Added tag 1.5.1 for changeset 79a69b0bbbd3 --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index 5961899..35e1853 100644 --- a/.hgtags +++ b/.hgtags @@ -18,3 +18,4 @@ e71bbae3398cb6a0e72f97a0cada9fcdee2bddea 1.4.1 fcb64787b51db2068061eb4aa13825abc1134916 1.4.2 111e3e86865360f83a65c04fa48c55f3d2957ee3 1.4.3 6f9066b83fe3a8c4699a8555ad1bc406f18974ff 1.5.0 +79a69b0bbbd35a8d95e1b754ed3feb03df23fb70 1.5.1 From 676dcdce786aeba80f0b70d9c1c104460086946c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Tue, 28 May 2024 18:03:51 +0200 Subject: [PATCH 40/79] Increase version number --- sql/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/__init__.py b/sql/__init__.py index edd13a0..4d37cbf 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -7,7 +7,7 @@ from itertools import chain from threading import current_thread, local -__version__ = '1.5.1' +__version__ = '1.5.2' __all__ = [ 'Flavor', 'Table', 'Values', 'Literal', 'Column', 'Grouping', 'Conflict', 'Matched', 'MatchedUpdate', 'MatchedDelete', From a933aaf773fdc98d619b34a91198e1cf04c081bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Wed, 29 May 2024 19:14:40 +0200 Subject: [PATCH 41/79] Replace assert by ValueError --- CHANGELOG | 2 + sql/__init__.py | 222 ++++++++++++++++++++---------- sql/aggregate.py | 27 +++- sql/functions.py | 12 +- sql/operators.py | 6 +- sql/tests/test_aggregate.py | 26 +++- sql/tests/test_collate.py | 5 - sql/tests/test_combining_query.py | 6 +- sql/tests/test_delete.py | 14 +- sql/tests/test_flavor.py | 46 +++++++ sql/tests/test_for.py | 4 + sql/tests/test_from.py | 25 ++++ sql/tests/test_from_item.py | 46 +++++++ sql/tests/test_functions.py | 18 ++- sql/tests/test_grouping.py | 13 ++ sql/tests/test_insert.py | 58 +++++++- sql/tests/test_join.py | 16 +++ sql/tests/test_merge.py | 28 +++- sql/tests/test_operators.py | 8 ++ sql/tests/test_order.py | 6 +- sql/tests/test_rollup.py | 13 ++ sql/tests/test_select.py | 40 ++++++ sql/tests/test_update.py | 8 ++ sql/tests/test_window.py | 24 ++++ sql/tests/test_with.py | 4 + 25 files changed, 580 insertions(+), 97 deletions(-) create mode 100644 sql/tests/test_flavor.py create mode 100644 sql/tests/test_from.py create mode 100644 sql/tests/test_from_item.py create mode 100644 sql/tests/test_grouping.py create mode 100644 sql/tests/test_rollup.py diff --git a/CHANGELOG b/CHANGELOG index fb7084b..845b2f5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +* Replace assert by ValueError + Version 1.5.1 - 2024-05-28 * Use parameter for start and end of WINDOW FRAME * Use parameter for limit and offset diff --git a/sql/__init__.py b/sql/__init__.py index 4d37cbf..8827536 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -62,17 +62,23 @@ class Flavor(object): def __init__(self, limitstyle='limit', max_limit=None, paramstyle='format', ilike=False, no_as=False, no_boolean=False, null_ordering=True, function_mapping=None, filter_=False, escape_empty=False): - assert limitstyle in ['fetch', 'limit', 'rownum'] + if limitstyle not in {'fetch', 'limit', 'rownum'}: + raise ValueError("unsupported limitstyle: %r" % limitstyle) self.limitstyle = limitstyle + if (max_limit is not None + and not isinstance(max_limit, numbers.Integral)): + raise ValueError("unsupported max_limit: %r" % max_limit) self.max_limit = max_limit + if paramstyle not in {'format', 'qmark'}: + raise ValueError("unsupported paramstyle: %r" % paramstyle) self.paramstyle = paramstyle - self.ilike = ilike - self.no_as = no_as - self.no_boolean = no_boolean - self.null_ordering = null_ordering - self.function_mapping = function_mapping or {} - self.filter_ = filter_ - self.escape_empty = escape_empty + self.ilike = bool(ilike) + self.no_as = bool(no_as) + self.no_boolean = bool(no_boolean) + self.null_ordering = bool(null_ordering) + self.function_mapping = dict(function_mapping or {}) + self.filter_ = bool(filter_) + self.escape_empty = bool(escape_empty) @property def param(self): @@ -213,7 +219,8 @@ def with_(self, value): if value is not None: if isinstance(value, With): value = [value] - assert all(isinstance(w, With) for w in value) + if any(not isinstance(w, With) for w in value): + raise ValueError("invalid with: %r" % value) self._with = value def _with_str(self): @@ -252,7 +259,8 @@ def __getattr__(self, name): return Column(self, name) def __add__(self, other): - assert isinstance(other, FromItem) + if not isinstance(other, FromItem): + return NotImplemented return From((self, other)) def select(self, *args, **kwargs): @@ -348,7 +356,8 @@ def order_by(self, value): if value is not None: if isinstance(value, Expression): value = [value] - assert all(isinstance(col, Expression) for col in value) + if any(not isinstance(col, Expression) for col in value): + raise ValueError("invalid order by: %r" % value) self._order_by = value @property @@ -365,7 +374,8 @@ def limit(self): @limit.setter def limit(self, value): if value is not None: - assert isinstance(value, numbers.Integral) + if not isinstance(value, numbers.Integral): + raise ValueError("invalid limit: %r" % value) self._limit = value @property @@ -375,7 +385,8 @@ def offset(self): @offset.setter def offset(self, value): if value is not None: - assert isinstance(value, numbers.Integral) + if not isinstance(value, numbers.Integral): + raise ValueError("invalid offset: %r" % value) self._offset = value @property @@ -464,7 +475,8 @@ def distinct_on(self, value): if value is not None: if isinstance(value, Expression): value = [value] - assert all(isinstance(col, Expression) for col in value) + if any(not isinstance(col, Expression) for col in value): + raise ValueError("invalid distinct on: %r" % value) self._distinct_on = value @property @@ -473,7 +485,10 @@ def columns(self): @columns.setter def columns(self, value): - assert all(isinstance(col, (Expression, SelectQuery)) for col in value) + if any( + not isinstance(col, (Expression, SelectQuery)) + for col in value): + raise ValueError("invalid columns: %r" % value) self._columns = tuple(value) @property @@ -484,7 +499,8 @@ def where(self): def where(self, value): from sql.operators import And, Or if value is not None: - assert isinstance(value, (Expression, And, Or)) + if not isinstance(value, (Expression, And, Or)): + raise ValueError("invalid where: %r" % value) self._where = value @property @@ -496,7 +512,8 @@ def group_by(self, value): if value is not None: if isinstance(value, Expression): value = [value] - assert all(isinstance(col, Expression) for col in value) + if any(not isinstance(col, Expression) for col in value): + raise ValueError("invalid group by: %r" % value) self._group_by = value @property @@ -507,7 +524,8 @@ def having(self): def having(self, value): from sql.operators import And, Or if value is not None: - assert isinstance(value, (Expression, And, Or)) + if not isinstance(value, (Expression, And, Or)): + raise ValueError("invalid having: %r" % value) self._having = value @property @@ -519,7 +537,8 @@ def for_(self, value): if value is not None: if isinstance(value, For): value = [value] - assert all(isinstance(f, For) for f in value) + if any(not isinstance(f, For) for f in value): + raise ValueError("invalid for: %r" % value) self._for_ = value @property @@ -547,7 +566,8 @@ def windows(self): @windows.setter def windows(self, value): if value is not None: - assert all(isinstance(w, Window) for w in value) + if any(not isinstance(w, Window) for w in value): + raise ValueError("invalid windows: %r" % value) self._windows = value @staticmethod @@ -707,7 +727,8 @@ def table(self): @table.setter def table(self, value): - assert isinstance(value, Table) + if not isinstance(value, Table): + raise ValueError("invalid table: %r" % value) self._table = value @property @@ -717,8 +738,10 @@ def columns(self): @columns.setter def columns(self, value): if value is not None: - assert all(isinstance(col, Column) for col in value) - assert all(col.table == self.table for col in value) + if any( + not isinstance(col, Column) or col.table != self.table + for col in value): + raise ValueError("invalid columns: %r" % value) self._columns = value @property @@ -728,7 +751,8 @@ def values(self): @values.setter def values(self, value): if value is not None: - assert isinstance(value, (list, Select)) + if not isinstance(value, (list, Select)): + raise ValueError("invalid values: %r" % value) if isinstance(value, list): value = Values(value) self._values = value @@ -740,8 +764,8 @@ def on_conflict(self): @on_conflict.setter def on_conflict(self, value): if value is not None: - assert isinstance(value, Conflict) - assert value.table == self.table + if not isinstance(value, Conflict) or value.table != self.table: + raise ValueError("invalid on conflict: %r" % value) self._on_conflict = value @property @@ -751,7 +775,8 @@ def returning(self): @returning.setter def returning(self, value): if value is not None: - assert isinstance(value, list) + if not isinstance(value, list): + raise ValueError("invalid returning: %r" % value) self._returning = value @staticmethod @@ -834,7 +859,8 @@ def table(self): @table.setter def table(self, value): - assert isinstance(value, Table) + if not isinstance(value, Table): + raise ValueError("invalid table: %r" % value) self._table = value @property @@ -844,8 +870,10 @@ def indexed_columns(self): @indexed_columns.setter def indexed_columns(self, value): if value is not None: - assert all(isinstance(col, Column) for col in value) - assert all(col.table == self.table for col in value) + if any( + not isinstance(col, Column) or col.table != self.table + for col in value): + raise ValueError("invalid indexed columns: %r" % value) self._indexed_columns = value @property @@ -856,7 +884,8 @@ def index_where(self): def index_where(self, value): from sql.operators import And, Or if value is not None: - assert isinstance(value, (Expression, And, Or)) + if not isinstance(value, (Expression, And, Or)): + raise ValueError("invalid index where: %r" % value) self._index_where = value @property @@ -866,8 +895,10 @@ def columns(self): @columns.setter def columns(self, value): if value is not None: - assert all(isinstance(col, Column) for col in value) - assert all(col.table == self.table for col in value) + if any( + not isinstance(col, Column) or col.table != self.table + for col in value): + raise ValueError("invalid columns: %r" % value) self._columns = value @property @@ -877,7 +908,8 @@ def values(self): @values.setter def values(self, value): if value is not None: - assert isinstance(value, (list, Select)) + if not isinstance(value, (list, Select)): + raise ValueError("invalid values: %r" % value) if isinstance(value, list): value = Values([value]) self._values = value @@ -890,7 +922,8 @@ def where(self): def where(self, value): from sql.operators import And, Or if value is not None: - assert isinstance(value, (Expression, And, Or)) + if not isinstance(value, (Expression, And, Or)): + raise ValueError("invalid where: %r" % value) self._where = value def __str__(self): @@ -961,7 +994,8 @@ def values(self): def values(self, value): if isinstance(value, Select): value = [value] - assert isinstance(value, list) + if not isinstance(value, list): + raise ValueError("invalid values: %r" % value) self._values = value @property @@ -972,7 +1006,8 @@ def where(self): def where(self, value): from sql.operators import And, Or if value is not None: - assert isinstance(value, (Expression, And, Or)) + if not isinstance(value, (Expression, And, Or)): + raise ValueError("invalid where: %r" % value) self._where = value def __str__(self): @@ -1037,7 +1072,8 @@ def table(self): @table.setter def table(self, value): - assert isinstance(value, Table) + if not isinstance(value, Table): + raise ValueError("invalid table: %r" % value) self._table = value @property @@ -1048,7 +1084,8 @@ def where(self): def where(self, value): from sql.operators import And, Or if value is not None: - assert isinstance(value, (Expression, And, Or)) + if not isinstance(value, (Expression, And, Or)): + raise ValueError("invalid where: %r" % value) self._where = value @property @@ -1058,7 +1095,8 @@ def returning(self): @returning.setter def returning(self, value): if value is not None: - assert isinstance(value, list) + if not isinstance(value, list): + raise ValueError("invalid returning: %r" % value) self._returning = value @staticmethod @@ -1118,7 +1156,8 @@ def target(self): @target.setter def target(self, value): - assert isinstance(value, Table) + if not isinstance(value, Table): + raise ValueError("invalid target: %r" % value) self._target = value @property @@ -1127,7 +1166,8 @@ def source(self): @source.setter def source(self, value): - assert isinstance(value, (Table, SelectQuery, Values)) + if not isinstance(value, (Table, SelectQuery, Values)): + raise ValueError("invalid source: %r" % value) self._source = value @property @@ -1136,7 +1176,8 @@ def condition(self): @condition.setter def condition(self, value): - assert isinstance(value, Expression) + if not isinstance(value, Expression): + raise ValueError("invalid condition: %r" % value) self._condition = value @property @@ -1145,7 +1186,8 @@ def whens(self): @whens.setter def whens(self, value): - assert all(isinstance(w, Matched) for w in value) + if any(not isinstance(w, Matched) for w in value): + raise ValueError("invalid whens: %r" % value) self._whens = tuple(value) def __str__(self): @@ -1191,7 +1233,8 @@ def condition(self): @condition.setter def condition(self, value): if value is not None: - assert isinstance(value, Expression) + if not isinstance(value, Expression): + raise ValueError("invalid condition: %r" % value) self._condition = value def _then_str(self): @@ -1228,7 +1271,8 @@ def columns(self): @columns.setter def columns(self, value): - assert all(isinstance(col, Column) for col in value) + if any(not isinstance(col, Column) for col in value): + raise ValueError("invalid columns: %r" % value) self._columns = value @@ -1304,7 +1348,8 @@ class CombiningQuery(FromItem, SelectQuery): _operator = '' def __init__(self, *queries, **kwargs): - assert all(isinstance(q, Query) for q in queries) + if any(not isinstance(q, Query) for q in queries): + raise ValueError("invalid queries: %r" % (queries,)) self.queries = queries self.all_ = kwargs.pop('all_', False) super(CombiningQuery, self).__init__(**kwargs) @@ -1424,7 +1469,8 @@ def left(self): @left.setter def left(self, value): - assert isinstance(value, FromItem) + if not isinstance(value, FromItem): + raise ValueError("invalid left: %r" % value) self._left = value @property @@ -1433,7 +1479,8 @@ def right(self): @right.setter def right(self, value): - assert isinstance(value, FromItem) + if not isinstance(value, FromItem): + raise ValueError("invalid right: %r" % value) self._right = value @property @@ -1444,7 +1491,8 @@ def condition(self): def condition(self, value): from sql.operators import And, Or if value is not None: - assert isinstance(value, (Expression, And, Or)) + if not isinstance(value, (Expression, And, Or)): + raise ValueError("invalid condition: %r" % value) self._condition = value @property @@ -1454,8 +1502,10 @@ def type_(self): @type_.setter def type_(self, value): value = value.upper() - assert value in ('INNER', 'LEFT', 'LEFT OUTER', - 'RIGHT', 'RIGHT OUTER', 'FULL', 'FULL OUTER', 'CROSS') + if value not in { + 'INNER', 'LEFT', 'LEFT OUTER', 'RIGHT', 'RIGHT OUTER', 'FULL', + 'FULL OUTER', 'CROSS'}: + raise ValueError("invalid type: %r" % value) self._type_ = value def __str__(self): @@ -1534,8 +1584,10 @@ def params(self): return tuple(p) def __add__(self, other): - assert isinstance(other, FromItem) - assert not isinstance(other, CombiningQuery) + if not isinstance(other, FromItem): + return NotImplemented + elif isinstance(other, CombiningQuery): + return NotImplemented return From(super(From, self).__add__([other])) @@ -1823,21 +1875,35 @@ def params(self): class Collate(Expression): - __slots__ = ('expression', 'collation') + __slots__ = ('_expression', '_collation') def __init__(self, expression, collation): super(Collate, self).__init__() self.expression = expression self.collation = collation + @property + def expression(self): + return self._expression + + @expression.setter + def expression(self, value): + self._expression = value + + @property + def collation(self): + return self._collation + + @collation.setter + def collation(self, value): + self._collation = value + def __str__(self): if isinstance(self.expression, Expression): value = self.expression else: value = Flavor.get().param - if '"' in self.collation: - raise ValueError("Wrong collation %s" % self.collation) - return '%s COLLATE "%s"' % (value, self.collation) + return '%s COLLATE %s' % (value, _escape_identifier(self.collation)) @property def params(self): @@ -1860,8 +1926,11 @@ def sets(self): @sets.setter def sets(self, value): - assert all( - isinstance(col, Expression) for cols in value for col in cols) + if any( + not isinstance(col, Expression) + for cols in value + for col in cols): + raise ValueError("invalid sets: %r" % value) self._sets = tuple(tuple(cols) for cols in value) def __str__(self): @@ -1888,10 +1957,11 @@ def expressions(self): @expressions.setter def expressions(self, value): - assert all( - isinstance(col, Expression) - or all(isinstance(c, Expression) for c in col) - for col in value) + if not all( + isinstance(col, Expression) + or all(isinstance(c, Expression) for c in col) + for col in value): + raise ValueError("invalid expressions: %r" % value) self._expressions = tuple(value) def __str__(self): @@ -1945,7 +2015,8 @@ def partition(self): @partition.setter def partition(self, value): - assert all(isinstance(e, Expression) for e in value) + if any(not isinstance(e, Expression) for e in value): + raise ValueError("invalid partition: %r" % value) self._partition = value @property @@ -1957,7 +2028,8 @@ def order_by(self, value): if value is not None: if isinstance(value, Expression): value = [value] - assert all(isinstance(col, Expression) for col in value) + if any(not isinstance(col, Expression) for col in value): + raise ValueError("invalid order by: %r" % value) self._order_by = value @property @@ -1967,7 +2039,8 @@ def frame(self): @frame.setter def frame(self, value): if value: - assert value in ['RANGE', 'ROWS', 'GROUPS'] + if value not in {'RANGE', 'ROWS', 'GROUPS'}: + raise ValueError("invalid frame: %r" % value) self._frame = value @property @@ -1977,7 +2050,8 @@ def start(self): @start.setter def start(self, value): if value: - assert isinstance(value, numbers.Integral) + if not isinstance(value, numbers.Integral): + raise ValueError("invalid start: %r" % value) self._start = value @property @@ -1987,7 +2061,8 @@ def end(self): @end.setter def end(self, value): if value: - assert isinstance(value, numbers.Integral) + if not isinstance(value, numbers.Integral): + raise ValueError("invalid end: %r" % value) self._end = value @property @@ -1997,7 +2072,8 @@ def exclude(self): @exclude.setter def exclude(self, value): if value: - assert value in ['CURRENT ROW', 'GROUP', 'TIES'] + if value not in {'CURRENT ROW', 'GROUP', 'TIES'}: + raise ValueError("invalid exclude: %r" % value) self._exclude = value @property @@ -2070,7 +2146,8 @@ def expression(self): @expression.setter def expression(self, value): - assert isinstance(value, (Expression, SelectQuery)) + if not isinstance(value, (Expression, SelectQuery)): + raise ValueError("invalid expression: %r" % value) self._expression = value def __str__(self): @@ -2173,7 +2250,8 @@ def type_(self): @type_.setter def type_(self, value): value = value.upper() - assert value in ('UPDATE', 'SHARE') + if value not in {'UPDATE', 'SHARE'}: + raise ValueError("invalid type: %r" % value) self._type_ = value def __str__(self): diff --git a/sql/aggregate.py b/sql/aggregate.py index 3cbc28a..c3210f4 100644 --- a/sql/aggregate.py +++ b/sql/aggregate.py @@ -8,7 +8,7 @@ class Aggregate(Expression): - __slots__ = ('expression', '_distinct', '_order_by', '_within', + __slots__ = ('_expression', '_distinct', '_order_by', '_within', '_filter', '_window') _sql = '' @@ -22,13 +22,24 @@ def __init__(self, expression, distinct=False, order_by=None, within=None, self.filter_ = filter_ self.window = window + @property + def expression(self): + return self._expression + + @expression.setter + def expression(self, value): + if not isinstance(value, Expression): + raise ValueError("invalid expression: %r" % value) + self._expression = value + @property def distinct(self): return self._distinct @distinct.setter def distinct(self, value): - assert isinstance(value, bool) + if not isinstance(value, bool): + raise ValueError("invalid distinct: %r" % value) self._distinct = value @property @@ -40,7 +51,8 @@ def order_by(self, value): if value is not None: if isinstance(value, Expression): value = [value] - assert all(isinstance(col, Expression) for col in value) + if any(not isinstance(col, Expression) for col in value): + raise ValueError("invalid order by: %r" % value) self._order_by = value @property @@ -52,7 +64,8 @@ def within(self, value): if value is not None: if isinstance(value, Expression): value = [value] - assert all(isinstance(col, Expression) for col in value) + if any(not isinstance(col, Expression) for col in value): + raise ValueError("invalid within: %r" % value) self._within = value @property @@ -63,7 +76,8 @@ def filter_(self): def filter_(self, value): from sql.operators import And, Or if value is not None: - assert isinstance(value, (Expression, And, Or)) + if not isinstance(value, (Expression, And, Or)): + raise ValueError("invalid filter: %r" % value) self._filter = value @property @@ -73,7 +87,8 @@ def window(self): @window.setter def window(self, value): if value: - assert isinstance(value, Window) + if not isinstance(value, Window): + raise ValueError("invalid window: %r" % value) self._window = value @property diff --git a/sql/functions.py b/sql/functions.py index 83e5bcf..053e5e9 100644 --- a/sql/functions.py +++ b/sql/functions.py @@ -39,7 +39,8 @@ def columns_definitions(self): @columns_definitions.setter def columns_definitions(self, value): - assert isinstance(value, list) + if not isinstance(value, list): + raise ValueError("invalid columns definitions: %r" % value) self._columns_definitions = value @staticmethod @@ -286,7 +287,8 @@ class Trim(Function): _function = 'TRIM' def __init__(self, string, position='BOTH', characters=' '): - assert position.upper() in ('LEADING', 'TRAILING', 'BOTH') + if position.upper() not in {'LEADING', 'TRAILING', 'BOTH'}: + raise ValueError("invalid position: %r" % position) self.position = position.upper() self.characters = characters self.string = string @@ -486,7 +488,8 @@ def filter_(self): def filter_(self, value): from sql.operators import And, Or if value is not None: - assert isinstance(value, (Expression, And, Or)) + if not isinstance(value, (Expression, And, Or)): + raise ValueError("invalid filter: %r" % value) self._filter = value @property @@ -496,7 +499,8 @@ def window(self): @window.setter def window(self, value): if value: - assert isinstance(value, Window) + if not isinstance(value, Window): + raise ValueError("invalid window: %r" % value) self._window = value def __str__(self): diff --git a/sql/operators.py b/sql/operators.py index 2497ea8..31a06a5 100644 --- a/sql/operators.py +++ b/sql/operators.py @@ -248,7 +248,8 @@ class Is(BinaryOperator): _operator = 'IS' def __init__(self, left, right): - assert right in [None, True, False] + if right not in {None, True, False}: + raise ValueError("invalid right: %r" % right) super(Is, self).__init__(left, right) @property @@ -379,7 +380,8 @@ class Like(BinaryOperator): def __init__(self, left, right, escape=None): super().__init__(left, right) - assert not escape or len(escape) == 1 + if escape and len(escape) != 1: + raise ValueError("invalid escape: %r" % escape) self.escape = escape @property diff --git a/sql/tests/test_aggregate.py b/sql/tests/test_aggregate.py index e70fc93..88d45e1 100644 --- a/sql/tests/test_aggregate.py +++ b/sql/tests/test_aggregate.py @@ -3,12 +3,36 @@ import unittest from sql import AliasManager, Flavor, Literal, Table, Window -from sql.aggregate import Avg, Count +from sql.aggregate import Aggregate, Avg, Count class TestAggregate(unittest.TestCase): table = Table('t') + def test_invalid_expression(self): + with self.assertRaises(ValueError): + Aggregate('foo') + + def test_invalid_distinct(self): + with self.assertRaises(ValueError): + Aggregate(self.table.c, distinct='foo') + + def test_invalid_order(self): + with self.assertRaises(ValueError): + Aggregate(self.table.c, order_by=['foo']) + + def test_invalid_within(self): + with self.assertRaises(ValueError): + Aggregate(self.table.c, within=['foo']) + + def test_invalid_filter(self): + with self.assertRaises(ValueError): + Aggregate(self.table.c, filter_='foo') + + def test_invalid_window(self): + with self.assertRaises(ValueError): + Aggregate(self.table.c, window='foo') + def test_avg(self): avg = Avg(self.table.c) self.assertEqual(str(avg), 'AVG("c")') diff --git a/sql/tests/test_collate.py b/sql/tests/test_collate.py index a06a6ba..381f7ca 100644 --- a/sql/tests/test_collate.py +++ b/sql/tests/test_collate.py @@ -17,8 +17,3 @@ def test_collate_no_expression(self): collate = Collate("foo", 'C') self.assertEqual(str(collate), '%s COLLATE "C"') self.assertEqual(collate.params, ("foo",)) - - def test_collate_injection(self): - collate = Collate(self.column, 'C";') - with self.assertRaises(ValueError): - str(collate) diff --git a/sql/tests/test_combining_query.py b/sql/tests/test_combining_query.py index ff7c680..9dab817 100644 --- a/sql/tests/test_combining_query.py +++ b/sql/tests/test_combining_query.py @@ -2,7 +2,7 @@ # this repository contains the full copyright notices and license terms. import unittest -from sql import Table, Union, With +from sql import CombiningQuery, Table, Union, With class TestUnion(unittest.TestCase): @@ -10,6 +10,10 @@ class TestUnion(unittest.TestCase): q2 = Table('t2').select() q3 = Table('t3').select() + def test_invalid_queries(self): + with self.assertRaises(ValueError): + CombiningQuery('foo', 'bar') + def test_union2(self): query = Union(self.q1, self.q2) self.assertEqual(str(query), diff --git a/sql/tests/test_delete.py b/sql/tests/test_delete.py index 764b87f..e94ebeb 100644 --- a/sql/tests/test_delete.py +++ b/sql/tests/test_delete.py @@ -2,7 +2,7 @@ # this repository contains the full copyright notices and license terms. import unittest -from sql import Table, With +from sql import Delete, Table, With class TestDelete(unittest.TestCase): @@ -27,11 +27,23 @@ def test_delete3(self): 'SELECT "a"."c" FROM "t2" AS "a"))') self.assertEqual(query.params, ()) + def test_delete_invalid_table(self): + with self.assertRaises(ValueError): + Delete('foo') + + def test_delete_invalid_where(self): + with self.assertRaises(ValueError): + self.table.delete(where='foo') + def test_delete_returning(self): query = self.table.delete(returning=[self.table.c]) self.assertEqual(str(query), 'DELETE FROM "t" RETURNING "c"') self.assertEqual(query.params, ()) + def test_delete_invalid_returning(self): + with self.assertRaises(ValueError): + self.table.delete(returning='foo') + def test_with(self): t1 = Table('t1') w = With(query=t1.select(t1.c1)) diff --git a/sql/tests/test_flavor.py b/sql/tests/test_flavor.py new file mode 100644 index 0000000..205422e --- /dev/null +++ b/sql/tests/test_flavor.py @@ -0,0 +1,46 @@ +# This file is part of python-sql. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. + +import unittest + +from sql import Flavor + + +class TestFlavor(unittest.TestCase): + + def test(self): + Flavor() + + def test_limitstyle(self): + flavor = Flavor(limitstyle='rownum') + + self.assertEqual(flavor.limitstyle, 'rownum') + + def test_invalid_limitstyle(self): + with self.assertRaises(ValueError): + Flavor(limitstyle='foo') + + def test_max_limit(self): + flavor = Flavor(max_limit=42) + + self.assertEqual(flavor.max_limit, 42) + + def test_invalid_max_limit(self): + with self.assertRaises(ValueError): + Flavor(max_limit='foo') + + def test_paramstyle_format(self): + flavor = Flavor(paramstyle='format') + + self.assertEqual(flavor.paramstyle, 'format') + self.assertEqual(flavor.param, '%s') + + def test_paramstyle_qmark(self): + flavor = Flavor(paramstyle='qmark') + + self.assertEqual(flavor.paramstyle, 'qmark') + self.assertEqual(flavor.param, '?') + + def test_invalid_paramstyle(self): + with self.assertRaises(ValueError): + Flavor(paramstyle='foo') diff --git a/sql/tests/test_for.py b/sql/tests/test_for.py index 8003257..599de0e 100644 --- a/sql/tests/test_for.py +++ b/sql/tests/test_for.py @@ -14,3 +14,7 @@ def test_for_single_table(self): for_ = For('UPDATE') for_.tables = Table('t1') self.assertEqual(str(for_), 'FOR UPDATE OF "t1"') + + def test_invalid_type(self): + with self.assertRaises(ValueError): + For('foo') diff --git a/sql/tests/test_from.py b/sql/tests/test_from.py new file mode 100644 index 0000000..98cb6c2 --- /dev/null +++ b/sql/tests/test_from.py @@ -0,0 +1,25 @@ +# This file is part of python-sql. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. + +import unittest + +from sql import CombiningQuery, From, Table + + +class TestFrom(unittest.TestCase): + + def test_add(self): + t1 = Table('t1') + t2 = Table('t2') + from_ = From([t1]) + t2 + + self.assertEqual(from_, [t1, t2]) + + def test_invalid_add(self): + with self.assertRaises(TypeError): + From([Table('t')]) + 'foo' + + def test_invalid_add_combining_query(self): + with self.assertRaises(TypeError): + From([Table('t')]) + CombiningQuery( + Table('t1').select(), Table('t2').select()) diff --git a/sql/tests/test_from_item.py b/sql/tests/test_from_item.py new file mode 100644 index 0000000..9b0a465 --- /dev/null +++ b/sql/tests/test_from_item.py @@ -0,0 +1,46 @@ +# This file is part of python-sql. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. + +import unittest + +from sql import AliasManager, Column, From, FromItem + + +class TestFromItem(unittest.TestCase): + + def test_from_item(self): + from_item = FromItem() + + with AliasManager(): + self.assertFalse(from_item.has_alias) + from_item.alias + self.assertTrue(from_item.has_alias) + + def test_get_column(self): + from_item = FromItem() + + foo = from_item.foo + + self.assertIsInstance(foo, Column) + self.assertEqual(foo.name, 'foo') + + def test_get_invalid_column(self): + from_item = FromItem() + + with self.assertRaises(AttributeError): + from_item.__foo__ + + def test_add(self): + from_item1 = FromItem() + from_item2 = FromItem() + + from_ = from_item1 + from_item2 + + self.assertIsInstance(from_, From) + self.assertEqual(from_, [from_item1, from_item2]) + + def test_invalid_add(self): + from_item = FromItem() + + with self.assertRaises(TypeError): + from_item + 'foo' diff --git a/sql/tests/test_functions.py b/sql/tests/test_functions.py index 51f7022..084689f 100644 --- a/sql/tests/test_functions.py +++ b/sql/tests/test_functions.py @@ -5,12 +5,16 @@ from sql import AliasManager, Flavor, Table, Window from sql.functions import ( Abs, AtTimeZone, CurrentTime, Div, Function, FunctionKeyword, - FunctionNotCallable, Overlay, Rank, Trim) + FunctionNotCallable, Overlay, Rank, Trim, WindowFunction) class TestFunctions(unittest.TestCase): table = Table('t') + def test_invalid_columns_definitions(self): + with self.assertRaises(ValueError): + Function(columns_definitions='foo') + def test_abs(self): abs_ = Abs(self.table.c1) self.assertEqual(str(abs_), 'ABS("c1")') @@ -87,6 +91,10 @@ def test_trim(self): self.assertEqual(str(trim), 'TRIM(BOTH %s FROM "c1")') self.assertEqual(trim.params, (' ',)) + def test_trim_invalid_position(self): + with self.assertRaises(ValueError): + Trim('test', 'foo') + def test_at_time_zone(self): time_zone = AtTimeZone(self.table.c1, 'UTC') self.assertEqual(str(time_zone), '"c1" AT TIME ZONE %s') @@ -142,6 +150,10 @@ def test_window(self): self.assertEqual(str(function), 'RANK("a"."c") OVER ()') self.assertEqual(function.params, ()) + def test_invalid_window(self): + with self.assertRaises(ValueError): + WindowFunction(window='foo') + def test_filter(self): t = Table('t') function = Rank(t.c, filter_=t.c > 0, window=Window([])) @@ -150,3 +162,7 @@ def test_filter(self): self.assertEqual(str(function), 'RANK("a"."c") FILTER (WHERE ("a"."c" > %s)) OVER ()') self.assertEqual(function.params, (0,)) + + def test_invalid_filter(self): + with self.assertRaises(ValueError): + WindowFunction(filter_='foo', window=Window([])) diff --git a/sql/tests/test_grouping.py b/sql/tests/test_grouping.py new file mode 100644 index 0000000..7da9757 --- /dev/null +++ b/sql/tests/test_grouping.py @@ -0,0 +1,13 @@ +# This file is part of python-sql. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. + +import unittest + +from sql import Grouping + + +class TestGrouping(unittest.TestCase): + + def test_invalid_sets(self): + with self.assertRaises(ValueError): + Grouping('foo') diff --git a/sql/tests/test_insert.py b/sql/tests/test_insert.py index 327ab84..2f3db3a 100644 --- a/sql/tests/test_insert.py +++ b/sql/tests/test_insert.py @@ -2,13 +2,25 @@ # this repository contains the full copyright notices and license terms. import unittest -from sql import Conflict, Excluded, Table, With +from sql import Conflict, Excluded, Insert, Table, With from sql.functions import Abs class TestInsert(unittest.TestCase): table = Table('t') + def test_insert_invalid_table(self): + with self.assertRaises(ValueError): + Insert('foo') + + def test_insert_invalid_columns(self): + with self.assertRaises(ValueError): + self.table.insert(['foo'], [['foo']]) + + def test_insert_invalid_values(self): + with self.assertRaises(ValueError): + self.table.insert([self.table.c], 'foo') + def test_insert_default(self): query = self.table.insert() self.assertEqual(str(query), 'INSERT INTO "t" DEFAULT VALUES') @@ -64,6 +76,10 @@ def test_insert_returning_select(self): 'WHERE (("a"."c1" = "b"."c") AND ("a"."c2" = %s)))') self.assertEqual(tuple(query.params), ('foo', 'bar')) + def test_insert_invalid_returning(self): + with self.assertRaises(ValueError): + self.table.insert(returning='foo') + def test_with(self): t1 = Table('t1') w = With(query=t1.select()) @@ -104,6 +120,14 @@ def test_schema(self): 'INSERT INTO "default"."t1" ("c1") VALUES (%s)') self.assertEqual(tuple(query.params), ('foo',)) + def test_upsert_invalid_on_conflict(self): + with self.assertRaises(ValueError): + self.table.insert(on_conflict='foo') + + def test_upsert_invalid_table_on_conflict(self): + with self.assertRaises(ValueError): + self.table.insert(on_conflict=Conflict(Table('t1'))) + def test_upsert_nothing(self): query = self.table.insert( [self.table.c1], [['foo']], @@ -196,3 +220,35 @@ def test_upsert_update_excluded(self): 'INSERT INTO "t" AS "a" ("c1") VALUES (%s) ' 'ON CONFLICT DO UPDATE SET "c1" = (("EXCLUDED"."c1" + %s))') self.assertEqual(tuple(query.params), (1, 2)) + + def test_conflict_invalid_table(self): + with self.assertRaises(ValueError): + Conflict('foo') + + def test_conflict_invalid_indexed_columns(self): + with self.assertRaises(ValueError): + Conflict(self.table, indexed_columns=['foo']) + + def test_conflict_indexed_columns_invalid_table(self): + with self.assertRaises(ValueError): + Conflict(self.table, indexed_columns=[Table('t').c]) + + def test_conflict_invalid_index_where(self): + with self.assertRaises(ValueError): + Conflict(self.table, index_where='foo') + + def test_conflict_invalid_columns(self): + with self.assertRaises(ValueError): + Conflict(self.table, columns=['foo']) + + def test_conflict_columns_invalid_table(self): + with self.assertRaises(ValueError): + Conflict(self.table, columns=[Table('t').c]) + + def test_conflict_invalid_values(self): + with self.assertRaises(ValueError): + Conflict(self.table, values='foo') + + def test_conflict_invalid_where(self): + with self.assertRaises(ValueError): + Conflict(self.table, where='foo') diff --git a/sql/tests/test_join.py b/sql/tests/test_join.py index 7e537c9..1727b7f 100644 --- a/sql/tests/test_join.py +++ b/sql/tests/test_join.py @@ -20,6 +20,22 @@ def test_join(self): self.assertEqual(str(join), '"t1" AS "a" INNER JOIN "t2" AS "b" ON ("a"."c" = "b"."c")') + def test_join_invalid_left(self): + with self.assertRaises(ValueError): + Join('foo', Table('t1')) + + def test_join_invalid_right(self): + with self.assertRaises(ValueError): + Join(Table('t1'), 'foo') + + def test_join_invalid_condition(self): + with self.assertRaises(ValueError): + Join(Table('t1'), Table('t2'), condition='foo') + + def test_join_invalid_type(self): + with self.assertRaises(ValueError): + Join(Table('t1'), Table('t2'), type_='foo') + def test_join_subselect(self): t1 = Table('t1') t2 = Table('t2') diff --git a/sql/tests/test_merge.py b/sql/tests/test_merge.py index bfeb820..5f4e17c 100644 --- a/sql/tests/test_merge.py +++ b/sql/tests/test_merge.py @@ -4,8 +4,8 @@ import unittest from sql import ( - Matched, MatchedDelete, MatchedUpdate, NotMatched, NotMatchedInsert, Table, - With) + Literal, Matched, MatchedDelete, MatchedUpdate, Merge, NotMatched, + NotMatchedInsert, Table, With) class TestMerge(unittest.TestCase): @@ -22,6 +22,22 @@ def test_merge(self): 'WHEN MATCHED THEN DO NOTHING') self.assertEqual(query.params, ()) + def test_merge_invalid_target(self): + with self.assertRaises(ValueError): + Merge('foo', self.source, Literal(True)) + + def test_merge_invalid_source(self): + with self.assertRaises(ValueError): + self.target.merge('foo', Literal(True)) + + def test_merge_invalid_condition(self): + with self.assertRaises(ValueError): + self.target.merge(self.source, 'foo') + + def test_merge_invalid_whens(self): + with self.assertRaises(ValueError): + self.target.merge(self.source, Literal(True), 'foo') + def test_condition(self): query = self.target.merge( self.source, @@ -94,6 +110,14 @@ def test_not_matched_insert(self): 'INSERT ("c1", "c2") VALUES ("b"."c3", "b"."c4")') self.assertEqual(query.params, ()) + def test_matched_invalid_condition(self): + with self.assertRaises(ValueError): + Matched('foo') + + def test_matched_values_invalid_columns(self): + with self.assertRaises(ValueError): + MatchedUpdate('foo', []) + def test_with(self): t1 = Table('t1') w = With(query=t1.select(where=t1.c2 == 42)) diff --git a/sql/tests/test_operators.py b/sql/tests/test_operators.py index ac862e9..e6dd0a6 100644 --- a/sql/tests/test_operators.py +++ b/sql/tests/test_operators.py @@ -200,6 +200,10 @@ def test_is(self): self.assertEqual(str(is_), '("c1" IS FALSE)') self.assertEqual(is_.params, ()) + def test_is_invalid_right(self): + with self.assertRaises(ValueError): + Is(self.table.c, 'foo') + def test_is_not(self): for is_ in [IsNot(self.table.c1, None), ~Is(self.table.c1, None)]: @@ -315,6 +319,10 @@ def test_like_escape_empty_true(self): finally: Flavor.set(Flavor()) + def test_like_invalid_escape(self): + with self.assertRaises(ValueError): + Like(self.table.c, 'test', escape='fo') + def test_ilike(self): flavor = Flavor(ilike=True) Flavor.set(flavor) diff --git a/sql/tests/test_order.py b/sql/tests/test_order.py index f012d92..aca0f4f 100644 --- a/sql/tests/test_order.py +++ b/sql/tests/test_order.py @@ -3,7 +3,7 @@ import unittest from sql import ( - Asc, Column, Desc, Flavor, Literal, NullsFirst, NullsLast, Table) + Asc, Column, Desc, Flavor, Literal, NullsFirst, NullsLast, Order, Table) class TestOrder(unittest.TestCase): @@ -54,3 +54,7 @@ def test_order_query(self): '(SELECT "a"."c" FROM "t" AS "a") ASC') self.assertEqual(str(Desc(query)), '(SELECT "a"."c" FROM "t" AS "a") DESC') + + def test_invalid_expression(self): + with self.assertRaises(ValueError): + Order('foo') diff --git a/sql/tests/test_rollup.py b/sql/tests/test_rollup.py new file mode 100644 index 0000000..8b29227 --- /dev/null +++ b/sql/tests/test_rollup.py @@ -0,0 +1,13 @@ +# This file is part of python-sql. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. + +import unittest + +from sql import Rollup + + +class TestRollup(unittest.TestCase): + + def test_invalid_expressions(self): + with self.assertRaises(ValueError): + Rollup('foo') diff --git a/sql/tests/test_select.py b/sql/tests/test_select.py index 1f5a181..a58260d 100644 --- a/sql/tests/test_select.py +++ b/sql/tests/test_select.py @@ -49,6 +49,14 @@ def test_select_select_as(self): self.assertEqual(str(query), 'SELECT (SELECT %s) AS "foo"') self.assertEqual(tuple(query.params), (1,)) + def test_select_invalid_column(self): + with self.assertRaises(ValueError): + Select(['foo']) + + def test_select_invalid_where(self): + with self.assertRaises(ValueError): + self.table.select(where='foo') + def test_select_distinct(self): query = self.table.select(self.table.c, distinct=True) self.assertEqual( @@ -68,6 +76,10 @@ def test_select_distinct_on(self): 'SELECT DISTINCT ON ("a"."a", "a"."b") "a"."c" FROM "t" AS "a"') self.assertEqual(tuple(query.params), ()) + def test_select_invalid_distinct_on(self): + with self.assertRaises(ValueError): + self.table.select(self.table.c, distinct_on='foo') + def test_select_from_list(self): t2 = Table('t2') t3 = Table('t3') @@ -231,6 +243,10 @@ def test_select_group_by_cube(self): 'GROUP BY CUBE ("a"."a", "a"."b")') self.assertEqual(tuple(query.params), ('*',)) + def test_select_invalid_group_by(self): + with self.assertRaises(ValueError): + self.table.select(group_by=['foo']) + def test_select_having(self): col1 = self.table.col1 col2 = self.table.col2 @@ -241,6 +257,10 @@ def test_select_having(self): 'HAVING (MIN("a"."col2") > %s)') self.assertEqual(tuple(query.params), (3,)) + def test_select_invalid_having(self): + with self.assertRaises(ValueError): + self.table.select(having='foo') + def test_select_order(self): c = self.table.c query = self.table.select(c, order_by=Literal(1)) @@ -248,6 +268,10 @@ def test_select_order(self): 'SELECT "a"."c" FROM "t" AS "a" ORDER BY %s') self.assertEqual(tuple(query.params), (1,)) + def test_select_invalid_order(self): + with self.assertRaises(ValueError): + self.table.select(order_by='foo') + def test_select_limit_offset(self): try: Flavor.set(Flavor(limitstyle='limit')) @@ -285,6 +309,14 @@ def test_select_limit_offset(self): finally: Flavor.set(Flavor()) + def test_select_invalid_limit(self): + with self.assertRaises(ValueError): + self.table.select(limit='foo') + + def test_select_invalid_offset(self): + with self.assertRaises(ValueError): + self.table.select(offset='foo') + def test_select_offset_fetch(self): try: Flavor.set(Flavor(limitstyle='fetch')) @@ -390,6 +422,10 @@ def test_select_for(self): 'SELECT "a"."c" FROM "t" AS "a" FOR UPDATE') self.assertEqual(tuple(query.params), ()) + def test_select_invalid_for(self): + with self.assertRaises(ValueError): + self.table.select(for_=['foo']) + def test_copy(self): query = self.table.select() copy_query = deepcopy(query) @@ -472,6 +508,10 @@ def test_window(self): 'WINDOW "b" AS (PARTITION BY "a"."c2")') self.assertEqual(tuple(query.params), (1,)) + def test_select_invalid_window(self): + with self.assertRaises(ValueError): + self.table.select(windows=['foo']) + def test_order_params(self): with_ = With(query=self.table.select(self.table.c, where=(self.table.c > 1))) diff --git a/sql/tests/test_update.py b/sql/tests/test_update.py index 89f03f1..74c5acc 100644 --- a/sql/tests/test_update.py +++ b/sql/tests/test_update.py @@ -27,6 +27,14 @@ def test_update2(self): 'WHERE ("b"."c" = "a"."c")') self.assertEqual(query.params, ('foo',)) + def test_update_invalid_values(self): + with self.assertRaises(ValueError): + self.table.update([self.table.c], 'foo') + + def test_update_invalid_where(self): + with self.assertRaises(ValueError): + self.table.update([self.table.c], ['foo'], where='foo') + def test_update_subselect(self): t1 = Table('t1') t2 = Table('t2') diff --git a/sql/tests/test_window.py b/sql/tests/test_window.py index c67bf3b..ae51835 100644 --- a/sql/tests/test_window.py +++ b/sql/tests/test_window.py @@ -14,6 +14,10 @@ def test_window(self): self.assertEqual(str(window), 'PARTITION BY "c1", "c2"') self.assertEqual(window.params, ()) + def test_window_invalid_partition(self): + with self.assertRaises(ValueError): + Window(['foo']) + def test_window_order(self): t = Table('t') window = Window([t.c], order_by=t.c) @@ -21,6 +25,10 @@ def test_window_order(self): self.assertEqual(str(window), 'PARTITION BY "c" ORDER BY "c"') self.assertEqual(window.params, ()) + def test_window_invalid_order(self): + with self.assertRaises(ValueError): + Window([Table('t').c], order_by='foo') + def test_window_range(self): t = Table('t') window = Window([t.c], frame='RANGE') @@ -50,6 +58,18 @@ def test_window_range(self): 'BETWEEN %s FOLLOWING AND UNBOUNDED FOLLOWING') self.assertEqual(window.params, (1,)) + def test_window_invalid_frame(self): + with self.assertRaises(ValueError): + Window([Table('t').c], frame='foo') + + def test_window_invalid_start(self): + with self.assertRaises(ValueError): + Window([Table('t').c], start='foo') + + def test_window_invalid_end(self): + with self.assertRaises(ValueError): + Window([Table('t').c], end='foo') + def test_window_exclude(self): t = Table('t') window = Window([t.c], exclude='TIES') @@ -58,6 +78,10 @@ def test_window_exclude(self): 'PARTITION BY "c" EXCLUDE TIES') self.assertEqual(window.params, ()) + def test_window_invalid_exclude(self): + with self.assertRaises(ValueError): + Window([Table('t').c], exclude='foo') + def test_window_rows(self): t = Table('t') window = Window([t.c], frame='ROWS') diff --git a/sql/tests/test_with.py b/sql/tests/test_with.py index f608b1c..ab465d4 100644 --- a/sql/tests/test_with.py +++ b/sql/tests/test_with.py @@ -62,3 +62,7 @@ def test_recursive(self): 'SELECT ("a"."n" + %s) FROM "a" AS "a" WHERE ("a"."n" < %s)' ') SELECT * FROM "a" AS "a"') self.assertEqual(tuple(q.params), (1, 1, 100)) + + def test_invalid_with(self): + with self.assertRaises(ValueError): + WithQuery(with_=['foo']) From a406c429ba714f012ee316e981f091d97d987f2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Wed, 29 May 2024 23:59:31 +0200 Subject: [PATCH 42/79] Use Select column formatting for returning of Update and Delete --- sql/__init__.py | 21 ++++++++++----------- sql/tests/test_delete.py | 8 ++++++++ 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/sql/__init__.py b/sql/__init__.py index 8827536..24effe2 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -1010,6 +1010,10 @@ def where(self, value): raise ValueError("invalid where: %r" % value) self._where = value + @staticmethod + def _format_column(value): + return Select._format_column(value) + def __str__(self): assert all(col.table == self.table for col in self.columns) # Get columns without alias @@ -1027,7 +1031,7 @@ def __str__(self): returning = '' if self.returning: returning = ' RETURNING ' + ', '.join( - map(self._format, self.returning)) + map(self._format_column, self.returning)) return (self._with_str() + 'UPDATE %s AS "%s" SET ' % (self.table, self.table.alias) + values + from_ + where + returning) @@ -1095,20 +1099,15 @@ def returning(self): @returning.setter def returning(self, value): if value is not None: - if not isinstance(value, list): + if any( + not isinstance(col, (Expression, SelectQuery)) + for col in value): raise ValueError("invalid returning: %r" % value) self._returning = value @staticmethod - def _format(value, param=None): - if param is None: - param = Flavor.get().param - if isinstance(value, Expression): - return str(value) - elif isinstance(value, Select): - return '(%s)' % value - else: - return param + def _format(value): + return Select._format_column(value) def __str__(self): with AliasManager(exclude=[self.table]): diff --git a/sql/tests/test_delete.py b/sql/tests/test_delete.py index e94ebeb..4048db6 100644 --- a/sql/tests/test_delete.py +++ b/sql/tests/test_delete.py @@ -40,6 +40,14 @@ def test_delete_returning(self): self.assertEqual(str(query), 'DELETE FROM "t" RETURNING "c"') self.assertEqual(query.params, ()) + def test_delet_returning_select(self): + query = self.table.delete(returning=[self.table.select()]) + + self.assertEqual( + str(query), + 'DELETE FROM "t" RETURNING (SELECT * FROM "t")') + self.assertEqual(query.params, ()) + def test_delete_invalid_returning(self): with self.assertRaises(ValueError): self.table.delete(returning='foo') From fa940a8c85ef6c234b7087c3e3fae849903ea666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Thu, 30 May 2024 00:03:51 +0200 Subject: [PATCH 43/79] Support default values when inserting not matched merge --- CHANGELOG | 1 + sql/__init__.py | 6 ++++-- sql/tests/test_merge.py | 12 ++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 845b2f5..efc0407 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Support default values when inserting not matched merge * Replace assert by ValueError Version 1.5.1 - 2024-05-28 diff --git a/sql/__init__.py b/sql/__init__.py index 24effe2..0607472 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -1324,13 +1324,15 @@ def values(self): @values.setter def values(self, value): - self._values = Values([value]) + if value is not None: + value = Values([value]) + self._values = value def _then_str(self): columns = ', '.join(c.column_name for c in self.columns) columns = '(' + columns + ')' if self.values is None: - values = ' DEFAULT VALUES ' + values = ' DEFAULT VALUES' else: values = ' ' + str(self.values) return 'INSERT ' + columns + values diff --git a/sql/tests/test_merge.py b/sql/tests/test_merge.py index 5f4e17c..63a53e8 100644 --- a/sql/tests/test_merge.py +++ b/sql/tests/test_merge.py @@ -110,6 +110,18 @@ def test_not_matched_insert(self): 'INSERT ("c1", "c2") VALUES ("b"."c3", "b"."c4")') self.assertEqual(query.params, ()) + def test_not_matched_insert_default(self): + query = self.target.merge( + self.source, self.target.c1 == self.source.c2, + NotMatchedInsert([self.target.c1, self.target.c2], None)) + self.assertEqual( + str(query), + 'MERGE INTO "t" AS "a" USING "s" AS "b" ' + 'ON ("a"."c1" = "b"."c2") ' + 'WHEN NOT MATCHED THEN ' + 'INSERT ("c1", "c2") DEFAULT VALUES') + self.assertEqual(query.params, ()) + def test_matched_invalid_condition(self): with self.assertRaises(ValueError): Matched('foo') From e3e11edb5c46da332d80956a70f93b7a1c00551f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Thu, 30 May 2024 00:04:39 +0200 Subject: [PATCH 44/79] Reach 100% of test coverage --- sql/__init__.py | 19 ++++++------------- sql/functions.py | 5 +---- sql/tests/test_alias.py | 15 +++++++++++++++ sql/tests/test_excluded.py | 15 +++++++++++++++ sql/tests/test_expression.py | 17 +++++++++++++++++ sql/tests/test_join.py | 7 +++++++ sql/tests/test_lateral.py | 2 +- sql/tests/test_merge.py | 8 +++++--- sql/tests/test_operators.py | 9 ++++++++- sql/tests/test_order.py | 19 ++++++++++++------- sql/tests/test_select.py | 16 ++++++++++++++++ 11 files changed, 103 insertions(+), 29 deletions(-) create mode 100644 sql/tests/test_excluded.py create mode 100644 sql/tests/test_expression.py diff --git a/sql/__init__.py b/sql/__init__.py index 0607472..67414a0 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -1195,10 +1195,7 @@ def __str__(self): source = '(%s)' % self.source else: source = self.source - if self.condition: - condition = 'ON %s' % self.condition - else: - condition = '' + condition = 'ON %s' % self.condition return (self._with_str() + 'MERGE INTO %s AS "%s" ' % (self.target, self.target.alias) + 'USING %s AS "%s" ' % (source, self.source.alias) @@ -1340,7 +1337,8 @@ def _then_str(self): @property def params(self): p = list(super().params) - p.extend(self.values.params) + if self.values: + p.extend(self.values.params) return tuple(p) @@ -1522,14 +1520,9 @@ def __str__(self): def params(self): p = [] for item in (self.left, self.right): - try: - p.extend(item.params) - except AttributeError: - pass - try: + p.extend(item.params) + if self.condition: p.extend(self.condition.params) - except AttributeError: - pass return tuple(p) @property @@ -1611,7 +1604,7 @@ def format_(value): @property def params(self): - p = [] + p = list(super().params) for values in self: for value in values: if isinstance(value, Expression): diff --git a/sql/functions.py b/sql/functions.py index 053e5e9..c617c9c 100644 --- a/sql/functions.py +++ b/sql/functions.py @@ -318,10 +318,7 @@ def params(self): if isinstance(arg, str): p.append(arg) else: - try: - p.extend(arg.params) - except AttributeError: - pass + p.extend(arg.params) return tuple(p) diff --git a/sql/tests/test_alias.py b/sql/tests/test_alias.py index a630e2e..c37ad27 100644 --- a/sql/tests/test_alias.py +++ b/sql/tests/test_alias.py @@ -61,3 +61,18 @@ def test_threading(self): self.finish2.wait() if not self.succeed1.is_set() or not self.succeed2.is_set(): self.fail() + + def test_contains(self): + with AliasManager(): + AliasManager.get(self.t1) + self.assertTrue(AliasManager.contains(self.t1)) + + def test_contains_exclude(self): + with AliasManager(exclude=[self.t1]): + self.assertEqual(AliasManager.get(self.t1), '') + self.assertFalse(AliasManager.contains(self.t1)) + + def test_set(self): + with AliasManager(): + AliasManager.set(self.t1, 'foo') + self.assertEqual(AliasManager.get(self.t1), 'foo') diff --git a/sql/tests/test_excluded.py b/sql/tests/test_excluded.py new file mode 100644 index 0000000..fb47e53 --- /dev/null +++ b/sql/tests/test_excluded.py @@ -0,0 +1,15 @@ +# This file is part of python-sql. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. + +import unittest + +from sql import Excluded + + +class TestExcluded(unittest.TestCase): + + def test_alias(self): + self.assertEqual(Excluded.alias, 'EXCLUDED') + + def test_has_alias(self): + self.assertFalse(Excluded.has_alias) diff --git a/sql/tests/test_expression.py b/sql/tests/test_expression.py new file mode 100644 index 0000000..0741094 --- /dev/null +++ b/sql/tests/test_expression.py @@ -0,0 +1,17 @@ +# This file is part of python-sql. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. + +import unittest + +from sql import Expression + + +class TestExpression(unittest.TestCase): + + def test_str(self): + with self.assertRaises(NotImplementedError): + str(Expression()) + + def test_params(self): + with self.assertRaises(NotImplementedError): + Expression().params diff --git a/sql/tests/test_join.py b/sql/tests/test_join.py index 1727b7f..ddd4e49 100644 --- a/sql/tests/test_join.py +++ b/sql/tests/test_join.py @@ -66,3 +66,10 @@ def test_join_methods(self): join = getattr(t1, method)(t2) type_ = method[:-len('_join')].replace('_', ' ').upper() self.assertEqual(join.type_, type_) + + def test_join_alias(self): + join = Join(Table('t1'), Table('t2')) + with self.assertRaises(AttributeError): + join.alias + with self.assertRaises(AttributeError): + join.has_alias diff --git a/sql/tests/test_lateral.py b/sql/tests/test_lateral.py index d70bee0..b1bbc89 100644 --- a/sql/tests/test_lateral.py +++ b/sql/tests/test_lateral.py @@ -11,7 +11,7 @@ class TestLateral(unittest.TestCase): def test_lateral_select(self): t1 = Table('t1') t2 = Table('t2') - lateral = Lateral(t2.select(where=t2.id == t1.t2)) + lateral = t2.select(where=t2.id == t1.t2).lateral() query = From([t1, lateral]).select() self.assertEqual(str(query), diff --git a/sql/tests/test_merge.py b/sql/tests/test_merge.py index 63a53e8..624c1b0 100644 --- a/sql/tests/test_merge.py +++ b/sql/tests/test_merge.py @@ -67,14 +67,16 @@ def test_matched(self): def test_matched_update(self): query = self.target.merge( self.source, self.target.c1 == self.source.c2, - MatchedUpdate([self.target.c1], [self.target.c1 + self.source.c2])) + MatchedUpdate( + [self.target.c1, self.target.c2], + [self.target.c1 + self.source.c2, 42])) self.assertEqual( str(query), 'MERGE INTO "t" AS "a" USING "s" AS "b" ' 'ON ("a"."c1" = "b"."c2") ' 'WHEN MATCHED THEN ' - 'UPDATE SET "c1" = ("a"."c1" + "b"."c2")') - self.assertEqual(query.params, ()) + 'UPDATE SET "c1" = ("a"."c1" + "b"."c2"), "c2" = %s') + self.assertEqual(query.params, (42,)) def test_matched_delete(self): query = self.target.merge( diff --git a/sql/tests/test_operators.py b/sql/tests/test_operators.py index e6dd0a6..4c9e835 100644 --- a/sql/tests/test_operators.py +++ b/sql/tests/test_operators.py @@ -9,12 +9,19 @@ Abs, And, Between, Div, Equal, Exists, FloorDiv, Greater, GreaterEqual, ILike, In, Is, IsDistinct, IsNot, IsNotDistinct, Less, LessEqual, Like, LShift, Mod, Mul, Neg, Not, NotBetween, NotEqual, NotILike, NotIn, NotLike, - Or, Pos, Pow, RShift, Sub) + Operator, Or, Pos, Pow, RShift, Sub) class TestOperators(unittest.TestCase): table = Table('t') + def test_operator_operands(self): + self.assertEqual(Operator()._operands, ()) + + def test_operator_str(self): + with self.assertRaises(NotImplementedError): + str(Operator()) + def test_and(self): for and_ in [And((self.table.c1, self.table.c2)), self.table.c1 & self.table.c2]: diff --git a/sql/tests/test_order.py b/sql/tests/test_order.py index aca0f4f..36ac834 100644 --- a/sql/tests/test_order.py +++ b/sql/tests/test_order.py @@ -3,28 +3,33 @@ import unittest from sql import ( - Asc, Column, Desc, Flavor, Literal, NullsFirst, NullsLast, Order, Table) + Asc, Column, Desc, Flavor, Literal, NullOrder, NullsFirst, NullsLast, + Order, Table) class TestOrder(unittest.TestCase): column = Column(Table('t'), 'c') def test_asc(self): - self.assertEqual(str(Asc(self.column)), '"c" ASC') + self.assertEqual(str(self.column.asc), '"c" ASC') def test_desc(self): - self.assertEqual(str(Desc(self.column)), '"c" DESC') + self.assertEqual(str(self.column.desc), '"c" DESC') def test_nulls_first(self): - self.assertEqual(str(NullsFirst(self.column)), '"c" NULLS FIRST') - self.assertEqual(str(NullsFirst(Asc(self.column))), + self.assertEqual(str(self.column.nulls_first), '"c" NULLS FIRST') + self.assertEqual(str(Asc(self.column).nulls_first), '"c" ASC NULLS FIRST') def test_nulls_last(self): - self.assertEqual(str(NullsLast(self.column)), '"c" NULLS LAST') - self.assertEqual(str(NullsLast(Asc(self.column))), + self.assertEqual(str(self.column.nulls_last), '"c" NULLS LAST') + self.assertEqual(str(Asc(self.column).nulls_last), '"c" ASC NULLS LAST') + def test_null_order_case_values(self): + with self.assertRaises(NotImplementedError): + NullOrder(self.column)._case_values() + def test_no_null_ordering(self): try: Flavor.set(Flavor(null_ordering=False)) diff --git a/sql/tests/test_select.py b/sql/tests/test_select.py index a58260d..13a1c3b 100644 --- a/sql/tests/test_select.py +++ b/sql/tests/test_select.py @@ -34,6 +34,12 @@ def test_select3(self): 'SELECT * FROM "t" AS "a" WHERE ("a"."c" = %s)') self.assertEqual(tuple(query.params), ('foo',)) + def test_select_iter(self): + query = self.table.select() + self.assertEqual( + tuple(query), + ('SELECT * FROM "t" AS "a"', ())) + def test_select_without_from(self): query = Select([Literal(1)]) self.assertEqual(str(query), 'SELECT %s') @@ -508,6 +514,16 @@ def test_window(self): 'WINDOW "b" AS (PARTITION BY "a"."c2")') self.assertEqual(tuple(query.params), (1,)) + def test_window_with_alias(self): + query = self.table.select( + Min(self.table.c1, window=Window([self.table.c2])).as_('min')) + + self.assertEqual( + str(query), + 'SELECT MIN("a"."c1") OVER "b" AS "min" FROM "t" AS "a" ' + 'WINDOW "b" AS (PARTITION BY "a"."c2")') + self.assertEqual(query.params, ()) + def test_select_invalid_window(self): with self.assertRaises(ValueError): self.table.select(windows=['foo']) From 698de9c0f53dfc8a63aff3a574f6a8e8b06653fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sat, 8 Jun 2024 09:17:47 +0200 Subject: [PATCH 45/79] Omit tox directory in coverage --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index f65a90c..44c6ef7 100644 --- a/tox.ini +++ b/tox.ini @@ -9,7 +9,7 @@ envlist = py35, py36, py37, py38, py39, py310, py311, py312, pypy3 [testenv] usedevelop = true commands = - coverage run --omit=*/tests/* -m xmlrunner discover -s sql.tests {posargs} + coverage run --omit=*/tests/*,*/.tox/* -m xmlrunner discover -s sql.tests {posargs} commands_post = coverage report --omit=README.rst coverage xml --omit=README.rst From 4c65941218b4186e5b7af974534dbd4cf42af6ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 23 Sep 2024 23:21:39 +0200 Subject: [PATCH 46/79] Use parameter for unary operator Closes #93 --- CHANGELOG | 1 + sql/operators.py | 3 ++- sql/tests/test_operators.py | 4 ++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index efc0407..cd5d20a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Use parameter for unary operator * Support default values when inserting not matched merge * Replace assert by ValueError diff --git a/sql/operators.py b/sql/operators.py index 31a06a5..9cd2831 100644 --- a/sql/operators.py +++ b/sql/operators.py @@ -121,7 +121,8 @@ def _operands(self): return self def __str__(self): - return '(' + (' %s ' % self._operator).join(map(str, self)) + ')' + return '(' + (' %s ' % self._operator).join( + map(self._format, self)) + ')' class And(NaryOperator): diff --git a/sql/tests/test_operators.py b/sql/tests/test_operators.py index 4c9e835..eb8c385 100644 --- a/sql/tests/test_operators.py +++ b/sql/tests/test_operators.py @@ -32,6 +32,10 @@ def test_and(self): self.assertEqual(str(and_), '(%s AND "c2")') self.assertEqual(and_.params, (True,)) + and_ = And((Literal(True), 'foo')) + self.assertEqual(str(and_), '(%s AND %s)') + self.assertEqual(and_.params, (True, 'foo')) + def test_operator_operators(self): and_ = And((Literal(True), self.table.c1)) and2 = and_ & And((Literal(True), self.table.c2)) From e944187fbad1ffa4ece009ae1cd8529431cafc55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 30 Sep 2024 22:43:34 +0200 Subject: [PATCH 47/79] Prepare release --- CHANGELOG | 1 + COPYRIGHT | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index cd5d20a..ad5ff0d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +Version 1.5.2 - 2024-09-30 * Use parameter for unary operator * Support default values when inserting not matched merge * Replace assert by ValueError diff --git a/COPYRIGHT b/COPYRIGHT index d45a6e0..0f79b04 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -1,6 +1,6 @@ -Copyright (c) 2011-2023, Cédric Krier +Copyright (c) 2011-2024, Cédric Krier Copyright (c) 2013-2023, Nicolas Évrard -Copyright (c) 2011-2023, B2CK +Copyright (c) 2011-2024, B2CK All rights reserved. Redistribution and use in source and binary forms, with or without From 098907f04711ea9d89048cbc12a752bcd1cbb881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 30 Sep 2024 22:43:40 +0200 Subject: [PATCH 48/79] Added tag 1.5.2 for changeset 41b0aaa68f5e --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index 35e1853..4d65d7c 100644 --- a/.hgtags +++ b/.hgtags @@ -19,3 +19,4 @@ fcb64787b51db2068061eb4aa13825abc1134916 1.4.2 111e3e86865360f83a65c04fa48c55f3d2957ee3 1.4.3 6f9066b83fe3a8c4699a8555ad1bc406f18974ff 1.5.0 79a69b0bbbd35a8d95e1b754ed3feb03df23fb70 1.5.1 +41b0aaa68f5e5bab3889fa1ef57ef44c6c21cacf 1.5.2 From 2fba17e14afb101ecd8d6def1143f89f4b7f4103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 30 Sep 2024 22:47:44 +0200 Subject: [PATCH 49/79] Increase version number --- sql/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/__init__.py b/sql/__init__.py index 67414a0..4377ee2 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -7,7 +7,7 @@ from itertools import chain from threading import current_thread, local -__version__ = '1.5.2' +__version__ = '1.5.3' __all__ = [ 'Flavor', 'Table', 'Values', 'Literal', 'Column', 'Grouping', 'Conflict', 'Matched', 'MatchedUpdate', 'MatchedDelete', From 38ad7044f95c75f9fc6cbe652325258601a99365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 29 Dec 2024 13:08:50 +0100 Subject: [PATCH 50/79] Add support for Python 3.13 --- .gitlab-ci.yml | 2 +- CHANGELOG | 2 ++ setup.py | 1 + tox.ini | 2 +- 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2246f77..276daaa 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -57,7 +57,7 @@ test-tox-python: - tox -e "py${PYTHON_VERSION/./}" -- -v --output-file junit.xml parallel: matrix: - - PYTHON_VERSION: ["3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + - PYTHON_VERSION: ["3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] test-tox-pypy: extends: .test-tox diff --git a/CHANGELOG b/CHANGELOG index ad5ff0d..36664f1 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +* Add support for Python 3.13 + Version 1.5.2 - 2024-09-30 * Use parameter for unary operator * Support default values when inserting not matched merge diff --git a/setup.py b/setup.py index a823dec..dc6583b 100644 --- a/setup.py +++ b/setup.py @@ -48,6 +48,7 @@ def get_version(): 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', 'Topic :: Database', 'Topic :: Software Development :: Libraries :: Python Modules', ], diff --git a/tox.ini b/tox.ini index 44c6ef7..c7c2d4e 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = py35, py36, py37, py38, py39, py310, py311, py312, pypy3 +envlist = py35, py36, py37, py38, py39, py310, py311, py312, py313, pypy3 [testenv] usedevelop = true From 0aa03d7d3b442dd3608f71571dd4a097f692c387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20=C3=89vrard?= Date: Wed, 19 Feb 2025 12:34:46 +0100 Subject: [PATCH 51/79] Re-enable the tests on the README file Since 5e5e6d259b9b the tests were disabled --- README.rst | 18 +++++++++--------- sql/tests/__init__.py | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.rst b/README.rst index fc87231..bd3ad74 100644 --- a/README.rst +++ b/README.rst @@ -106,23 +106,23 @@ Select on other schema:: Insert query with default values:: >>> tuple(user.insert()) - ('INSERT INTO "user" AS "a" DEFAULT VALUES', ()) + ('INSERT INTO "user" DEFAULT VALUES', ()) Insert query with values:: >>> tuple(user.insert(columns=[user.name, user.login], ... values=[['Foo', 'foo']])) - ('INSERT INTO "user" AS "a" ("name", "login") VALUES (%s, %s)', ('Foo', 'foo')) + ('INSERT INTO "user" ("name", "login") VALUES (%s, %s)', ('Foo', 'foo')) >>> tuple(user.insert(columns=[user.name, user.login], ... values=[['Foo', 'foo'], ['Bar', 'bar']])) - ('INSERT INTO "user" AS "a" ("name", "login") VALUES (%s, %s), (%s, %s)', ('Foo', 'foo', 'Bar', 'bar')) + ('INSERT INTO "user" ("name", "login") VALUES (%s, %s), (%s, %s)', ('Foo', 'foo', 'Bar', 'bar')) Insert query with query:: >>> passwd = Table('passwd') >>> select = passwd.select(passwd.login, passwd.passwd) >>> tuple(user.insert(values=select)) - ('INSERT INTO "user" AS "b" SELECT "a"."login", "a"."passwd" FROM "passwd" AS "a"', ()) + ('INSERT INTO "user" SELECT "a"."login", "a"."passwd" FROM "passwd" AS "a"', ()) Update query with values:: @@ -166,23 +166,23 @@ Flavors:: >>> select.offset = 10 >>> Flavor.set(Flavor()) >>> tuple(select) - ('SELECT * FROM "user" AS "a" OFFSET 10', ()) + ('SELECT * FROM "user" AS "a" OFFSET %s', (10,)) >>> Flavor.set(Flavor(max_limit=18446744073709551615)) >>> tuple(select) - ('SELECT * FROM "user" AS "a" LIMIT 18446744073709551615 OFFSET 10', ()) + ('SELECT * FROM "user" AS "a" LIMIT 18446744073709551615 OFFSET %s', (10,)) >>> Flavor.set(Flavor(max_limit=-1)) >>> tuple(select) - ('SELECT * FROM "user" AS "a" LIMIT -1 OFFSET 10', ()) + ('SELECT * FROM "user" AS "a" LIMIT -1 OFFSET %s', (10,)) Limit style:: >>> select = user.select(limit=10, offset=20) >>> Flavor.set(Flavor(limitstyle='limit')) >>> tuple(select) - ('SELECT * FROM "user" AS "a" LIMIT 10 OFFSET 20', ()) + ('SELECT * FROM "user" AS "a" LIMIT %s OFFSET %s', (10, 20)) >>> Flavor.set(Flavor(limitstyle='fetch')) >>> tuple(select) - ('SELECT * FROM "user" AS "a" OFFSET (20) ROWS FETCH FIRST (10) ROWS ONLY', ()) + ('SELECT * FROM "user" AS "a" OFFSET (%s) ROWS FETCH FIRST (%s) ROWS ONLY', (20, 10)) >>> Flavor.set(Flavor(limitstyle='rownum')) >>> tuple(select) ('SELECT "a".* FROM (SELECT "b".*, ROWNUM AS "rnum" FROM (SELECT * FROM "user" AS "c") AS "b" WHERE (ROWNUM <= %s)) AS "a" WHERE ("rnum" > %s)', (30, 20)) diff --git a/sql/tests/__init__.py b/sql/tests/__init__.py index e1048d4..099392d 100644 --- a/sql/tests/__init__.py +++ b/sql/tests/__init__.py @@ -6,7 +6,7 @@ import sql here = os.path.dirname(__file__) -readme = os.path.normpath(os.path.join(here, '..', '..', 'README')) +readme = os.path.normpath(os.path.join(here, '..', '..', 'README.rst')) def load_tests(loader, tests, pattern): From 5cb7f91cd3e9a2cb3825a0e257c13d1f0f41e823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20=C3=89vrard?= Date: Sun, 30 Mar 2025 23:52:36 +0200 Subject: [PATCH 52/79] Add support for weak reference on SQL objects --- CHANGELOG | 1 + sql/__init__.py | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 36664f1..867a1d6 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Add support for weak reference on SQL objects * Add support for Python 3.13 Version 1.5.2 - 2024-09-30 diff --git a/sql/__init__.py b/sql/__init__.py index 4377ee2..b6546bf 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -182,7 +182,7 @@ def format2numeric(query, params): class Query(object): - __slots__ = () + __slots__ = ('__weakref__',) @property def params(self): @@ -243,7 +243,7 @@ def _with_params(self): class FromItem(object): - __slots__ = () + __slots__ = ('__weakref__',) @property def alias(self): @@ -1615,7 +1615,7 @@ def params(self): class Expression(object): - __slots__ = () + __slots__ = ('__weakref__',) def __str__(self): raise NotImplementedError @@ -1986,7 +1986,8 @@ class Cube(Rollup): class Window(object): __slots__ = ( - '_partition', '_order_by', '_frame', '_start', '_end', '_exclude') + '_partition', '_order_by', '_frame', '_start', '_end', '_exclude', + '__weakref__') def __init__(self, partition, order_by=None, frame=None, start=None, end=0, exclude=None): From cdbcb2bf491281570d6f26242ba7a5828b2f747e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Fri, 2 May 2025 09:15:41 +0200 Subject: [PATCH 53/79] Fix position of order_by parameters in Select query The order_by must come after the parameters of having and windows. Closes #94 --- CHANGELOG | 1 + sql/__init__.py | 6 +++--- sql/tests/test_select.py | 16 +++++++++++++--- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 867a1d6..6968cff 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Fix position of order_by parameters in Select query * Add support for weak reference on SQL objects * Add support for Python 3.13 diff --git a/sql/__init__.py b/sql/__init__.py index b6546bf..82999b0 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -692,13 +692,13 @@ def params(self): if self.group_by: for expression in self.group_by: p.extend(expression.params) - if self.order_by: - for expression in self.order_by: - p.extend(expression.params) if self.having: p.extend(self.having.params) for window in self.windows: p.extend(window.params) + if self.order_by: + for expression in self.order_by: + p.extend(expression.params) p.extend(self._limit_offset_params) return tuple(p) diff --git a/sql/tests/test_select.py b/sql/tests/test_select.py index 13a1c3b..3885f0e 100644 --- a/sql/tests/test_select.py +++ b/sql/tests/test_select.py @@ -531,14 +531,24 @@ def test_select_invalid_window(self): def test_order_params(self): with_ = With(query=self.table.select(self.table.c, where=(self.table.c > 1))) - w = Window([Literal(8)]) + w = Window([Literal(7)]) query = Select([Literal(2), Min(self.table.c, window=w)], from_=self.table.select(where=self.table.c > 3), with_=with_, where=self.table.c > 4, group_by=[Literal(5)], - order_by=[Literal(6)], - having=Literal(7)) + having=Literal(6), + order_by=[Literal(8)]) + self.assertEqual( + str(query), + 'WITH "c" AS (SELECT "a"."c" FROM "t" AS "a" WHERE ("a"."c" > %s))' + ' SELECT %s, MIN("a"."c") OVER "b" ' + 'FROM SELECT * FROM "t" AS "a" WHERE ("a"."c" > %s) ' + 'WHERE ("a"."c" > %s) ' + 'GROUP BY %s ' + 'HAVING %s ' + 'WINDOW "b" AS (PARTITION BY %s) ' + 'ORDER BY %s') self.assertEqual(tuple(query.params), (1, 2, 3, 4, 5, 6, 7, 8)) def test_no_as(self): From 9dd6198dc1dcf6a57edecbfd16a48b8bb194cfd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Fri, 2 May 2025 21:54:13 +0200 Subject: [PATCH 54/79] Prepare release --- CHANGELOG | 1 + COPYRIGHT | 6 +++--- sql/__init__.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 6968cff..54a3075 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +Version 1.6.0 - 2025-05-02 * Fix position of order_by parameters in Select query * Add support for weak reference on SQL objects * Add support for Python 3.13 diff --git a/COPYRIGHT b/COPYRIGHT index 0f79b04..da57efd 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -1,6 +1,6 @@ -Copyright (c) 2011-2024, Cédric Krier -Copyright (c) 2013-2023, Nicolas Évrard -Copyright (c) 2011-2024, B2CK +Copyright (c) 2011-2025, Cédric Krier +Copyright (c) 2013-2025, Nicolas Évrard +Copyright (c) 2011-2025, B2CK All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/sql/__init__.py b/sql/__init__.py index 82999b0..012d131 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -7,7 +7,7 @@ from itertools import chain from threading import current_thread, local -__version__ = '1.5.3' +__version__ = '1.6.0' __all__ = [ 'Flavor', 'Table', 'Values', 'Literal', 'Column', 'Grouping', 'Conflict', 'Matched', 'MatchedUpdate', 'MatchedDelete', From 29348b132ffc8b2af2b7f27a80f7e85cf7b1ca34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Fri, 2 May 2025 21:54:17 +0200 Subject: [PATCH 55/79] Added tag 1.6.0 for changeset 475502ba46eb --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index 4d65d7c..71c45b3 100644 --- a/.hgtags +++ b/.hgtags @@ -20,3 +20,4 @@ fcb64787b51db2068061eb4aa13825abc1134916 1.4.2 6f9066b83fe3a8c4699a8555ad1bc406f18974ff 1.5.0 79a69b0bbbd35a8d95e1b754ed3feb03df23fb70 1.5.1 41b0aaa68f5e5bab3889fa1ef57ef44c6c21cacf 1.5.2 +475502ba46eba3b7e141e8fbceaf495b545bcddb 1.6.0 From 7f00365d60e165be59973d25dd7449fdb1453c3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Fri, 2 May 2025 21:57:09 +0200 Subject: [PATCH 56/79] Increase version number --- sql/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/__init__.py b/sql/__init__.py index 012d131..2f6db37 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -7,7 +7,7 @@ from itertools import chain from threading import current_thread, local -__version__ = '1.6.0' +__version__ = '1.6.1' __all__ = [ 'Flavor', 'Table', 'Values', 'Literal', 'Column', 'Grouping', 'Conflict', 'Matched', 'MatchedUpdate', 'MatchedDelete', From 21dc7a84e27d9d4571fe46fae28ba02642e18cd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Wed, 18 Jun 2025 15:36:56 +0200 Subject: [PATCH 57/79] Do not use parameters for COUNT(*) Closes #95 --- CHANGELOG | 2 ++ sql/aggregate.py | 22 ++++++++++++++++------ sql/tests/test_aggregate.py | 4 ++-- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 54a3075..39bb87b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +* Do not use parameters for COUNT(*) + Version 1.6.0 - 2025-05-02 * Fix position of order_by parameters in Select query * Add support for weak reference on SQL objects diff --git a/sql/aggregate.py b/sql/aggregate.py index c3210f4..dca5394 100644 --- a/sql/aggregate.py +++ b/sql/aggregate.py @@ -4,7 +4,6 @@ __all__ = ['Avg', 'BitAnd', 'BitOr', 'BoolAnd', 'BoolOr', 'Count', 'Every', 'Max', 'Min', 'Stddev', 'Sum', 'Variance'] -_sentinel = object() class Aggregate(Expression): @@ -169,20 +168,31 @@ class BoolOr(Aggregate): _sql = 'BOOL_OR' +class _Star(Expression): + __slots__ = () + + def __str__(self): + return '*' + + @property + def params(self): + return () + + class Count(Aggregate): __slots__ = () _sql = 'COUNT' - def __init__(self, expression=_sentinel, **kwargs): - if expression is _sentinel: - expression = Literal('*') + def __init__(self, expression=_Star(), **kwargs): super().__init__(expression, **kwargs) @property def _case_expression(self): expression = super(Count, self)._case_expression - if (isinstance(self.expression, Literal) - and expression.value == '*'): + if (isinstance(self.expression, _Star) + # Keep testing Literal('*') for backward compatibility + or (isinstance(self.expression, Literal) + and expression.value == '*')): expression = Literal(1) return expression diff --git a/sql/tests/test_aggregate.py b/sql/tests/test_aggregate.py index 88d45e1..32698bd 100644 --- a/sql/tests/test_aggregate.py +++ b/sql/tests/test_aggregate.py @@ -42,8 +42,8 @@ def test_avg(self): def test_count_without_expression(self): count = Count() - self.assertEqual(str(count), 'COUNT(%s)') - self.assertEqual(count.params, ('*',)) + self.assertEqual(str(count), 'COUNT(*)') + self.assertEqual(count.params, ()) def test_order_by_one_column(self): avg = Avg(self.table.a, order_by=self.table.b) From fd2e81582ad0c107fc8838318ec5a080cbd76715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 24 Nov 2025 13:37:31 +0100 Subject: [PATCH 58/79] Add support for Python 3.14 --- .gitlab-ci.yml | 2 +- CHANGELOG | 1 + setup.py | 1 + tox.ini | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 276daaa..5c562eb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -57,7 +57,7 @@ test-tox-python: - tox -e "py${PYTHON_VERSION/./}" -- -v --output-file junit.xml parallel: matrix: - - PYTHON_VERSION: ["3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + - PYTHON_VERSION: ["3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] test-tox-pypy: extends: .test-tox diff --git a/CHANGELOG b/CHANGELOG index 39bb87b..e0ba035 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Add support for Python 3.14 * Do not use parameters for COUNT(*) Version 1.6.0 - 2025-05-02 diff --git a/setup.py b/setup.py index dc6583b..291eb4a 100644 --- a/setup.py +++ b/setup.py @@ -49,6 +49,7 @@ def get_version(): 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', + 'Programming Language :: Python :: 3.14', 'Topic :: Database', 'Topic :: Software Development :: Libraries :: Python Modules', ], diff --git a/tox.ini b/tox.ini index c7c2d4e..c6a6889 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = py35, py36, py37, py38, py39, py310, py311, py312, py313, pypy3 +envlist = py35, py36, py37, py38, py39, py310, py311, py312, py313, py314, pypy3 [testenv] usedevelop = true From 7cd02a45bb6b64f744f2169aefc93549a9158da5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 24 Nov 2025 13:50:50 +0100 Subject: [PATCH 59/79] Prepare release --- CHANGELOG | 1 + sql/__init__.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index e0ba035..710df29 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +Version 1.7.0 - 2025-11-24 * Add support for Python 3.14 * Do not use parameters for COUNT(*) diff --git a/sql/__init__.py b/sql/__init__.py index 2f6db37..43bac75 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -7,7 +7,7 @@ from itertools import chain from threading import current_thread, local -__version__ = '1.6.1' +__version__ = '1.7.0' __all__ = [ 'Flavor', 'Table', 'Values', 'Literal', 'Column', 'Grouping', 'Conflict', 'Matched', 'MatchedUpdate', 'MatchedDelete', From 229d2aa232e914a24cd1c1d6187d95fd3cca5640 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 24 Nov 2025 13:51:10 +0100 Subject: [PATCH 60/79] Added tag 1.7.0 for changeset 231ce10b975e --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index 71c45b3..9d169ff 100644 --- a/.hgtags +++ b/.hgtags @@ -21,3 +21,4 @@ fcb64787b51db2068061eb4aa13825abc1134916 1.4.2 79a69b0bbbd35a8d95e1b754ed3feb03df23fb70 1.5.1 41b0aaa68f5e5bab3889fa1ef57ef44c6c21cacf 1.5.2 475502ba46eba3b7e141e8fbceaf495b545bcddb 1.6.0 +231ce10b975e41027c6121f9bb9033d786553b90 1.7.0 From 00698fa3ae573b2b3f1145efce344f569de05157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 24 Nov 2025 13:54:15 +0100 Subject: [PATCH 61/79] Increase version number --- sql/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/__init__.py b/sql/__init__.py index 43bac75..f032aad 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -7,7 +7,7 @@ from itertools import chain from threading import current_thread, local -__version__ = '1.7.0' +__version__ = '1.7.1' __all__ = [ 'Flavor', 'Table', 'Values', 'Literal', 'Column', 'Grouping', 'Conflict', 'Matched', 'MatchedUpdate', 'MatchedDelete', From a0ec4f0d2c78bf365330aea98c5fa1f632854132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 24 Nov 2025 17:34:12 +0100 Subject: [PATCH 62/79] Remove support for Python older than 3.6 --- .gitlab-ci.yml | 2 +- CHANGELOG | 2 ++ setup.py | 3 +-- tox.ini | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5c562eb..7934b05 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -57,7 +57,7 @@ test-tox-python: - tox -e "py${PYTHON_VERSION/./}" -- -v --output-file junit.xml parallel: matrix: - - PYTHON_VERSION: ["3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + - PYTHON_VERSION: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] test-tox-pypy: extends: .test-tox diff --git a/CHANGELOG b/CHANGELOG index 710df29..a1e5b77 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +* Remove support for Python older than 3.6 + Version 1.7.0 - 2025-11-24 * Add support for Python 3.14 * Do not use parameters for COUNT(*) diff --git a/setup.py b/setup.py index 291eb4a..217f552 100644 --- a/setup.py +++ b/setup.py @@ -33,14 +33,13 @@ def get_version(): }, keywords='SQL database query', packages=find_packages(), - python_requires='>=3.5', + python_requires='>=3.6', classifiers=[ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', diff --git a/tox.ini b/tox.ini index c6a6889..281c0bd 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = py35, py36, py37, py38, py39, py310, py311, py312, py313, py314, pypy3 +envlist = py36, py37, py38, py39, py310, py311, py312, py313, py314, pypy3 [testenv] usedevelop = true From 92ee8c955540c03621058bbc4521646f08125a8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 24 Nov 2025 21:35:33 +0100 Subject: [PATCH 63/79] Do not use parameter for EXTRACT field Closes #97 --- CHANGELOG | 1 + sql/functions.py | 66 +++++++++++++++++++++++++++++++++++-- sql/tests/test_functions.py | 34 ++++++++++++++++++- 3 files changed, 97 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index a1e5b77..d908f9c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Do not use parameter for EXTRACT field * Remove support for Python older than 3.6 Version 1.7.0 - 2025-11-24 diff --git a/sql/functions.py b/sql/functions.py index c617c9c..18b39ef 100644 --- a/sql/functions.py +++ b/sql/functions.py @@ -1,5 +1,7 @@ # This file is part of python-sql. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. + +from enum import Enum, auto from itertools import chain from sql import CombiningQuery, Expression, Flavor, FromItem, Select, Window @@ -85,7 +87,7 @@ def __str__(self): return (self._function + '(' + ' '.join(chain(*zip( self._keywords, - map(self._format, self.args))))[1:] + map(self._format, self.args)))).strip() + ')') @@ -383,9 +385,67 @@ class DateTrunc(Function): class Extract(FunctionKeyword): - __slots__ = () + __slots__ = ('_field',) _function = 'EXTRACT' - _keywords = ('', 'FROM') + + class Fields(str, Enum): + def _generate_next_value_(name, start, count, last_values): + return name.upper() + + CENTURY = auto() + DAY = auto() + DECADE = auto() + DOW = auto() + DOY = auto() + EPOCH = auto() + HOUR = auto() + ISODOW = auto() + ISOYEAR = auto() + JULIAN = auto() + MICROSECONDS = auto() + MILLENNIUM = auto() + MILLISECONDS = auto() + MINUTE = auto() + MONTH = auto() + QUARTER = auto() + SECOND = auto() + TIMEZONE = auto() + TIMEZONE_HOUR = auto() + TIMEZONE_MINUTE = auto() + WEEK = auto() + YEAR = auto() + + def __init__(self, field, *args, **kwargs): + super().__init__(*args, **kwargs) + self.field = field + + @property + def field(self): + return self._field + + @field.setter + def field(self, value): + value = value.upper() + if not hasattr(self.Fields, value): + raise ValueError("invalid field: %r" % value) + self._field = value + + @property + def _keywords(self): + return ('%s FROM' % self.field,) + + def __str__(self): + Mapping = Flavor.get().function_mapping.get(self.__class__) + if Mapping: + return str(Mapping(self.field, *self.args)) + return super().__str__() + + @property + def params(self): + Mapping = Flavor.get().function_mapping.get(self.__class__) + if Mapping: + return Mapping(self.field, *self.args).params + return super().params class Isfinite(Function): diff --git a/sql/tests/test_functions.py b/sql/tests/test_functions.py index 084689f..dfdf889 100644 --- a/sql/tests/test_functions.py +++ b/sql/tests/test_functions.py @@ -4,7 +4,7 @@ from sql import AliasManager, Flavor, Table, Window from sql.functions import ( - Abs, AtTimeZone, CurrentTime, Div, Function, FunctionKeyword, + Abs, AtTimeZone, CurrentTime, Div, Extract, Function, FunctionKeyword, FunctionNotCallable, Overlay, Rank, Trim, WindowFunction) @@ -139,6 +139,38 @@ def test_current_time(self): self.assertEqual(str(current_time), 'CURRENT_TIME') self.assertEqual(current_time.params, ()) + def test_extract(self): + extract = Extract(Extract.Fields.DAY, self.table.c) + self.assertEqual(str(extract), 'EXTRACT(DAY FROM "c")') + self.assertEqual(extract.params, ()) + + extract = Extract('day', self.table.c) + self.assertEqual(str(extract), 'EXTRACT(DAY FROM "c")') + self.assertEqual(extract.params, ()) + + extract = Extract(Extract.Fields.DAY, '2000-01-01') + self.assertEqual(str(extract), 'EXTRACT(DAY FROM %s)') + self.assertEqual(extract.params, ('2000-01-01',)) + + def test_extract_mapping(self): + class MyExtract(Function): + _function = 'MY_EXTRACT' + + extract = Extract(Extract.Fields.DAY, '2000-01-01') + flavor = Flavor(function_mapping={ + Extract: MyExtract, + }) + Flavor.set(flavor) + try: + self.assertEqual(str(extract), 'MY_EXTRACT(%s, %s)') + self.assertEqual(extract.params, ('DAY', '2000-01-01')) + finally: + Flavor.set(Flavor()) + + def test_extract_invalid_field(self): + with self.assertRaises(ValueError): + Extract('foo', self.table.c) + class TestWindowFunction(unittest.TestCase): From df2a504aacfe87661f16dd6f08735b0ce99e62d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 24 Nov 2025 22:19:12 +0100 Subject: [PATCH 64/79] Check the coherence of the aliases of GROUP BY and ORDER BY expressions --- CHANGELOG | 1 + sql/__init__.py | 14 ++++++++++++++ sql/tests/test_select.py | 26 ++++++++++++++++++++++---- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index d908f9c..7e07c0e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Check the coherence of the aliases of GROUP BY and ORDER BY expressions * Do not use parameter for EXTRACT field * Remove support for Python older than 3.6 diff --git a/sql/__init__.py b/sql/__init__.py index f032aad..e32f735 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -629,6 +629,20 @@ def __str__(self): and (self.limit is not None or self.offset is not None)): return self._rownum(str) + for expression in chain( + self.group_by or [], + self.order_by or []): + if not isinstance(expression, As): + continue + for column in self.columns: + if not isinstance(column, As): + continue + if column.output_name != expression.output_name: + continue + if (str(column.expression) != str(expression.expression) + or column.params != expression.params): + raise ValueError("%r != %r" % (expression, column)) + with AliasManager(): if self.from_ is not None: from_ = ' FROM %s' % self.from_ diff --git a/sql/tests/test_select.py b/sql/tests/test_select.py index 3885f0e..01cf4b8 100644 --- a/sql/tests/test_select.py +++ b/sql/tests/test_select.py @@ -253,6 +253,12 @@ def test_select_invalid_group_by(self): with self.assertRaises(ValueError): self.table.select(group_by=['foo']) + def test_select_invalid_group_by_alias(self): + query = self.table.select( + self.table.c1.as_('c'), group_by=self.table.c2.as_('c')) + with self.assertRaises(ValueError): + str(query) + def test_select_having(self): col1 = self.table.col1 col2 = self.table.col2 @@ -268,16 +274,28 @@ def test_select_invalid_having(self): self.table.select(having='foo') def test_select_order(self): - c = self.table.c - query = self.table.select(c, order_by=Literal(1)) + column = self.table.c + query = self.table.select(column, order_by=column) self.assertEqual(str(query), - 'SELECT "a"."c" FROM "t" AS "a" ORDER BY %s') - self.assertEqual(tuple(query.params), (1,)) + 'SELECT "a"."c" FROM "t" AS "a" ORDER BY "a"."c"') + self.assertEqual(tuple(query.params), ()) + + output = column.as_('c1') + query = self.table.select(output, order_by=output) + self.assertEqual(str(query), + 'SELECT "a"."c" AS "c1" FROM "t" AS "a" ORDER BY "c1"') + self.assertEqual(tuple(query.params), ()) def test_select_invalid_order(self): with self.assertRaises(ValueError): self.table.select(order_by='foo') + def test_select_invalid_order_alias(self): + query = self.table.select( + self.table.c1.as_('c'), order_by=self.table.c2.as_('c')) + with self.assertRaises(ValueError): + str(query) + def test_select_limit_offset(self): try: Flavor.set(Flavor(limitstyle='limit')) From 484a87d13b368ed2cb701a74809b9ba054682185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Tue, 25 Nov 2025 10:23:15 +0100 Subject: [PATCH 65/79] Use the ordinal number as aliases for GROUP BY Closes #96 --- CHANGELOG | 1 + sql/__init__.py | 12 ++++++++++-- sql/tests/test_select.py | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 7e07c0e..624be2f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Use the ordinal number as aliases for GROUP BY * Check the coherence of the aliases of GROUP BY and ORDER BY expressions * Do not use parameter for EXTRACT field * Remove support for Python older than 3.6 diff --git a/sql/__init__.py b/sql/__init__.py index e32f735..e4a689f 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -629,12 +629,13 @@ def __str__(self): and (self.limit is not None or self.offset is not None)): return self._rownum(str) + ordinals = {} for expression in chain( self.group_by or [], self.order_by or []): if not isinstance(expression, As): continue - for column in self.columns: + for i, column in enumerate(self.columns, start=1): if not isinstance(column, As): continue if column.output_name != expression.output_name: @@ -642,6 +643,12 @@ def __str__(self): if (str(column.expression) != str(expression.expression) or column.params != expression.params): raise ValueError("%r != %r" % (expression, column)) + ordinals[column.output_name] = i + + def str_or_ordinal(expression): + if isinstance(expression, As): + expression = ordinals.get(expression.output_name, expression) + return str(expression) with AliasManager(): if self.from_ is not None: @@ -671,7 +678,8 @@ def __str__(self): where = ' WHERE ' + str(self.where) group_by = '' if self.group_by: - group_by = ' GROUP BY ' + ', '.join(map(str, self.group_by)) + group_by = ' GROUP BY ' + ', '.join( + map(str_or_ordinal, self.group_by)) having = '' if self.having: having = ' HAVING ' + str(self.having) diff --git a/sql/tests/test_select.py b/sql/tests/test_select.py index 01cf4b8..07c24f1 100644 --- a/sql/tests/test_select.py +++ b/sql/tests/test_select.py @@ -197,7 +197,7 @@ def test_select_group_by(self): output = column.as_('c1') query = self.table.select(output, group_by=output) self.assertEqual(str(query), - 'SELECT "a"."c" AS "c1" FROM "t" AS "a" GROUP BY "c1"') + 'SELECT "a"."c" AS "c1" FROM "t" AS "a" GROUP BY 1') self.assertEqual(tuple(query.params), ()) query = self.table.select(Literal('foo'), group_by=Literal('foo')) From 5288f55fb8effa5528acb09064df6491f0d0e283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Wed, 24 Dec 2025 12:00:34 +0100 Subject: [PATCH 66/79] Add test to cover group by alias with multiple alias columns --- sql/tests/test_select.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sql/tests/test_select.py b/sql/tests/test_select.py index 07c24f1..b2a86f7 100644 --- a/sql/tests/test_select.py +++ b/sql/tests/test_select.py @@ -205,6 +205,14 @@ def test_select_group_by(self): 'SELECT %s FROM "t" AS "a" GROUP BY %s') self.assertEqual(tuple(query.params), ('foo', 'foo')) + output1 = column.as_('c1') + output2 = column.as_('c2') + query = self.table.select(output1, output2, group_by=output2) + self.assertEqual(str(query), + 'SELECT "a"."c" AS "c1", "a"."c" AS "c2" FROM "t" AS "a" ' + 'GROUP BY 2') + self.assertEqual(tuple(query.params), ()) + def test_select_group_by_grouping_sets(self): query = self.table.select( Literal('*'), From a927d78f3c4b897e5699d10ddec130859e265a87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Wed, 24 Dec 2025 12:01:06 +0100 Subject: [PATCH 67/79] Add test to cover group by alias with column without alias --- sql/tests/test_select.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sql/tests/test_select.py b/sql/tests/test_select.py index b2a86f7..a794510 100644 --- a/sql/tests/test_select.py +++ b/sql/tests/test_select.py @@ -213,6 +213,11 @@ def test_select_group_by(self): 'GROUP BY 2') self.assertEqual(tuple(query.params), ()) + query = self.table.select(column, group_by=output) + self.assertEqual(str(query), + 'SELECT "a"."c" FROM "t" AS "a" GROUP BY "c1"') + self.assertEqual(tuple(query.params), ()) + def test_select_group_by_grouping_sets(self): query = self.table.select( Literal('*'), From 8ee1b7f50f007cb95e6e9c6e4422d05dccf4903f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Wed, 24 Dec 2025 10:37:11 +0100 Subject: [PATCH 68/79] Remove the parentheses around the unary and binary operators Closes #98 --- CHANGELOG | 1 + README.rst | 28 +++---- sql/operators.py | 32 ++++---- sql/tests/test_aggregate.py | 8 +- sql/tests/test_combining_query.py | 2 +- sql/tests/test_conditionals.py | 6 +- sql/tests/test_delete.py | 8 +- sql/tests/test_functions.py | 6 +- sql/tests/test_insert.py | 8 +- sql/tests/test_join.py | 4 +- sql/tests/test_lateral.py | 2 +- sql/tests/test_merge.py | 24 +++--- sql/tests/test_operators.py | 125 +++++++++++++++--------------- sql/tests/test_order.py | 6 +- sql/tests/test_select.py | 52 ++++++------- sql/tests/test_update.py | 12 +-- sql/tests/test_with.py | 6 +- 17 files changed, 165 insertions(+), 165 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 624be2f..1b15bae 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Remove the parentheses around the unary and binary operators * Use the ordinal number as aliases for GROUP BY * Check the coherence of the aliases of GROUP BY and ORDER BY expressions * Do not use parameter for EXTRACT field diff --git a/README.rst b/README.rst index bd3ad74..e53aaef 100644 --- a/README.rst +++ b/README.rst @@ -39,14 +39,14 @@ Select with where condition:: >>> select.where = user.name == 'foo' >>> tuple(select) - ('SELECT "a"."id", "a"."name" FROM "user" AS "a" WHERE ("a"."name" = %s)', ('foo',)) + ('SELECT "a"."id", "a"."name" FROM "user" AS "a" WHERE "a"."name" = %s', ('foo',)) >>> select.where = (user.name == 'foo') & (user.active == True) >>> tuple(select) - ('SELECT "a"."id", "a"."name" FROM "user" AS "a" WHERE (("a"."name" = %s) AND ("a"."active" = %s))', ('foo', True)) + ('SELECT "a"."id", "a"."name" FROM "user" AS "a" WHERE ("a"."name" = %s) AND ("a"."active" = %s)', ('foo', True)) >>> select.where = user.name == user.login >>> tuple(select) - ('SELECT "a"."id", "a"."name" FROM "user" AS "a" WHERE ("a"."name" = "a"."login")', ()) + ('SELECT "a"."id", "a"."name" FROM "user" AS "a" WHERE "a"."name" = "a"."login"', ()) Select with join:: @@ -54,7 +54,7 @@ Select with join:: >>> join.condition = join.right.user == user.id >>> select = join.select(user.name, join.right.group) >>> tuple(select) - ('SELECT "a"."name", "b"."group" FROM "user" AS "a" INNER JOIN "user_group" AS "b" ON ("b"."user" = "a"."id")', ()) + ('SELECT "a"."name", "b"."group" FROM "user" AS "a" INNER JOIN "user_group" AS "b" ON "b"."user" = "a"."id"', ()) Select with multiple joins:: @@ -93,9 +93,9 @@ Select with sub-select:: ... where=user_group.active == True) >>> user = Table('user') >>> tuple(user.select(user.id, where=user.id.in_(subselect))) - ('SELECT "a"."id" FROM "user" AS "a" WHERE ("a"."id" IN (SELECT "b"."user" FROM "user_group" AS "b" WHERE ("b"."active" = %s)))', (True,)) + ('SELECT "a"."id" FROM "user" AS "a" WHERE "a"."id" IN (SELECT "b"."user" FROM "user_group" AS "b" WHERE "b"."active" = %s)', (True,)) >>> tuple(subselect.select(subselect.user)) - ('SELECT "a"."user" FROM (SELECT "b"."user" FROM "user_group" AS "b" WHERE ("b"."active" = %s)) AS "a"', (True,)) + ('SELECT "a"."user" FROM (SELECT "b"."user" FROM "user_group" AS "b" WHERE "b"."active" = %s) AS "a"', (True,)) Select on other schema:: @@ -129,20 +129,20 @@ Update query with values:: >>> tuple(user.update(columns=[user.active], values=[True])) ('UPDATE "user" AS "a" SET "active" = %s', (True,)) >>> tuple(invoice.update(columns=[invoice.total], values=[invoice.amount + invoice.tax])) - ('UPDATE "invoice" AS "a" SET "total" = ("a"."amount" + "a"."tax")', ()) + ('UPDATE "invoice" AS "a" SET "total" = "a"."amount" + "a"."tax"', ()) Update query with where condition:: >>> tuple(user.update(columns=[user.active], values=[True], ... where=user.active == False)) - ('UPDATE "user" AS "a" SET "active" = %s WHERE ("a"."active" = %s)', (True, False)) + ('UPDATE "user" AS "a" SET "active" = %s WHERE "a"."active" = %s', (True, False)) Update query with from list:: >>> group = Table('user_group') >>> tuple(user.update(columns=[user.active], values=[group.active], ... from_=[group], where=user.id == group.user)) - ('UPDATE "user" AS "b" SET "active" = "a"."active" FROM "user_group" AS "a" WHERE ("b"."id" = "a"."user")', ()) + ('UPDATE "user" AS "b" SET "active" = "a"."active" FROM "user_group" AS "a" WHERE "b"."id" = "a"."user"', ()) Delete query:: @@ -152,13 +152,13 @@ Delete query:: Delete query with where condition:: >>> tuple(user.delete(where=user.name == 'foo')) - ('DELETE FROM "user" WHERE ("name" = %s)', ('foo',)) + ('DELETE FROM "user" WHERE "name" = %s', ('foo',)) Delete query with sub-query:: >>> tuple(user.delete( ... where=user.id.in_(user_group.select(user_group.user)))) - ('DELETE FROM "user" WHERE ("id" IN (SELECT "a"."user" FROM "user_group" AS "a"))', ()) + ('DELETE FROM "user" WHERE "id" IN (SELECT "a"."user" FROM "user_group" AS "a")', ()) Flavors:: @@ -185,7 +185,7 @@ Limit style:: ('SELECT * FROM "user" AS "a" OFFSET (%s) ROWS FETCH FIRST (%s) ROWS ONLY', (20, 10)) >>> Flavor.set(Flavor(limitstyle='rownum')) >>> tuple(select) - ('SELECT "a".* FROM (SELECT "b".*, ROWNUM AS "rnum" FROM (SELECT * FROM "user" AS "c") AS "b" WHERE (ROWNUM <= %s)) AS "a" WHERE ("rnum" > %s)', (30, 20)) + ('SELECT "a".* FROM (SELECT "b".*, ROWNUM AS "rnum" FROM (SELECT * FROM "user" AS "c") AS "b" WHERE ROWNUM <= %s) AS "a" WHERE "rnum" > %s', (30, 20)) qmark style:: @@ -193,7 +193,7 @@ qmark style:: >>> select = user.select() >>> select.where = user.name == 'foo' >>> tuple(select) - ('SELECT * FROM "user" AS "a" WHERE ("a"."name" = ?)', ('foo',)) + ('SELECT * FROM "user" AS "a" WHERE "a"."name" = ?', ('foo',)) numeric style:: @@ -201,4 +201,4 @@ numeric style:: >>> select = user.select() >>> select.where = user.name == 'foo' >>> format2numeric(*select) - ('SELECT * FROM "user" AS "a" WHERE ("a"."name" = :0)', ('foo',)) + ('SELECT * FROM "user" AS "a" WHERE "a"."name" = :0', ('foo',)) diff --git a/sql/operators.py b/sql/operators.py index 9cd2831..464601b 100644 --- a/sql/operators.py +++ b/sql/operators.py @@ -48,9 +48,10 @@ def convert(operands): def _format(self, operand, param=None): if param is None: param = Flavor.get().param - if isinstance(operand, Expression): + if (isinstance(operand, Expression) + and not isinstance(operand, Operator)): return str(operand) - elif isinstance(operand, (Select, CombiningQuery)): + elif isinstance(operand, (Expression, Select, CombiningQuery)): return '(%s)' % operand elif isinstance(operand, (list, tuple)): return '(' + ', '.join(self._format(o, param) @@ -88,7 +89,7 @@ def _operands(self): return (self.operand,) def __str__(self): - return '(%s %s)' % (self._operator, self._format(self.operand)) + return '%s %s' % (self._operator, self._format(self.operand)) class BinaryOperator(Operator): @@ -105,7 +106,7 @@ def _operands(self): def __str__(self): left, right = self._operands - return '(%s %s %s)' % (self._format(left), self._operator, + return '%s %s %s' % (self._format(left), self._operator, self._format(right)) def __invert__(self): @@ -121,8 +122,7 @@ def _operands(self): return self def __str__(self): - return '(' + (' %s ' % self._operator).join( - map(self._format, self)) + ')' + return (' %s ' % self._operator).join(map(self._format, self)) class And(NaryOperator): @@ -184,9 +184,9 @@ def _operands(self): def __str__(self): if self.left is Null: - return '(%s IS NULL)' % self.right + return '%s IS NULL' % self.right elif self.right is Null: - return '(%s IS NULL)' % self.left + return '%s IS NULL' % self.left return super(Equal, self).__str__() @@ -196,9 +196,9 @@ class NotEqual(Equal): def __str__(self): if self.left is Null: - return '(%s IS NOT NULL)' % self.right + return '%s IS NOT NULL' % self.right elif self.right is Null: - return '(%s IS NOT NULL)' % self.left + return '%s IS NOT NULL' % self.left return super(Equal, self).__str__() @@ -220,7 +220,7 @@ def __str__(self): operator = self._operator if self.symmetric: operator += ' SYMMETRIC' - return '(%s %s %s AND %s)' % ( + return '%s %s %s AND %s' % ( self._format(self.operand), operator, self._format(self.left), self._format(self.right)) @@ -259,12 +259,12 @@ def _operands(self): def __str__(self): if self.right is None: - return '(%s %s UNKNOWN)' % ( + return '%s %s UNKNOWN' % ( self._format(self.left), self._operator) elif self.right is True: - return '(%s %s TRUE)' % (self._format(self.left), self._operator) + return '%s %s TRUE' % (self._format(self.left), self._operator) elif self.right is False: - return '(%s %s FALSE)' % (self._format(self.left), self._operator) + return '%s %s FALSE' % (self._format(self.left), self._operator) class IsNot(Is): @@ -395,11 +395,11 @@ def params(self): def __str__(self): left, right = self._operands if self.escape or Flavor().get().escape_empty: - return '(%s %s %s ESCAPE %s)' % ( + return '%s %s %s ESCAPE %s' % ( self._format(left), self._operator, self._format(right), self._format(self.escape or '')) else: - return '(%s %s %s)' % ( + return '%s %s %s' % ( self._format(left), self._operator, self._format(right)) def __invert__(self): diff --git a/sql/tests/test_aggregate.py b/sql/tests/test_aggregate.py index 32698bd..3e8dbc1 100644 --- a/sql/tests/test_aggregate.py +++ b/sql/tests/test_aggregate.py @@ -38,7 +38,7 @@ def test_avg(self): self.assertEqual(str(avg), 'AVG("c")') avg = Avg(self.table.a + self.table.b) - self.assertEqual(str(avg), 'AVG(("a" + "b"))') + self.assertEqual(str(avg), 'AVG("a" + "b")') def test_count_without_expression(self): count = Count() @@ -67,7 +67,7 @@ def test_filter(self): try: avg = Avg(self.table.a + 1, filter_=self.table.a > 0) self.assertEqual( - str(avg), 'AVG(("a" + %s)) FILTER (WHERE ("a" > %s))') + str(avg), 'AVG("a" + %s) FILTER (WHERE "a" > %s)') self.assertEqual(avg.params, (1, 0)) finally: Flavor.set(Flavor()) @@ -75,13 +75,13 @@ def test_filter(self): def test_filter_case(self): avg = Avg(self.table.a + 1, filter_=self.table.a > 0) self.assertEqual( - str(avg), 'AVG(CASE WHEN ("a" > %s) THEN ("a" + %s) END)') + str(avg), 'AVG(CASE WHEN "a" > %s THEN "a" + %s END)') self.assertEqual(avg.params, (0, 1)) def test_filter_case_count_star(self): count = Count(Literal('*'), filter_=self.table.a > 0) self.assertEqual( - str(count), 'COUNT(CASE WHEN ("a" > %s) THEN %s END)') + str(count), 'COUNT(CASE WHEN "a" > %s THEN %s END)') self.assertEqual(count.params, (0, 1)) def test_window(self): diff --git a/sql/tests/test_combining_query.py b/sql/tests/test_combining_query.py index 9dab817..caa2845 100644 --- a/sql/tests/test_combining_query.py +++ b/sql/tests/test_combining_query.py @@ -33,7 +33,7 @@ def test_union_with(self): self.assertEqual(str(query), 'WITH "a" AS (' - 'SELECT "b"."id" FROM "t" AS "b" WHERE ("b"."id" = %s)) ' + 'SELECT "b"."id" FROM "t" AS "b" WHERE "b"."id" = %s) ' 'SELECT * FROM "t1" AS "c" UNION SELECT * FROM "t2" AS "d"') self.assertEqual(tuple(query.params), (1,)) diff --git a/sql/tests/test_conditionals.py b/sql/tests/test_conditionals.py index 937d7e3..7945f59 100644 --- a/sql/tests/test_conditionals.py +++ b/sql/tests/test_conditionals.py @@ -36,9 +36,9 @@ def test_case_sql(self): where=self.table.c2 == 'foo')) self.assertEqual(str(case), 'CASE WHEN ' - '(SELECT "a"."bool" FROM "t" AS "a" WHERE ("a"."c2" = %s)) ' + '(SELECT "a"."bool" FROM "t" AS "a" WHERE "a"."c2" = %s) ' 'THEN "c1" ' - 'ELSE (SELECT "a"."c1" FROM "t" AS "a" WHERE ("a"."c2" = %s)) END') + 'ELSE (SELECT "a"."c1" FROM "t" AS "a" WHERE "a"."c2" = %s) END') self.assertEqual(case.params, ('bar', 'foo')) def test_coalesce(self): @@ -52,7 +52,7 @@ def test_coalesce_sql(self): self.table.c2) self.assertEqual(str(coalesce), 'COALESCE(' - '(SELECT "a"."c1" FROM "t" AS "a" WHERE ("a"."c2" = %s)), "c2")') + '(SELECT "a"."c1" FROM "t" AS "a" WHERE "a"."c2" = %s), "c2")') self.assertEqual(coalesce.params, ('bar',)) def test_nullif(self): diff --git a/sql/tests/test_delete.py b/sql/tests/test_delete.py index 4048db6..2564991 100644 --- a/sql/tests/test_delete.py +++ b/sql/tests/test_delete.py @@ -15,7 +15,7 @@ def test_delete1(self): def test_delete2(self): query = self.table.delete(where=(self.table.c == 'foo')) - self.assertEqual(str(query), 'DELETE FROM "t" WHERE ("c" = %s)') + self.assertEqual(str(query), 'DELETE FROM "t" WHERE "c" = %s') self.assertEqual(query.params, ('foo',)) def test_delete3(self): @@ -23,8 +23,8 @@ def test_delete3(self): t2 = Table('t2') query = t1.delete(where=(t1.c.in_(t2.select(t2.c)))) self.assertEqual(str(query), - 'DELETE FROM "t1" WHERE ("c" IN (' - 'SELECT "a"."c" FROM "t2" AS "a"))') + 'DELETE FROM "t1" WHERE "c" IN (' + 'SELECT "a"."c" FROM "t2" AS "a")') self.assertEqual(query.params, ()) def test_delete_invalid_table(self): @@ -61,5 +61,5 @@ def test_with(self): self.assertEqual(str(query), 'WITH "a" AS (SELECT "b"."c1" FROM "t1" AS "b") ' 'DELETE FROM "t" WHERE ' - '("c2" IN (SELECT "a"."c3" FROM "a" AS "a"))') + '"c2" IN (SELECT "a"."c3" FROM "a" AS "a")') self.assertEqual(query.params, ()) diff --git a/sql/tests/test_functions.py b/sql/tests/test_functions.py index dfdf889..0b5d3a6 100644 --- a/sql/tests/test_functions.py +++ b/sql/tests/test_functions.py @@ -70,7 +70,7 @@ def test_sql(self): abs_ = Abs(self.table.select(self.table.c1, where=self.table.c2 == 'foo')) self.assertEqual(str(abs_), - 'ABS((SELECT "a"."c1" FROM "t" AS "a" WHERE ("a"."c2" = %s)))') + 'ABS((SELECT "a"."c1" FROM "t" AS "a" WHERE "a"."c2" = %s))') self.assertEqual(abs_.params, ('foo',)) def test_overlay(self): @@ -110,7 +110,7 @@ def test_at_time_zone_sql(self): self.table.select(self.table.tz, where=self.table.c1 == 'foo')) self.assertEqual(str(time_zone), '"c1" AT TIME ZONE ' - '(SELECT "a"."tz" FROM "t" AS "a" WHERE ("a"."c1" = %s))') + '(SELECT "a"."tz" FROM "t" AS "a" WHERE "a"."c1" = %s)') self.assertEqual(time_zone.params, ('foo',)) def test_at_time_zone_mapping(self): @@ -192,7 +192,7 @@ def test_filter(self): with AliasManager(): self.assertEqual(str(function), - 'RANK("a"."c") FILTER (WHERE ("a"."c" > %s)) OVER ()') + 'RANK("a"."c") FILTER (WHERE "a"."c" > %s) OVER ()') self.assertEqual(function.params, (0,)) def test_invalid_filter(self): diff --git a/sql/tests/test_insert.py b/sql/tests/test_insert.py index 2f3db3a..02a5439 100644 --- a/sql/tests/test_insert.py +++ b/sql/tests/test_insert.py @@ -73,7 +73,7 @@ def test_insert_returning_select(self): self.assertEqual(str(query), 'INSERT INTO "t1" AS "b" ("c") VALUES (%s) ' 'RETURNING (SELECT "a"."c" FROM "t2" AS "a" ' - 'WHERE (("a"."c1" = "b"."c") AND ("a"."c2" = %s)))') + 'WHERE ("a"."c1" = "b"."c") AND ("a"."c2" = %s))') self.assertEqual(tuple(query.params), ('foo', 'bar')) def test_insert_invalid_returning(self): @@ -160,7 +160,7 @@ def test_upsert_indexed_column_index_where(self): self.assertEqual(str(query), 'INSERT INTO "t" AS "a" ("c1") VALUES (%s) ' - 'ON CONFLICT ("c1") WHERE ("a"."c2" = %s) DO NOTHING') + 'ON CONFLICT ("c1") WHERE "a"."c2" = %s DO NOTHING') self.assertEqual(tuple(query.params), ('foo', 'bar')) def test_upsert_update(self): @@ -188,7 +188,7 @@ def test_upsert_update_where(self): self.assertEqual(str(query), 'INSERT INTO "t" AS "a" ("c1") VALUES (%s) ' 'ON CONFLICT DO UPDATE SET "c1" = (%s) ' - 'WHERE ("a"."c2" = %s)') + 'WHERE "a"."c2" = %s') self.assertEqual(tuple(query.params), ('baz', 'foo', 'bar')) def test_upsert_update_subquery(self): @@ -218,7 +218,7 @@ def test_upsert_update_excluded(self): self.assertEqual(str(query), 'INSERT INTO "t" AS "a" ("c1") VALUES (%s) ' - 'ON CONFLICT DO UPDATE SET "c1" = (("EXCLUDED"."c1" + %s))') + 'ON CONFLICT DO UPDATE SET "c1" = ("EXCLUDED"."c1" + %s)') self.assertEqual(tuple(query.params), (1, 2)) def test_conflict_invalid_table(self): diff --git a/sql/tests/test_join.py b/sql/tests/test_join.py index ddd4e49..1c8c576 100644 --- a/sql/tests/test_join.py +++ b/sql/tests/test_join.py @@ -18,7 +18,7 @@ def test_join(self): join.condition = t1.c == t2.c with AliasManager(): self.assertEqual(str(join), - '"t1" AS "a" INNER JOIN "t2" AS "b" ON ("a"."c" = "b"."c")') + '"t1" AS "a" INNER JOIN "t2" AS "b" ON "a"."c" = "b"."c"') def test_join_invalid_left(self): with self.assertRaises(ValueError): @@ -45,7 +45,7 @@ def test_join_subselect(self): with AliasManager(): self.assertEqual(str(join), '"t1" AS "a" INNER JOIN (SELECT * FROM "t2" AS "c") AS "b" ' - 'ON ("a"."c" = "b"."c")') + 'ON "a"."c" = "b"."c"') self.assertEqual(tuple(join.params), ()) def test_join_function(self): diff --git a/sql/tests/test_lateral.py b/sql/tests/test_lateral.py index b1bbc89..4764079 100644 --- a/sql/tests/test_lateral.py +++ b/sql/tests/test_lateral.py @@ -16,7 +16,7 @@ def test_lateral_select(self): self.assertEqual(str(query), 'SELECT * FROM "t1" AS "a", LATERAL ' - '(SELECT * FROM "t2" AS "c" WHERE ("c"."id" = "a"."t2")) AS "b"') + '(SELECT * FROM "t2" AS "c" WHERE "c"."id" = "a"."t2") AS "b"') self.assertEqual(tuple(query.params), ()) def test_lateral_function(self): diff --git a/sql/tests/test_merge.py b/sql/tests/test_merge.py index 624c1b0..08bebae 100644 --- a/sql/tests/test_merge.py +++ b/sql/tests/test_merge.py @@ -18,7 +18,7 @@ def test_merge(self): self.assertEqual( str(query), 'MERGE INTO "t" AS "a" USING "s" AS "b" ' - 'ON ("a"."c1" = "b"."c2") ' + 'ON "a"."c1" = "b"."c2" ' 'WHEN MATCHED THEN DO NOTHING') self.assertEqual(query.params, ()) @@ -46,7 +46,7 @@ def test_condition(self): self.assertEqual( str(query), 'MERGE INTO "t" AS "a" USING "s" AS "b" ' - 'ON (("a"."c1" = "b"."c2") AND ("a"."c3" = %s)) ' + 'ON ("a"."c1" = "b"."c2") AND ("a"."c3" = %s) ' 'WHEN MATCHED THEN DO NOTHING') self.assertEqual(query.params, (42,)) @@ -58,9 +58,9 @@ def test_matched(self): self.assertEqual( str(query), 'MERGE INTO "t" AS "a" USING "s" AS "b" ' - 'ON ("a"."c1" = "b"."c2") ' + 'ON "a"."c1" = "b"."c2" ' 'WHEN MATCHED ' - 'AND (("b"."c3" = %s) AND ("a"."c4" = "b"."c5")) ' + 'AND ("b"."c3" = %s) AND ("a"."c4" = "b"."c5") ' 'THEN DO NOTHING') self.assertEqual(query.params, (42,)) @@ -73,9 +73,9 @@ def test_matched_update(self): self.assertEqual( str(query), 'MERGE INTO "t" AS "a" USING "s" AS "b" ' - 'ON ("a"."c1" = "b"."c2") ' + 'ON "a"."c1" = "b"."c2" ' 'WHEN MATCHED THEN ' - 'UPDATE SET "c1" = ("a"."c1" + "b"."c2"), "c2" = %s') + 'UPDATE SET "c1" = "a"."c1" + "b"."c2", "c2" = %s') self.assertEqual(query.params, (42,)) def test_matched_delete(self): @@ -84,7 +84,7 @@ def test_matched_delete(self): self.assertEqual( str(query), 'MERGE INTO "t" AS "a" USING "s" AS "b" ' - 'ON ("a"."c1" = "b"."c2") ' + 'ON "a"."c1" = "b"."c2" ' 'WHEN MATCHED THEN DELETE') self.assertEqual(query.params, ()) @@ -94,7 +94,7 @@ def test_not_matched(self): self.assertEqual( str(query), 'MERGE INTO "t" AS "a" USING "s" AS "b" ' - 'ON ("a"."c1" = "b"."c2") ' + 'ON "a"."c1" = "b"."c2" ' 'WHEN NOT MATCHED THEN DO NOTHING') self.assertEqual(query.params, ()) @@ -107,7 +107,7 @@ def test_not_matched_insert(self): self.assertEqual( str(query), 'MERGE INTO "t" AS "a" USING "s" AS "b" ' - 'ON ("a"."c1" = "b"."c2") ' + 'ON "a"."c1" = "b"."c2" ' 'WHEN NOT MATCHED THEN ' 'INSERT ("c1", "c2") VALUES ("b"."c3", "b"."c4")') self.assertEqual(query.params, ()) @@ -119,7 +119,7 @@ def test_not_matched_insert_default(self): self.assertEqual( str(query), 'MERGE INTO "t" AS "a" USING "s" AS "b" ' - 'ON ("a"."c1" = "b"."c2") ' + 'ON "a"."c1" = "b"."c2" ' 'WHEN NOT MATCHED THEN ' 'INSERT ("c1", "c2") DEFAULT VALUES') self.assertEqual(query.params, ()) @@ -141,9 +141,9 @@ def test_with(self): source, self.target.c1 == source.c2, Matched(), with_=[w]) self.assertEqual( str(query), - 'WITH "a" AS (SELECT * FROM "t1" AS "d" WHERE ("d"."c2" = %s)) ' + 'WITH "a" AS (SELECT * FROM "t1" AS "d" WHERE "d"."c2" = %s) ' 'MERGE INTO "t" AS "b" ' 'USING (SELECT * FROM "a" AS "a") AS "c" ' - 'ON ("b"."c1" = "c"."c2") ' + 'ON "b"."c1" = "c"."c2" ' 'WHEN MATCHED THEN DO NOTHING') self.assertEqual(query.params, (42,)) diff --git a/sql/tests/test_operators.py b/sql/tests/test_operators.py index eb8c385..073ffa6 100644 --- a/sql/tests/test_operators.py +++ b/sql/tests/test_operators.py @@ -25,34 +25,34 @@ def test_operator_str(self): def test_and(self): for and_ in [And((self.table.c1, self.table.c2)), self.table.c1 & self.table.c2]: - self.assertEqual(str(and_), '("c1" AND "c2")') + self.assertEqual(str(and_), '"c1" AND "c2"') self.assertEqual(and_.params, ()) and_ = And((Literal(True), self.table.c2)) - self.assertEqual(str(and_), '(%s AND "c2")') + self.assertEqual(str(and_), '%s AND "c2"') self.assertEqual(and_.params, (True,)) and_ = And((Literal(True), 'foo')) - self.assertEqual(str(and_), '(%s AND %s)') + self.assertEqual(str(and_), '%s AND %s') self.assertEqual(and_.params, (True, 'foo')) def test_operator_operators(self): and_ = And((Literal(True), self.table.c1)) and2 = and_ & And((Literal(True), self.table.c2)) - self.assertEqual(str(and2), '((%s AND "c1") AND %s AND "c2")') + self.assertEqual(str(and2), '(%s AND "c1") AND %s AND "c2"') self.assertEqual(and2.params, (True, True)) and3 = and_ & Literal(True) - self.assertEqual(str(and3), '((%s AND "c1") AND %s)') + self.assertEqual(str(and3), '(%s AND "c1") AND %s') self.assertEqual(and3.params, (True, True)) or_ = Or((Literal(True), self.table.c1)) or2 = or_ | Or((Literal(True), self.table.c2)) - self.assertEqual(str(or2), '((%s OR "c1") OR %s OR "c2")') + self.assertEqual(str(or2), '(%s OR "c1") OR %s OR "c2"') self.assertEqual(or2.params, (True, True)) or3 = or_ | Literal(True) - self.assertEqual(str(or3), '((%s OR "c1") OR %s)') + self.assertEqual(str(or3), '(%s OR "c1") OR %s') self.assertEqual(or3.params, (True, True)) def test_operator_compat_column(self): @@ -63,152 +63,152 @@ def test_operator_compat_column(self): def test_or(self): for or_ in [Or((self.table.c1, self.table.c2)), self.table.c1 | self.table.c2]: - self.assertEqual(str(or_), '("c1" OR "c2")') + self.assertEqual(str(or_), '"c1" OR "c2"') self.assertEqual(or_.params, ()) def test_not(self): for not_ in [Not(self.table.c), ~self.table.c]: - self.assertEqual(str(not_), '(NOT "c")') + self.assertEqual(str(not_), 'NOT "c"') self.assertEqual(not_.params, ()) not_ = Not(Literal(False)) - self.assertEqual(str(not_), '(NOT %s)') + self.assertEqual(str(not_), 'NOT %s') self.assertEqual(not_.params, (False,)) def test_neg(self): for neg in [Neg(self.table.c1), -self.table.c1]: - self.assertEqual(str(neg), '(- "c1")') + self.assertEqual(str(neg), '- "c1"') self.assertEqual(neg.params, ()) def test_pos(self): for pos in [Pos(self.table.c1), +self.table.c1]: - self.assertEqual(str(pos), '(+ "c1")') + self.assertEqual(str(pos), '+ "c1"') self.assertEqual(pos.params, ()) def test_less(self): for less in [Less(self.table.c1, self.table.c2), self.table.c1 < self.table.c2, ~GreaterEqual(self.table.c1, self.table.c2)]: - self.assertEqual(str(less), '("c1" < "c2")') + self.assertEqual(str(less), '"c1" < "c2"') self.assertEqual(less.params, ()) less = Less(Literal(0), self.table.c2) - self.assertEqual(str(less), '(%s < "c2")') + self.assertEqual(str(less), '%s < "c2"') self.assertEqual(less.params, (0,)) def test_greater(self): for greater in [Greater(self.table.c1, self.table.c2), self.table.c1 > self.table.c2, ~LessEqual(self.table.c1, self.table.c2)]: - self.assertEqual(str(greater), '("c1" > "c2")') + self.assertEqual(str(greater), '"c1" > "c2"') self.assertEqual(greater.params, ()) def test_less_equal(self): for less in [LessEqual(self.table.c1, self.table.c2), self.table.c1 <= self.table.c2, ~Greater(self.table.c1, self.table.c2)]: - self.assertEqual(str(less), '("c1" <= "c2")') + self.assertEqual(str(less), '"c1" <= "c2"') self.assertEqual(less.params, ()) def test_greater_equal(self): for greater in [GreaterEqual(self.table.c1, self.table.c2), self.table.c1 >= self.table.c2, ~Less(self.table.c1, self.table.c2)]: - self.assertEqual(str(greater), '("c1" >= "c2")') + self.assertEqual(str(greater), '"c1" >= "c2"') self.assertEqual(greater.params, ()) def test_equal(self): for equal in [Equal(self.table.c1, self.table.c2), self.table.c1 == self.table.c2, ~NotEqual(self.table.c1, self.table.c2)]: - self.assertEqual(str(equal), '("c1" = "c2")') + self.assertEqual(str(equal), '"c1" = "c2"') self.assertEqual(equal.params, ()) equal = Equal(Literal('foo'), Literal('bar')) - self.assertEqual(str(equal), '(%s = %s)') + self.assertEqual(str(equal), '%s = %s') self.assertEqual(equal.params, ('foo', 'bar')) equal = Equal(self.table.c1, Null) - self.assertEqual(str(equal), '("c1" IS NULL)') + self.assertEqual(str(equal), '"c1" IS NULL') self.assertEqual(equal.params, ()) equal = Equal(Literal('test'), Null) - self.assertEqual(str(equal), '(%s IS NULL)') + self.assertEqual(str(equal), '%s IS NULL') self.assertEqual(equal.params, ('test',)) equal = Equal(Null, self.table.c1) - self.assertEqual(str(equal), '("c1" IS NULL)') + self.assertEqual(str(equal), '"c1" IS NULL') self.assertEqual(equal.params, ()) equal = Equal(Null, Literal('test')) - self.assertEqual(str(equal), '(%s IS NULL)') + self.assertEqual(str(equal), '%s IS NULL') self.assertEqual(equal.params, ('test',)) def test_not_equal(self): for equal in [NotEqual(self.table.c1, self.table.c2), self.table.c1 != self.table.c2, ~Equal(self.table.c1, self.table.c2)]: - self.assertEqual(str(equal), '("c1" != "c2")') + self.assertEqual(str(equal), '"c1" != "c2"') self.assertEqual(equal.params, ()) equal = NotEqual(self.table.c1, Null) - self.assertEqual(str(equal), '("c1" IS NOT NULL)') + self.assertEqual(str(equal), '"c1" IS NOT NULL') self.assertEqual(equal.params, ()) equal = NotEqual(Null, self.table.c1) - self.assertEqual(str(equal), '("c1" IS NOT NULL)') + self.assertEqual(str(equal), '"c1" IS NOT NULL') self.assertEqual(equal.params, ()) def test_between(self): for between in [Between(self.table.c1, 1, 2), ~NotBetween(self.table.c1, 1, 2)]: - self.assertEqual(str(between), '("c1" BETWEEN %s AND %s)') + self.assertEqual(str(between), '"c1" BETWEEN %s AND %s') self.assertEqual(between.params, (1, 2)) between = Between( self.table.c1, self.table.c2, self.table.c3, symmetric=True) self.assertEqual( - str(between), '("c1" BETWEEN SYMMETRIC "c2" AND "c3")') + str(between), '"c1" BETWEEN SYMMETRIC "c2" AND "c3"') self.assertEqual(between.params, ()) def test_not_between(self): for between in [NotBetween(self.table.c1, 1, 2), ~Between(self.table.c1, 1, 2)]: - self.assertEqual(str(between), '("c1" NOT BETWEEN %s AND %s)') + self.assertEqual(str(between), '"c1" NOT BETWEEN %s AND %s') self.assertEqual(between.params, (1, 2)) between = NotBetween( self.table.c1, self.table.c2, self.table.c3, symmetric=True) self.assertEqual( - str(between), '("c1" NOT BETWEEN SYMMETRIC "c2" AND "c3")') + str(between), '"c1" NOT BETWEEN SYMMETRIC "c2" AND "c3"') self.assertEqual(between.params, ()) def test_is_distinct(self): for distinct in [IsDistinct(self.table.c1, self.table.c2), ~IsNotDistinct(self.table.c1, self.table.c2)]: - self.assertEqual(str(distinct), '("c1" IS DISTINCT FROM "c2")') + self.assertEqual(str(distinct), '"c1" IS DISTINCT FROM "c2"') self.assertEqual(distinct.params, ()) def test_is_not_distinct(self): for distinct in [IsNotDistinct(self.table.c1, self.table.c2), ~IsDistinct(self.table.c1, self.table.c2)]: - self.assertEqual(str(distinct), '("c1" IS NOT DISTINCT FROM "c2")') + self.assertEqual(str(distinct), '"c1" IS NOT DISTINCT FROM "c2"') self.assertEqual(distinct.params, ()) def test_is(self): for is_ in [Is(self.table.c1, None), ~IsNot(self.table.c1, None)]: - self.assertEqual(str(is_), '("c1" IS UNKNOWN)') + self.assertEqual(str(is_), '"c1" IS UNKNOWN') self.assertEqual(is_.params, ()) for is_ in [Is(self.table.c1, True), ~IsNot(self.table.c1, True)]: - self.assertEqual(str(is_), '("c1" IS TRUE)') + self.assertEqual(str(is_), '"c1" IS TRUE') self.assertEqual(is_.params, ()) for is_ in [Is(self.table.c1, False), ~IsNot(self.table.c1, False)]: - self.assertEqual(str(is_), '("c1" IS FALSE)') + self.assertEqual(str(is_), '"c1" IS FALSE') self.assertEqual(is_.params, ()) def test_is_invalid_right(self): @@ -218,41 +218,41 @@ def test_is_invalid_right(self): def test_is_not(self): for is_ in [IsNot(self.table.c1, None), ~Is(self.table.c1, None)]: - self.assertEqual(str(is_), '("c1" IS NOT UNKNOWN)') + self.assertEqual(str(is_), '"c1" IS NOT UNKNOWN') self.assertEqual(is_.params, ()) for is_ in [IsNot(self.table.c1, True), ~Is(self.table.c1, True)]: - self.assertEqual(str(is_), '("c1" IS NOT TRUE)') + self.assertEqual(str(is_), '"c1" IS NOT TRUE') self.assertEqual(is_.params, ()) for is_ in [IsNot(self.table.c1, False), ~Is(self.table.c1, False)]: - self.assertEqual(str(is_), '("c1" IS NOT FALSE)') + self.assertEqual(str(is_), '"c1" IS NOT FALSE') self.assertEqual(is_.params, ()) def test_sub(self): for sub in [Sub(self.table.c1, self.table.c2), self.table.c1 - self.table.c2]: - self.assertEqual(str(sub), '("c1" - "c2")') + self.assertEqual(str(sub), '"c1" - "c2"') self.assertEqual(sub.params, ()) def test_mul(self): for mul in [Mul(self.table.c1, self.table.c2), self.table.c1 * self.table.c2]: - self.assertEqual(str(mul), '("c1" * "c2")') + self.assertEqual(str(mul), '"c1" * "c2"') self.assertEqual(mul.params, ()) def test_div(self): for div in [Div(self.table.c1, self.table.c2), self.table.c1 / self.table.c2]: - self.assertEqual(str(div), '("c1" / "c2")') + self.assertEqual(str(div), '"c1" / "c2"') self.assertEqual(div.params, ()) def test_mod(self): for mod in [Mod(self.table.c1, self.table.c2), self.table.c1 % self.table.c2]: - self.assertEqual(str(mod), '("c1" %% "c2")') + self.assertEqual(str(mod), '"c1" %% "c2"') self.assertEqual(mod.params, ()) def test_mod_paramstyle(self): @@ -260,7 +260,7 @@ def test_mod_paramstyle(self): Flavor.set(flavor) try: mod = Mod(self.table.c1, self.table.c2) - self.assertEqual(str(mod), '("c1" %% "c2")') + self.assertEqual(str(mod), '"c1" %% "c2"') self.assertEqual(mod.params, ()) finally: Flavor.set(Flavor()) @@ -269,7 +269,7 @@ def test_mod_paramstyle(self): Flavor.set(flavor) try: mod = Mod(self.table.c1, self.table.c2) - self.assertEqual(str(mod), '("c1" % "c2")') + self.assertEqual(str(mod), '"c1" % "c2"') self.assertEqual(mod.params, ()) finally: Flavor.set(Flavor()) @@ -277,24 +277,24 @@ def test_mod_paramstyle(self): def test_pow(self): for pow_ in [Pow(self.table.c1, self.table.c2), self.table.c1 ** self.table.c2]: - self.assertEqual(str(pow_), '("c1" ^ "c2")') + self.assertEqual(str(pow_), '"c1" ^ "c2"') self.assertEqual(pow_.params, ()) def test_abs(self): for abs_ in [Abs(self.table.c1), abs(self.table.c1)]: - self.assertEqual(str(abs_), '(@ "c1")') + self.assertEqual(str(abs_), '@ "c1"') self.assertEqual(abs_.params, ()) def test_lshift(self): for lshift in [LShift(self.table.c1, 2), self.table.c1 << 2]: - self.assertEqual(str(lshift), '("c1" << %s)') + self.assertEqual(str(lshift), '"c1" << %s') self.assertEqual(lshift.params, (2,)) def test_rshift(self): for rshift in [RShift(self.table.c1, 2), self.table.c1 >> 2]: - self.assertEqual(str(rshift), '("c1" >> %s)') + self.assertEqual(str(rshift), '"c1" >> %s') self.assertEqual(rshift.params, (2,)) def test_like(self): @@ -302,12 +302,12 @@ def test_like(self): self.table.c1.like('foo'), ~NotLike(self.table.c1, 'foo'), ~~Like(self.table.c1, 'foo')]: - self.assertEqual(str(like), '("c1" LIKE %s)') + self.assertEqual(str(like), '"c1" LIKE %s') self.assertEqual(like.params, ('foo',)) def test_like_escape(self): like = Like(self.table.c1, 'foo', escape='$') - self.assertEqual(str(like), '("c1" LIKE %s ESCAPE %s)') + self.assertEqual(str(like), '"c1" LIKE %s ESCAPE %s') self.assertEqual(like.params, ('foo', '$')) def test_like_escape_empty_false(self): @@ -315,7 +315,7 @@ def test_like_escape_empty_false(self): Flavor.set(flavor) try: like = Like(self.table.c1, 'foo') - self.assertEqual(str(like), '("c1" LIKE %s)') + self.assertEqual(str(like), '"c1" LIKE %s') self.assertEqual(like.params, ('foo',)) finally: Flavor.set(Flavor()) @@ -325,7 +325,7 @@ def test_like_escape_empty_true(self): Flavor.set(flavor) try: like = Like(self.table.c1, 'foo') - self.assertEqual(str(like), '("c1" LIKE %s ESCAPE %s)') + self.assertEqual(str(like), '"c1" LIKE %s ESCAPE %s') self.assertEqual(like.params, ('foo', '')) finally: Flavor.set(Flavor()) @@ -341,7 +341,7 @@ def test_ilike(self): for like in [ILike(self.table.c1, 'foo'), self.table.c1.ilike('foo'), ~NotILike(self.table.c1, 'foo')]: - self.assertEqual(str(like), '("c1" ILIKE %s)') + self.assertEqual(str(like), '"c1" ILIKE %s') self.assertEqual(like.params, ('foo',)) finally: Flavor.set(Flavor()) @@ -351,7 +351,7 @@ def test_ilike(self): try: like = ILike(self.table.c1, 'foo') self.assertEqual( - str(like), '(UPPER("c1") LIKE UPPER(%s))') + str(like), 'UPPER("c1") LIKE UPPER(%s)') self.assertEqual(like.params, ('foo',)) finally: Flavor.set(Flavor()) @@ -362,7 +362,7 @@ def test_not_ilike(self): try: for like in [NotILike(self.table.c1, 'foo'), ~self.table.c1.ilike('foo')]: - self.assertEqual(str(like), '("c1" NOT ILIKE %s)') + self.assertEqual(str(like), '"c1" NOT ILIKE %s') self.assertEqual(like.params, ('foo',)) finally: Flavor.set(Flavor()) @@ -372,7 +372,7 @@ def test_not_ilike(self): try: like = NotILike(self.table.c1, 'foo') self.assertEqual( - str(like), '(UPPER("c1") NOT LIKE UPPER(%s))') + str(like), 'UPPER("c1") NOT LIKE UPPER(%s)') self.assertEqual(like.params, ('foo',)) finally: Flavor.set(Flavor()) @@ -381,32 +381,31 @@ def test_in(self): for in_ in [In(self.table.c1, [self.table.c2, 1, Null]), ~NotIn(self.table.c1, [self.table.c2, 1, Null]), ~~In(self.table.c1, [self.table.c2, 1, Null])]: - self.assertEqual(str(in_), '("c1" IN ("c2", %s, %s))') + self.assertEqual(str(in_), '"c1" IN ("c2", %s, %s)') self.assertEqual(in_.params, (1, None)) t2 = Table('t2') in_ = In(self.table.c1, t2.select(t2.c2)) self.assertEqual(str(in_), - '("c1" IN (SELECT "a"."c2" FROM "t2" AS "a"))') + '"c1" IN (SELECT "a"."c2" FROM "t2" AS "a")') self.assertEqual(in_.params, ()) in_ = In(self.table.c1, t2.select(t2.c2) | t2.select(t2.c3)) self.assertEqual(str(in_), - '("c1" IN (SELECT "a"."c2" FROM "t2" AS "a" ' - 'UNION SELECT "a"."c3" FROM "t2" AS "a"))') + '"c1" IN (SELECT "a"."c2" FROM "t2" AS "a" ' + 'UNION SELECT "a"."c3" FROM "t2" AS "a")') self.assertEqual(in_.params, ()) in_ = In(self.table.c1, array('l', list(range(10)))) self.assertEqual(str(in_), - '("c1" IN (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s))') + '"c1" IN (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)') self.assertEqual(in_.params, tuple(range(10))) def test_exists(self): exists = Exists(self.table.select(self.table.c1, where=self.table.c1 == 1)) self.assertEqual(str(exists), - '(EXISTS (SELECT "a"."c1" FROM "t" AS "a" ' - 'WHERE ("a"."c1" = %s)))') + 'EXISTS (SELECT "a"."c1" FROM "t" AS "a" WHERE "a"."c1" = %s)') self.assertEqual(exists.params, (1,)) def test_floordiv(self): diff --git a/sql/tests/test_order.py b/sql/tests/test_order.py index 36ac834..466950e 100644 --- a/sql/tests/test_order.py +++ b/sql/tests/test_order.py @@ -36,17 +36,17 @@ def test_no_null_ordering(self): exp = NullsFirst(self.column) self.assertEqual(str(exp), - 'CASE WHEN ("c" IS NULL) THEN %s ELSE %s END ASC, "c"') + 'CASE WHEN "c" IS NULL THEN %s ELSE %s END ASC, "c"') self.assertEqual(exp.params, (0, 1)) exp = NullsFirst(Desc(self.column)) self.assertEqual(str(exp), - 'CASE WHEN ("c" IS NULL) THEN %s ELSE %s END ASC, "c" DESC') + 'CASE WHEN "c" IS NULL THEN %s ELSE %s END ASC, "c" DESC') self.assertEqual(exp.params, (0, 1)) exp = NullsLast(Literal(2)) self.assertEqual(str(exp), - 'CASE WHEN (%s IS NULL) THEN %s ELSE %s END ASC, %s') + 'CASE WHEN %s IS NULL THEN %s ELSE %s END ASC, %s') self.assertEqual(exp.params, (2, 1, 0, 2)) finally: Flavor.set(Flavor()) diff --git a/sql/tests/test_select.py b/sql/tests/test_select.py index a794510..232ea51 100644 --- a/sql/tests/test_select.py +++ b/sql/tests/test_select.py @@ -31,7 +31,7 @@ def test_select2(self): def test_select3(self): query = self.table.select(where=(self.table.c == 'foo')) self.assertEqual(str(query), - 'SELECT * FROM "t" AS "a" WHERE ("a"."c" = %s)') + 'SELECT * FROM "t" AS "a" WHERE "a"."c" = %s') self.assertEqual(tuple(query.params), ('foo',)) def test_select_iter(self): @@ -110,7 +110,7 @@ def test_select_union(self): 'SELECT * FROM "t2" AS "c") AS "a"') query1.where = self.table.c == 'foo' self.assertEqual(str(union), - 'SELECT * FROM "t" AS "a" WHERE ("a"."c" = %s) UNION ALL ' + 'SELECT * FROM "t" AS "a" WHERE "a"."c" = %s UNION ALL ' 'SELECT * FROM "t2" AS "b"') self.assertEqual(tuple(union.params), ('foo',)) @@ -279,7 +279,7 @@ def test_select_having(self): having=(Min(col2) > 3)) self.assertEqual(str(query), 'SELECT "a"."col1", MIN("a"."col2") FROM "t" AS "a" ' - 'HAVING (MIN("a"."col2") > %s)') + 'HAVING MIN("a"."col2") > %s') self.assertEqual(tuple(query.params), (3,)) def test_select_invalid_having(self): @@ -383,8 +383,8 @@ def test_select_rownum(self): 'SELECT "a".* FROM (' 'SELECT "b".*, ROWNUM AS "rnum" FROM (' 'SELECT * FROM "t" AS "c") AS "b" ' - 'WHERE (ROWNUM <= %s)) AS "a" ' - 'WHERE ("rnum" > %s)') + 'WHERE ROWNUM <= %s) AS "a" ' + 'WHERE "rnum" > %s') self.assertEqual(tuple(query.params), (60, 10)) query = self.table.select( @@ -395,8 +395,8 @@ def test_select_rownum(self): 'SELECT "b"."col1", "b"."col2", ROWNUM AS "rnum" FROM (' 'SELECT "c"."c1" AS "col1", "c"."c2" AS "col2" ' 'FROM "t" AS "c") AS "b" ' - 'WHERE (ROWNUM <= %s)) AS "a" ' - 'WHERE ("rnum" > %s)') + 'WHERE ROWNUM <= %s) AS "a" ' + 'WHERE "rnum" > %s') self.assertEqual(tuple(query.params), (60, 10)) subquery = query.select(query.col1, query.col2) @@ -407,8 +407,8 @@ def test_select_rownum(self): 'FROM (' 'SELECT "c"."c1" AS "col1", "c"."c2" AS "col2" ' 'FROM "t" AS "c") AS "a" ' - 'WHERE (ROWNUM <= %s)) AS "b" ' - 'WHERE ("rnum" > %s)) AS "a"') + 'WHERE ROWNUM <= %s) AS "b" ' + 'WHERE "rnum" > %s) AS "a"') # XXX alias of query is reused but not a problem # as it is hidden in subquery self.assertEqual(tuple(query.params), (60, 10)) @@ -419,15 +419,15 @@ def test_select_rownum(self): 'SELECT "a".* FROM (' 'SELECT "b".*, ROWNUM AS "rnum" FROM (' 'SELECT * FROM "t" AS "c" ORDER BY "c"."c") AS "b" ' - 'WHERE (ROWNUM <= %s)) AS "a" ' - 'WHERE ("rnum" > %s)') + 'WHERE ROWNUM <= %s) AS "a" ' + 'WHERE "rnum" > %s') self.assertEqual(tuple(query.params), (60, 10)) query = self.table.select(limit=50) self.assertEqual(str(query), 'SELECT "a".* FROM (' 'SELECT * FROM "t" AS "b") AS "a" ' - 'WHERE (ROWNUM <= %s)') + 'WHERE ROWNUM <= %s') self.assertEqual(tuple(query.params), (50,)) query = self.table.select(offset=10) @@ -435,7 +435,7 @@ def test_select_rownum(self): 'SELECT "a".* FROM (' 'SELECT "b".*, ROWNUM AS "rnum" FROM (' 'SELECT * FROM "t" AS "c") AS "b") AS "a" ' - 'WHERE ("rnum" > %s)') + 'WHERE "rnum" > %s') self.assertEqual(tuple(query.params), (10,)) query = self.table.select(self.table.c.as_('col'), @@ -445,9 +445,9 @@ def test_select_rownum(self): 'SELECT "a"."col" FROM (' 'SELECT "b"."col", ROWNUM AS "rnum" FROM (' 'SELECT "c"."c" AS "col" FROM "t" AS "c" ' - 'WHERE ("c"."c" >= %s)) AS "b" ' - 'WHERE (ROWNUM <= %s)) AS "a" ' - 'WHERE ("rnum" > %s)') + 'WHERE "c"."c" >= %s) AS "b" ' + 'WHERE ROWNUM <= %s) AS "a" ' + 'WHERE "rnum" > %s') self.assertEqual(tuple(query.params), (20, 60, 10)) finally: Flavor.set(Flavor()) @@ -499,7 +499,7 @@ def test_window(self): Rank(filter_=self.table.c1 > 0, window=window), Min(self.table.c1, window=window)) self.assertEqual(str(query), - 'SELECT RANK() FILTER (WHERE ("a"."c1" > %s)) OVER "b", ' + 'SELECT RANK() FILTER (WHERE "a"."c1" > %s) OVER "b", ' 'MIN("a"."c1") OVER "b" FROM "t" AS "a" ' 'WINDOW "b" AS (PARTITION BY "a"."c1")') self.assertEqual(tuple(query.params), (0,)) @@ -517,8 +517,8 @@ def test_window(self): Max(self.table.c1, window=window) / Min(self.table.c1, window=window)) self.assertEqual(str(query), - 'SELECT (MAX("a"."c1") OVER (PARTITION BY "a"."c2") ' - '/ MIN("a"."c1") OVER (PARTITION BY "a"."c2")) ' + 'SELECT MAX("a"."c1") OVER (PARTITION BY "a"."c2") ' + '/ MIN("a"."c1") OVER (PARTITION BY "a"."c2") ' 'FROM "t" AS "a"') self.assertEqual(tuple(query.params), ()) @@ -527,8 +527,8 @@ def test_window(self): Max(self.table.c1, window=window) / Min(self.table.c1, window=window)) self.assertEqual(str(query), - 'SELECT (MAX("a"."c1") OVER (PARTITION BY %s) ' - '/ MIN("a"."c1") OVER (PARTITION BY %s)) ' + 'SELECT MAX("a"."c1") OVER (PARTITION BY %s) ' + '/ MIN("a"."c1") OVER (PARTITION BY %s) ' 'FROM "t" AS "a"') self.assertEqual(tuple(query.params), (1, 1)) @@ -539,8 +539,8 @@ def test_window(self): / Min(self.table.c1, window=window2), windows=[window1]) self.assertEqual(str(query), - 'SELECT (MAX("a"."c1") OVER "b" ' - '/ MIN("a"."c1") OVER (PARTITION BY %s)) ' + 'SELECT MAX("a"."c1") OVER "b" ' + '/ MIN("a"."c1") OVER (PARTITION BY %s) ' 'FROM "t" AS "a" ' 'WINDOW "b" AS (PARTITION BY "a"."c2")') self.assertEqual(tuple(query.params), (1,)) @@ -572,10 +572,10 @@ def test_order_params(self): order_by=[Literal(8)]) self.assertEqual( str(query), - 'WITH "c" AS (SELECT "a"."c" FROM "t" AS "a" WHERE ("a"."c" > %s))' + 'WITH "c" AS (SELECT "a"."c" FROM "t" AS "a" WHERE "a"."c" > %s)' ' SELECT %s, MIN("a"."c") OVER "b" ' - 'FROM SELECT * FROM "t" AS "a" WHERE ("a"."c" > %s) ' - 'WHERE ("a"."c" > %s) ' + 'FROM SELECT * FROM "t" AS "a" WHERE "a"."c" > %s ' + 'WHERE "a"."c" > %s ' 'GROUP BY %s ' 'HAVING %s ' 'WINDOW "b" AS (PARTITION BY %s) ' diff --git a/sql/tests/test_update.py b/sql/tests/test_update.py index 74c5acc..713f080 100644 --- a/sql/tests/test_update.py +++ b/sql/tests/test_update.py @@ -15,7 +15,7 @@ def test_update1(self): query.where = (self.table.b == Literal(True)) self.assertEqual(str(query), - 'UPDATE "t" AS "a" SET "c" = %s WHERE ("a"."b" = %s)') + 'UPDATE "t" AS "a" SET "c" = %s WHERE "a"."b" = %s') self.assertEqual(query.params, ('foo', True)) def test_update2(self): @@ -24,7 +24,7 @@ def test_update2(self): query = t1.update([t1.c], ['foo'], from_=[t2], where=(t1.c == t2.c)) self.assertEqual(str(query), 'UPDATE "t1" AS "b" SET "c" = %s FROM "t2" AS "a" ' - 'WHERE ("b"."c" = "a"."c")') + 'WHERE "b"."c" = "a"."c"') self.assertEqual(query.params, ('foo',)) def test_update_invalid_values(self): @@ -43,7 +43,7 @@ def test_update_subselect(self): for query in [query_list, query_nolist]: self.assertEqual(str(query), 'UPDATE "t1" AS "b" SET "c" = (' - 'SELECT "a"."c" FROM "t2" AS "a" WHERE ("a"."i" = "b"."i"))') + 'SELECT "a"."c" FROM "t2" AS "a" WHERE "a"."i" = "b"."i")') self.assertEqual(query.params, ()) def test_update_returning(self): @@ -62,7 +62,7 @@ def test_update_returning_select(self): self.assertEqual(str(query), 'UPDATE "t1" AS "b" SET "c" = %s ' 'RETURNING (SELECT "a"."c" FROM "t2" AS "a" ' - 'WHERE (("a"."c1" = "b"."c") AND ("a"."c2" = %s)))') + 'WHERE ("a"."c1" = "b"."c") AND ("a"."c2" = %s))') self.assertEqual(query.params, ('foo', 'bar')) def test_with(self): @@ -76,7 +76,7 @@ def test_with(self): self.assertEqual(str(query), 'WITH "a" AS (SELECT "b"."c1" FROM "t1" AS "b") ' 'UPDATE "t" AS "c" SET "c2" = (SELECT "a"."c3" FROM "a" AS "a" ' - 'WHERE ("a"."c4" = %s))') + 'WHERE "a"."c4" = %s)') self.assertEqual(query.params, (2,)) def test_schema(self): @@ -95,5 +95,5 @@ def test_schema_subselect(self): self.assertEqual(str(query), 'UPDATE "default"."t1" AS "b" SET "c1" = (' 'SELECT "a"."c" FROM "default"."t2" AS "a" ' - 'WHERE ("a"."i" = "b"."i"))') + 'WHERE "a"."i" = "b"."i")') self.assertEqual(query.params, ()) diff --git a/sql/tests/test_with.py b/sql/tests/test_with.py index ab465d4..72abf0a 100644 --- a/sql/tests/test_with.py +++ b/sql/tests/test_with.py @@ -15,7 +15,7 @@ def test_with(self): self.assertEqual(simple.statement(), '"a" AS (' - 'SELECT "b"."id" FROM "t" AS "b" WHERE ("b"."id" = %s)' + 'SELECT "b"."id" FROM "t" AS "b" WHERE "b"."id" = %s' ')') self.assertEqual(simple.statement_params(), (1,)) @@ -40,7 +40,7 @@ def test_with_query(self): wq = WithQuery(with_=[simple, second]) self.assertEqual(wq._with_str(), 'WITH "a" AS (' - 'SELECT "b"."id" FROM "t" AS "b" WHERE ("b"."id" = %s)' + 'SELECT "b"."id" FROM "t" AS "b" WHERE "b"."id" = %s' '), "c" AS (' 'SELECT * FROM "a" AS "a"' ') ') @@ -59,7 +59,7 @@ def test_recursive(self): 'WITH RECURSIVE "a" ("n") AS (' 'VALUES (%s) ' 'UNION ALL ' - 'SELECT ("a"."n" + %s) FROM "a" AS "a" WHERE ("a"."n" < %s)' + 'SELECT "a"."n" + %s FROM "a" AS "a" WHERE "a"."n" < %s' ') SELECT * FROM "a" AS "a"') self.assertEqual(tuple(q.params), (1, 1, 100)) From ce59f80fda1b02a1323488071be4fdcc73c6cbfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sat, 24 Jan 2026 09:06:14 +0100 Subject: [PATCH 69/79] Add support for array operators --- CHANGELOG | 1 + sql/operators.py | 24 +++++++++++++++++++++--- sql/tests/test_operators.py | 26 ++++++++++++++++++++++---- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 1b15bae..435e467 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Add support for array operators * Remove the parentheses around the unary and binary operators * Use the ordinal number as aliases for GROUP BY * Check the coherence of the aliases of GROUP BY and ORDER BY expressions diff --git a/sql/operators.py b/sql/operators.py index 464601b..8026b94 100644 --- a/sql/operators.py +++ b/sql/operators.py @@ -49,7 +49,8 @@ def _format(self, operand, param=None): if param is None: param = Flavor.get().param if (isinstance(operand, Expression) - and not isinstance(operand, Operator)): + and (not isinstance(operand, Operator) + or isinstance(operand, UnaryOperator))): return str(operand) elif isinstance(operand, (Expression, Select, CombiningQuery)): return '(%s)' % operand @@ -458,7 +459,24 @@ class Exists(UnaryOperator): _operator = 'EXISTS' -class Any(UnaryOperator): +class _ArrayOperator(UnaryOperator): + __slots__ = () + + @property + def params(self): + if isinstance(self.operand, (list, tuple, array)): + return (list(self.operand),) + return super().params + + def _format(self, operand, param=None): + if param is None: + param = Flavor.get().param + if isinstance(operand, (list, tuple, array)): + return '(%s)' % param + return super()._format(operand, param=param) + + +class Any(_ArrayOperator): __slots__ = () _operator = 'ANY' @@ -466,7 +484,7 @@ class Any(UnaryOperator): Some = Any -class All(UnaryOperator): +class All(_ArrayOperator): __slots__ = () _operator = 'ALL' diff --git a/sql/tests/test_operators.py b/sql/tests/test_operators.py index 073ffa6..4fed465 100644 --- a/sql/tests/test_operators.py +++ b/sql/tests/test_operators.py @@ -6,10 +6,10 @@ from sql import Flavor, Literal, Null, Table from sql.operators import ( - Abs, And, Between, Div, Equal, Exists, FloorDiv, Greater, GreaterEqual, - ILike, In, Is, IsDistinct, IsNot, IsNotDistinct, Less, LessEqual, Like, - LShift, Mod, Mul, Neg, Not, NotBetween, NotEqual, NotILike, NotIn, NotLike, - Operator, Or, Pos, Pow, RShift, Sub) + Abs, And, Any, Between, Div, Equal, Exists, FloorDiv, Greater, + GreaterEqual, ILike, In, Is, IsDistinct, IsNot, IsNotDistinct, Less, + LessEqual, Like, LShift, Mod, Mul, Neg, Not, NotBetween, NotEqual, + NotILike, NotIn, NotLike, Operator, Or, Pos, Pow, RShift, Sub) class TestOperators(unittest.TestCase): @@ -418,3 +418,21 @@ def test_floordiv(self): self.assertIn( 'FloorDiv operator is deprecated, use Div function', str(w[-1].message)) + + def test_any(self): + any_ = Any(self.table.select(self.table.c1, where=self.table.c2 == 1)) + self.assertEqual(str(any_), + 'ANY (SELECT "a"."c1" FROM "t" AS "a" WHERE "a"."c2" = %s)') + self.assertEqual(any_.params, (1,)) + + for value in [[1, 2, 3], (1, 2, 3), array('l', [1, 2, 3])]: + with self.subTest(value=value): + any_ = Any(value) + self.assertEqual(str(any_), 'ANY (%s)') + self.assertEqual(any_.params, ([1, 2, 3],)) + + def test_binary_unary(self): + operator = Equal(self.table.c1, Any([1, 2, 3])) + + self.assertEqual(str(operator), '"c1" = ANY (%s)') + self.assertEqual(operator.params, ([1, 2, 3],)) From 1be9d68d837bcd8ff7023d0bcf25a695373576ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 16 Mar 2026 14:28:21 +0100 Subject: [PATCH 70/79] Upgrade to pyproject using hatchling as build-system --- .gitlab-ci.yml | 4 ++-- .hgignore | 7 +++++++ CHANGELOG | 1 + COPYRIGHT | 6 +++--- MANIFEST.in | 3 --- pyproject.toml | 38 ++++++++++++++++++++++++++++++++++ setup.py | 56 -------------------------------------------------- 7 files changed, 51 insertions(+), 64 deletions(-) create mode 100644 .hgignore delete mode 100644 MANIFEST.in create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7934b05..79433d5 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -25,9 +25,9 @@ check-isort: check-dist: extends: .check before_script: - - pip install twine + - pip install build twine script: - - python setup.py sdist + - pyproject-build - twine check dist/* .test: diff --git a/.hgignore b/.hgignore new file mode 100644 index 0000000..19f0bee --- /dev/null +++ b/.hgignore @@ -0,0 +1,7 @@ +syntax: glob +*.py[cdo] +*.egg-info +dist/ +build/ +.tox/ +.coverage diff --git a/CHANGELOG b/CHANGELOG index 435e467..79dce82 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Upgrade to pyproject * Add support for array operators * Remove the parentheses around the unary and binary operators * Use the ordinal number as aliases for GROUP BY diff --git a/COPYRIGHT b/COPYRIGHT index da57efd..5a60dd4 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -1,6 +1,6 @@ -Copyright (c) 2011-2025, Cédric Krier -Copyright (c) 2013-2025, Nicolas Évrard -Copyright (c) 2011-2025, B2CK +Copyright (c) 2011-2025 Cédric Krier +Copyright (c) 2013-2025 Nicolas Évrard +Copyright (c) 2011-2025 B2CK SRL All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index db31cdb..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -include COPYRIGHT -include README.rst -include CHANGELOG diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bae4bff --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ['hatchling >= 1', 'hatch-tryton'] +build-backend = 'hatchling.build' + +[project] +name = 'python-sql' +dynamic = ['version', 'authors'] +requires-python = '>=3.6' +maintainers = [ + {name = "Tryton", email = "foundation@tryton.org"}, + ] +description = "Library to write SQL queries" +readme = 'README.rst' +license = 'BSD-3-Clause' +license-files = ['COPYRIGHT'] +keywords = ["SQL", "database", "query"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Topic :: Database", + "Topic :: Software Development :: Libraries :: Python Modules", + ] + +[project.urls] +homepage = "https://www.tryton.org/" +changelog = "https://code.tryton.org/python-sql/-/blob/branch/default/CHANGELOG" +forum = "https://discuss.tryton.org/tags/python-sql" +issues = "https://bugs.tryton.org/python-sql" +repository = "https://code.tryton.org/python-sql" + +[tool.hatch.version] +path = 'sql/__init__.py' + +[tool.hatch.build] +packages = ['sql'] + +[tool.hatch.metadata.hooks.tryton] +copyright = 'COPYRIGHT' diff --git a/setup.py b/setup.py deleted file mode 100644 index 217f552..0000000 --- a/setup.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python -# This file is part of python-sql. The COPYRIGHT file at the top level of -# this repository contains the full copyright notices and license terms. -import codecs -import os -import re - -from setuptools import find_packages, setup - - -def read(fname): - return codecs.open( - os.path.join(os.path.dirname(__file__), fname), 'r', 'utf-8').read() - - -def get_version(): - init = read(os.path.join('sql', '__init__.py')) - return re.search("__version__ = '([0-9.]*)'", init).group(1) - - -setup(name='python-sql', - version=get_version(), - description='Library to write SQL queries', - long_description=read('README.rst'), - author='Tryton', - author_email='foundation@tryton.org', - url='https://pypi.org/project/python-sql/', - download_url='https://downloads.tryton.org/python-sql/', - project_urls={ - "Bug Tracker": 'https://bugs.tryton.org/python-sql', - "Forum": 'https://discuss.tryton.org/tags/python-sql', - "Source Code": 'https://code.tryton.org/python-sql', - }, - keywords='SQL database query', - packages=find_packages(), - python_requires='>=3.6', - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Programming Language :: Python :: 3.13', - 'Programming Language :: Python :: 3.14', - 'Topic :: Database', - 'Topic :: Software Development :: Libraries :: Python Modules', - ], - license='BSD', - ) From 9a590df297034e459ae9c600190c3b2d7a34affc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Thu, 19 Mar 2026 09:30:31 +0100 Subject: [PATCH 71/79] Remove support for Python older than 3.9 --- .gitlab-ci.yml | 2 +- CHANGELOG | 2 +- pyproject.toml | 2 +- tox.ini | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 79433d5..d70c58f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -57,7 +57,7 @@ test-tox-python: - tox -e "py${PYTHON_VERSION/./}" -- -v --output-file junit.xml parallel: matrix: - - PYTHON_VERSION: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + - PYTHON_VERSION: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] test-tox-pypy: extends: .test-tox diff --git a/CHANGELOG b/CHANGELOG index 79dce82..d939677 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -4,7 +4,7 @@ * Use the ordinal number as aliases for GROUP BY * Check the coherence of the aliases of GROUP BY and ORDER BY expressions * Do not use parameter for EXTRACT field -* Remove support for Python older than 3.6 +* Remove support for Python older than 3.9 Version 1.7.0 - 2025-11-24 * Add support for Python 3.14 diff --git a/pyproject.toml b/pyproject.toml index bae4bff..c6545ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = 'hatchling.build' [project] name = 'python-sql' dynamic = ['version', 'authors'] -requires-python = '>=3.6' +requires-python = '>=3.9' maintainers = [ {name = "Tryton", email = "foundation@tryton.org"}, ] diff --git a/tox.ini b/tox.ini index 281c0bd..6a44a68 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = py36, py37, py38, py39, py310, py311, py312, py313, py314, pypy3 +envlist = py39, py310, py311, py312, py313, py314, pypy3 [testenv] usedevelop = true From 17269ad7b4d8e300d3e24832caa1462f684a4efc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sat, 21 Mar 2026 18:33:34 +0100 Subject: [PATCH 72/79] Prepare release 1.8.0 --- CHANGELOG | 4 ++++ COPYRIGHT | 4 ++-- sql/__init__.py | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index d939677..c302ecd 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,7 @@ + +Version 1.8.0 - 2026-03-21 +-------------------------- +* Bug fixes (see mercurial logs for details) * Upgrade to pyproject * Add support for array operators * Remove the parentheses around the unary and binary operators diff --git a/COPYRIGHT b/COPYRIGHT index 5a60dd4..e0e9536 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -1,6 +1,6 @@ -Copyright (c) 2011-2025 Cédric Krier +Copyright (c) 2011-2026 Cédric Krier Copyright (c) 2013-2025 Nicolas Évrard -Copyright (c) 2011-2025 B2CK SRL +Copyright (c) 2011-2026 B2CK SRL All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/sql/__init__.py b/sql/__init__.py index e4a689f..be4cbc4 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -7,7 +7,7 @@ from itertools import chain from threading import current_thread, local -__version__ = '1.7.1' +__version__ = '1.8.0' __all__ = [ 'Flavor', 'Table', 'Values', 'Literal', 'Column', 'Grouping', 'Conflict', 'Matched', 'MatchedUpdate', 'MatchedDelete', From e300229741d2c9f261ba3919cb5ba3b6790e4bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sat, 21 Mar 2026 18:33:41 +0100 Subject: [PATCH 73/79] Added tag 1.8.0 for changeset a1db1b7c5513 --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index 9d169ff..c7f99ef 100644 --- a/.hgtags +++ b/.hgtags @@ -22,3 +22,4 @@ fcb64787b51db2068061eb4aa13825abc1134916 1.4.2 41b0aaa68f5e5bab3889fa1ef57ef44c6c21cacf 1.5.2 475502ba46eba3b7e141e8fbceaf495b545bcddb 1.6.0 231ce10b975e41027c6121f9bb9033d786553b90 1.7.0 +a1db1b7c55132372b933242b2f07cb353b973b29 1.8.0 From 2fa938a7d912a231b62a16d91e4ff91cce962e4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sat, 21 Mar 2026 18:35:19 +0100 Subject: [PATCH 74/79] Increase version number --- sql/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/__init__.py b/sql/__init__.py index be4cbc4..53c4c3d 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -7,7 +7,7 @@ from itertools import chain from threading import current_thread, local -__version__ = '1.8.0' +__version__ = '1.8.1' __all__ = [ 'Flavor', 'Table', 'Values', 'Literal', 'Column', 'Grouping', 'Conflict', 'Matched', 'MatchedUpdate', 'MatchedDelete', From ce06be5fdcfb87512014a384bd5657562c57bea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sat, 21 Mar 2026 18:42:12 +0100 Subject: [PATCH 75/79] Do not test in-place with tox --- .gitlab-ci.yml | 4 ++-- tox.ini | 9 ++------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d70c58f..ec70a00 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -45,10 +45,10 @@ check-dist: coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/' artifacts: reports: - junit: junit.xml + junit: .tox/junit.xml coverage_report: coverage_format: cobertura - path: coverage.xml + path: .tox/coverage.xml test-tox-python: extends: .test-tox diff --git a/tox.ini b/tox.ini index 6a44a68..9a67137 100644 --- a/tox.ini +++ b/tox.ini @@ -1,15 +1,10 @@ -# Tox (http://tox.testrun.org/) is a tool for running tests -# in multiple virtualenvs. This configuration file will run the -# test suite on all supported python versions. To use it, "pip install tox" -# and then run "tox" from this directory. - [tox] envlist = py39, py310, py311, py312, py313, py314, pypy3 [testenv] -usedevelop = true +changedir = {toxworkdir} commands = - coverage run --omit=*/tests/*,*/.tox/* -m xmlrunner discover -s sql.tests {posargs} + coverage run --source=sql --omit=*/tests/* -m xmlrunner discover -s sql.tests {posargs} commands_post = coverage report --omit=README.rst coverage xml --omit=README.rst From 3ff772540bc7f8ef5d99cdc334b4d2b1441d6d5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Fri, 27 Mar 2026 23:55:39 +0100 Subject: [PATCH 76/79] Use relative paths for coverage --- .gitlab-ci.yml | 8 ++++---- tox.ini | 11 +++++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ec70a00..e027c05 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -45,16 +45,16 @@ check-dist: coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/' artifacts: reports: - junit: .tox/junit.xml + junit: ${CI_PROJECT_DIR}/junit.xml coverage_report: coverage_format: cobertura - path: .tox/coverage.xml + path: ${CI_PROJECT_DIR}/coverage.xml test-tox-python: extends: .test-tox image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/python:${PYTHON_VERSION} script: - - tox -e "py${PYTHON_VERSION/./}" -- -v --output-file junit.xml + - tox -e "py${PYTHON_VERSION/./}" -- -v --output-file "${CI_PROJECT_DIR}/junit.xml" parallel: matrix: - PYTHON_VERSION: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] @@ -63,4 +63,4 @@ test-tox-pypy: extends: .test-tox image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/pypy:3 script: - - tox -e pypy3 -- -v --output-file junit.xml + - tox -e pypy3 -- -v --output-file "${CI_PROJECT_DIR}/junit.xml" diff --git a/tox.ini b/tox.ini index 9a67137..553e159 100644 --- a/tox.ini +++ b/tox.ini @@ -2,13 +2,16 @@ envlist = py39, py310, py311, py312, py313, py314, pypy3 [testenv] -changedir = {toxworkdir} +changedir = {env_site_packages_dir} commands = - coverage run --source=sql --omit=*/tests/* -m xmlrunner discover -s sql.tests {posargs} + coverage run --rcfile={toxinidir}/tox.ini --source=sql --omit=*/tests/* -m xmlrunner discover -s sql.tests {posargs} commands_post = - coverage report --omit=README.rst - coverage xml --omit=README.rst + coverage report --rcfile={toxinidir}/tox.ini --omit=README.rst + coverage xml --rcfile={toxinidir}/tox.ini --omit=README.rst -o {package_root}/coverage.xml deps = coverage unittest-xml-reporting passenv = * + +[coverage:run] +relative_files = true From 750817a3c399192c05918fc773aa60769ec609ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Fri, 3 Apr 2026 23:40:00 +0200 Subject: [PATCH 77/79] Prepare release 1.8.1 --- CHANGELOG | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index c302ecd..64a2b69 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,9 @@ +Version 1.8.1 - 2026-04-03 +-------------------------- +* Bug fixes (see mercurial logs for details) + + Version 1.8.0 - 2026-03-21 -------------------------- * Bug fixes (see mercurial logs for details) From a4c254be5d9fc927c542d808adc5902639a020b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Fri, 3 Apr 2026 23:42:38 +0200 Subject: [PATCH 78/79] Increase version number --- sql/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/__init__.py b/sql/__init__.py index 53c4c3d..c5357d2 100644 --- a/sql/__init__.py +++ b/sql/__init__.py @@ -7,7 +7,7 @@ from itertools import chain from threading import current_thread, local -__version__ = '1.8.1' +__version__ = '1.8.2' __all__ = [ 'Flavor', 'Table', 'Values', 'Literal', 'Column', 'Grouping', 'Conflict', 'Matched', 'MatchedUpdate', 'MatchedDelete', From e0deaaacd7e8048c1805c0d403667a49da53e8d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Fri, 3 Apr 2026 23:50:18 +0200 Subject: [PATCH 79/79] Added tag 1.8.1 for changeset 80c2d7b2dc49 --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index c7f99ef..517fffd 100644 --- a/.hgtags +++ b/.hgtags @@ -23,3 +23,4 @@ fcb64787b51db2068061eb4aa13825abc1134916 1.4.2 475502ba46eba3b7e141e8fbceaf495b545bcddb 1.6.0 231ce10b975e41027c6121f9bb9033d786553b90 1.7.0 a1db1b7c55132372b933242b2f07cb353b973b29 1.8.0 +80c2d7b2dc49be9ee71a4c5c2b54b00003ef67d1 1.8.1