diff --git a/backend/.flake8 b/backend/.flake8 index 47ae276f0..560241e7c 100644 --- a/backend/.flake8 +++ b/backend/.flake8 @@ -7,6 +7,7 @@ per-file-ignores = ../enterprise/backend/tests/*: F841 src/baserow/contrib/database/migrations/*: X1 src/baserow/core/migrations/*: X1 + src/baserow/core/psycopg.py: BRP001 exclude = .git, __pycache__, @@ -16,4 +17,5 @@ exclude = [flake8:local-plugins] extension = X1 = flake8_baserow:DocstringPlugin + BRP001 = flake8_baserow:BaserowPsycopgChecker paths = ./flake8_plugins diff --git a/backend/docker/docker-entrypoint.sh b/backend/docker/docker-entrypoint.sh index e65f8875c..936f23869 100755 --- a/backend/docker/docker-entrypoint.sh +++ b/backend/docker/docker-entrypoint.sh @@ -55,7 +55,10 @@ DATABASE_PASSWORD=$DATABASE_PASSWORD \ DATABASE_OPTIONS=$DATABASE_OPTIONS \ python3 << END import sys -import psycopg2 +try: + import psycopg +except ImportError: + import psycopg2 as psycopg import json import os DATABASE_NAME=os.getenv('DATABASE_NAME') @@ -66,7 +69,7 @@ DATABASE_PASSWORD=os.getenv('DATABASE_PASSWORD') DATABASE_OPTIONS=os.getenv('DATABASE_OPTIONS') try: options = json.loads(DATABASE_OPTIONS or "{}") - psycopg2.connect( + psycopg.connect( dbname=DATABASE_NAME, user=DATABASE_USER, password=DATABASE_PASSWORD, @@ -80,7 +83,7 @@ except Exception as e: print(e) print("Trying again without any DATABASE_OPTIONS:") try: - psycopg2.connect( + psycopg.connect( dbname=DATABASE_NAME, user=DATABASE_USER, password=DATABASE_PASSWORD, @@ -99,14 +102,17 @@ else DATABASE_URL=$DATABASE_URL \ python3 << END import sys -import psycopg2 +try: + import psycopg +except ImportError: + import psycopg2 as psycopg import os DATABASE_URL=os.getenv('DATABASE_URL') try: - psycopg2.connect( + psycopg.connect( DATABASE_URL ) -except psycopg2.OperationalError as e: +except psycopg.OperationalError as e: print(f"Error: Failed to connect to the postgresql database at {DATABASE_URL}") print("Please see the error below for more details:") print(e) diff --git a/backend/flake8_plugins/__init__.py b/backend/flake8_plugins/__init__.py index df147162f..5f48e1dd2 100644 --- a/backend/flake8_plugins/__init__.py +++ b/backend/flake8_plugins/__init__.py @@ -1 +1 @@ -from .flake8_baserow import DocstringPlugin +from .flake8_baserow import DocstringPlugin, BaserowPsycopgChecker diff --git a/backend/flake8_plugins/flake8_baserow/__init__.py b/backend/flake8_plugins/flake8_baserow/__init__.py index 619a0e75d..1caa7e951 100644 --- a/backend/flake8_plugins/flake8_baserow/__init__.py +++ b/backend/flake8_plugins/flake8_baserow/__init__.py @@ -1,3 +1,4 @@ from .docstring import Plugin as DocstringPlugin +from .psycopg import BaserowPsycopgChecker -__all__ = ["DocstringPlugin"] +__all__ = ["DocstringPlugin", "BaserowPsycopgChecker"] diff --git a/backend/flake8_plugins/flake8_baserow/psycopg.py b/backend/flake8_plugins/flake8_baserow/psycopg.py new file mode 100644 index 000000000..25e57a1a3 --- /dev/null +++ b/backend/flake8_plugins/flake8_baserow/psycopg.py @@ -0,0 +1,30 @@ +import ast +from typing import Iterator, Tuple, Any + +class BaserowPsycopgChecker: + name = 'flake8-baserow-psycopg' + version = '0.1.0' + + def __init__(self, tree: ast.AST, filename: str): + self.tree = tree + self.filename = filename + + def run(self) -> Iterator[Tuple[int, int, str, Any]]: + for node in ast.walk(self.tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name in ('psycopg', 'psycopg2'): + yield ( + node.lineno, + node.col_offset, + 'BRP001 Import psycopg/psycopg2 from baserow.core.psycopg instead', + type(self) + ) + elif isinstance(node, ast.ImportFrom): + if node.module in ('psycopg', 'psycopg2'): + yield ( + node.lineno, + node.col_offset, + 'BRP001 Import psycopg/psycopg2 from baserow.core.psycopg instead', + type(self) + ) \ No newline at end of file diff --git a/backend/flake8_plugins/tests/test_flake8_baserow_psycopg.py b/backend/flake8_plugins/tests/test_flake8_baserow_psycopg.py new file mode 100644 index 000000000..bb904137e --- /dev/null +++ b/backend/flake8_plugins/tests/test_flake8_baserow_psycopg.py @@ -0,0 +1,38 @@ +import ast +from flake8_baserow.psycopg import BaserowPsycopgChecker + + +def run_checker(code: str): + tree = ast.parse(code) + checker = BaserowPsycopgChecker(tree, 'test.py') + return list(checker.run()) + +def test_direct_import(): + code = ''' +import psycopg +import psycopg2 +from psycopg import connect +from psycopg2 import connect as pg_connect + ''' + errors = run_checker(code) + assert len(errors) == 4 + assert all(error[2].startswith('BRP001') for error in errors) + +def test_allowed_import(): + code = ''' +from baserow.core.psycopg import connect +from baserow.core.psycopg import psycopg2 + ''' + errors = run_checker(code) + assert len(errors) == 0 + +def test_mixed_imports(): + code = ''' +import psycopg +from baserow.core.psycopg import connect +from psycopg2 import connect as pg_connect + ''' + errors = run_checker(code) + assert len(errors) == 2 + assert errors[0][2].startswith('BRP001') + assert errors[1][2].startswith('BRP001') \ No newline at end of file diff --git a/backend/requirements/base.in b/backend/requirements/base.in index 4b5f2700d..808ecbb09 100644 --- a/backend/requirements/base.in +++ b/backend/requirements/base.in @@ -2,7 +2,7 @@ django==5.0.9 django-cors-headers==4.3.1 djangorestframework==3.15.1 djangorestframework-simplejwt==5.3.1 -psycopg2==2.9.9 +psycopg2==2.9.10 Faker==25.0.1 Twisted==24.3.0 gunicorn==22.0.0 @@ -39,25 +39,26 @@ redis==5.0.4 pysaml2==7.5.0 validators==0.28.1 requests-oauthlib==2.0.0 -opentelemetry-api==1.24.0 -opentelemetry-exporter-otlp-proto-http==1.24.0 -opentelemetry-instrumentation==0.45b0 -opentelemetry-instrumentation-django==0.45b0 -opentelemetry-instrumentation-aiohttp-client==0.45b0 -opentelemetry-instrumentation-asgi==0.45b0 -opentelemetry-instrumentation-botocore==0.45b0 -opentelemetry-instrumentation-celery==0.45b0 -opentelemetry-instrumentation-dbapi==0.45b0 -opentelemetry-instrumentation-grpc==0.45b0 -opentelemetry-instrumentation-logging==0.45b0 -opentelemetry-instrumentation-psycopg2==0.45b0 -opentelemetry-instrumentation-redis==0.45b0 -opentelemetry-instrumentation-requests==0.45b0 -opentelemetry-instrumentation-wsgi==0.45b0 -opentelemetry-proto==1.24.0 -opentelemetry-sdk==1.24.0 -opentelemetry-semantic-conventions==0.45b0 -opentelemetry-util-http==0.45b0 +opentelemetry-api==1.29.0 +opentelemetry-exporter-otlp-proto-http==1.29.0 +opentelemetry-instrumentation==0.50b0 +opentelemetry-instrumentation-django==0.50b0 +opentelemetry-instrumentation-aiohttp-client==0.50b0 +opentelemetry-instrumentation-asgi==0.50b0 +opentelemetry-instrumentation-botocore==0.50b0 +opentelemetry-instrumentation-celery==0.50b0 +opentelemetry-instrumentation-dbapi==0.50b0 +opentelemetry-instrumentation-grpc==0.50b0 +opentelemetry-instrumentation-logging==0.50b0 +opentelemetry-instrumentation-redis==0.50b0 +opentelemetry-instrumentation-psycopg2==0.50b0 +opentelemetry-instrumentation-psycopg==0.50b0 +opentelemetry-instrumentation-requests==0.50b0 +opentelemetry-instrumentation-wsgi==0.50b0 +opentelemetry-proto==1.29.0 +opentelemetry-sdk==1.29.0 +opentelemetry-semantic-conventions==0.50b0 +opentelemetry-util-http==0.50b0 Brotli==1.1.0 loguru==0.7.2 django-cachalot==2.6.2 diff --git a/backend/requirements/base.txt b/backend/requirements/base.txt index 77e52b64d..b4c7fa981 100644 --- a/backend/requirements/base.txt +++ b/backend/requirements/base.txt @@ -6,13 +6,15 @@ # advocate==1.0.0 # via -r base.in -aiohttp==3.9.5 +aiohappyeyeballs==2.4.4 + # via aiohttp +aiohttp==3.11.11 # via # langchain # langchain-community -aiosignal==1.3.1 +aiosignal==1.3.2 # via aiohttp -amqp==5.2.0 +amqp==5.3.1 # via kombu annotated-types==0.7.0 # via pydantic @@ -20,7 +22,7 @@ anthropic==0.37.1 # via -r base.in antlr4-python3-runtime==4.9.3 # via -r base.in -anyio==4.4.0 +anyio==4.8.0 # via # anthropic # httpx @@ -35,38 +37,37 @@ asgiref==3.8.1 # django # django-cors-headers # opentelemetry-instrumentation-asgi -async-timeout==4.0.3 +async-timeout==5.0.1 # via redis -attrs==23.2.0 +attrs==24.3.0 # via # aiohttp - # automat # jsonschema # service-identity # twisted -autobahn==23.6.2 +autobahn==24.4.2 # via daphne -automat==22.10.0 +automat==24.8.1 # via twisted -azure-core==1.30.1 +azure-core==1.32.0 # via # azure-storage-blob # django-storages -azure-storage-blob==12.20.0 +azure-storage-blob==12.24.0 # via django-storages backoff==2.2.1 # via posthog -billiard==4.2.0 +billiard==4.2.1 # via celery boto3==1.34.98 # via -r base.in -botocore==1.34.119 +botocore==1.34.162 # via # boto3 # s3transfer brotli==1.1.0 # via -r base.in -cachetools==5.3.3 +cachetools==5.5.0 # via google-auth celery[redis]==5.4.0 # via @@ -80,13 +81,13 @@ celery-redbeat==2.2.0 # via -r base.in celery-singleton==0.3.1 # via -r base.in -certifi==2024.6.2 +certifi==2024.12.14 # via # httpcore # httpx # requests # sentry-sdk -cffi==1.16.0 +cffi==1.17.1 # via cryptography channels[daphne]==4.0.0 # via @@ -94,9 +95,9 @@ channels[daphne]==4.0.0 # channels-redis channels-redis==4.1.0 # via -r base.in -charset-normalizer==3.3.2 +charset-normalizer==3.4.1 # via requests -click==8.1.7 +click==8.1.8 # via # celery # click-didyoumean @@ -111,9 +112,9 @@ click-repl==0.3.0 # via celery constantly==23.10.4 # via twisted -cron-descriptor==1.4.3 +cron-descriptor==1.4.5 # via django-celery-beat -cryptography==42.0.8 +cryptography==44.0.0 # via # autobahn # azure-storage-blob @@ -122,16 +123,17 @@ cryptography==42.0.8 # service-identity daphne==4.1.2 # via channels -dataclasses-json==0.6.6 +dataclasses-json==0.6.7 # via # langchain # langchain-community defusedxml==0.7.1 # via pysaml2 -deprecated==1.2.14 +deprecated==1.2.15 # via # opentelemetry-api # opentelemetry-exporter-otlp-proto-http + # opentelemetry-semantic-conventions distro==1.9.0 # via # anthropic @@ -171,7 +173,7 @@ django-redis==5.4.0 # via -r base.in django-storages[azure,google]==1.14.3 # via -r base.in -django-timezone-field==6.1.0 +django-timezone-field==7.0 # via django-celery-beat djangorestframework==3.15.1 # via @@ -182,11 +184,11 @@ djangorestframework-simplejwt==5.3.1 # via -r base.in drf-spectacular==0.27.2 # via -r base.in -elementpath==4.4.0 +elementpath==4.7.0 # via xmlschema et-xmlfile==2.0.0 # via openpyxl -eval-type-backport==0.2.0 +eval-type-backport==0.2.2 # via mistralai faker==25.0.1 # via -r base.in @@ -194,36 +196,36 @@ filelock==3.16.1 # via huggingface-hub flower==2.0.1 # via -r base.in -frozenlist==1.4.1 +frozenlist==1.5.0 # via # aiohttp # aiosignal -fsspec==2024.10.0 +fsspec==2024.12.0 # via huggingface-hub -google-api-core==2.19.0 +google-api-core==2.24.0 # via # google-cloud-core # google-cloud-storage -google-auth==2.29.0 +google-auth==2.37.0 # via # google-api-core # google-cloud-core # google-cloud-storage google-cloud-core==2.4.1 # via google-cloud-storage -google-cloud-storage==2.16.0 +google-cloud-storage==2.19.0 # via django-storages -google-crc32c==1.5.0 +google-crc32c==1.6.0 # via # google-cloud-storage # google-resumable-media -google-resumable-media==2.7.0 +google-resumable-media==2.7.2 # via google-cloud-storage -googleapis-common-protos==1.63.1 +googleapis-common-protos==1.66.0 # via # google-api-core # opentelemetry-exporter-otlp-proto-http -greenlet==3.0.3 +greenlet==3.1.1 # via sqlalchemy gunicorn==22.0.0 # via -r base.in @@ -231,19 +233,20 @@ h11==0.14.0 # via # httpcore # uvicorn -httpcore==1.0.5 +httpcore==1.0.7 # via httpx -httptools==0.6.1 +httptools==0.6.4 # via uvicorn -httpx==0.27.0 +httpx==0.27.2 # via # anthropic + # langsmith # mistralai # ollama # openai -huggingface-hub==0.26.1 +huggingface-hub==0.27.1 # via tokenizers -humanize==4.9.0 +humanize==4.11.0 # via flower hyperlink==21.0.0 # via @@ -251,7 +254,7 @@ hyperlink==21.0.0 # twisted icalendar==5.0.12 # via -r base.in -idna==3.7 +idna==3.10 # via # anyio # httpx @@ -259,19 +262,19 @@ idna==3.7 # requests # twisted # yarl -importlib-metadata==7.0.0 +importlib-metadata==8.4.0 # via opentelemetry-api -incremental==22.10.0 +incremental==24.7.2 # via twisted inflection==0.5.1 # via drf-spectacular -isodate==0.6.1 +isodate==0.7.2 # via azure-storage-blob itsdangerous==2.2.0 # via -r base.in jira2markdown==0.3.7 # via -r base.in -jiter==0.6.1 +jiter==0.8.2 # via anthropic jmespath==1.0.1 # via @@ -283,26 +286,26 @@ jsonpatch==1.33 # langchain-core jsonpath-python==1.0.6 # via mistralai -jsonpointer==2.4 +jsonpointer==3.0.0 # via jsonpatch jsonschema==4.17.3 # via # -r base.in # drf-spectacular -kombu==5.3.7 +kombu==5.4.2 # via celery langchain==0.1.17 # via -r base.in langchain-community==0.0.38 # via langchain -langchain-core==0.1.52 +langchain-core==0.1.53 # via # langchain # langchain-community # langchain-text-splitters langchain-text-splitters==0.0.2 # via langchain -langsmith==0.1.71 +langsmith==0.1.147 # via # langchain # langchain-community @@ -311,7 +314,7 @@ loguru==0.7.2 # via -r base.in markdown-it-py==3.0.0 # via rich -marshmallow==3.21.2 +marshmallow==3.24.1 # via dataclasses-json mdurl==0.1.2 # via markdown-it-py @@ -319,9 +322,9 @@ mistralai==1.1.0 # via -r base.in monotonic==1.6 # via posthog -msgpack==1.0.8 +msgpack==1.1.0 # via channels-redis -multidict==6.0.5 +multidict==6.1.0 # via # aiohttp # yarl @@ -343,7 +346,7 @@ openai==1.30.1 # via -r base.in openpyxl==3.1.5 # via -r base.in -opentelemetry-api==1.24.0 +opentelemetry-api==1.29.0 # via # -r base.in # opentelemetry-exporter-otlp-proto-http @@ -356,17 +359,19 @@ opentelemetry-api==1.24.0 # opentelemetry-instrumentation-django # opentelemetry-instrumentation-grpc # opentelemetry-instrumentation-logging + # opentelemetry-instrumentation-psycopg # opentelemetry-instrumentation-psycopg2 # opentelemetry-instrumentation-redis # opentelemetry-instrumentation-requests # opentelemetry-instrumentation-wsgi # opentelemetry-propagator-aws-xray # opentelemetry-sdk -opentelemetry-exporter-otlp-proto-common==1.24.0 + # opentelemetry-semantic-conventions +opentelemetry-exporter-otlp-proto-common==1.29.0 # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.24.0 +opentelemetry-exporter-otlp-proto-http==1.29.0 # via -r base.in -opentelemetry-instrumentation==0.45b0 +opentelemetry-instrumentation==0.50b0 # via # -r base.in # opentelemetry-instrumentation-aiohttp-client @@ -377,53 +382,57 @@ opentelemetry-instrumentation==0.45b0 # opentelemetry-instrumentation-django # opentelemetry-instrumentation-grpc # opentelemetry-instrumentation-logging + # opentelemetry-instrumentation-psycopg # opentelemetry-instrumentation-psycopg2 # opentelemetry-instrumentation-redis # opentelemetry-instrumentation-requests # opentelemetry-instrumentation-wsgi -opentelemetry-instrumentation-aiohttp-client==0.45b0 +opentelemetry-instrumentation-aiohttp-client==0.50b0 # via -r base.in -opentelemetry-instrumentation-asgi==0.45b0 +opentelemetry-instrumentation-asgi==0.50b0 # via -r base.in -opentelemetry-instrumentation-botocore==0.45b0 +opentelemetry-instrumentation-botocore==0.50b0 # via -r base.in -opentelemetry-instrumentation-celery==0.45b0 +opentelemetry-instrumentation-celery==0.50b0 # via -r base.in -opentelemetry-instrumentation-dbapi==0.45b0 +opentelemetry-instrumentation-dbapi==0.50b0 # via # -r base.in + # opentelemetry-instrumentation-psycopg # opentelemetry-instrumentation-psycopg2 -opentelemetry-instrumentation-django==0.45b0 +opentelemetry-instrumentation-django==0.50b0 # via -r base.in -opentelemetry-instrumentation-grpc==0.45b0 +opentelemetry-instrumentation-grpc==0.50b0 # via -r base.in -opentelemetry-instrumentation-logging==0.45b0 +opentelemetry-instrumentation-logging==0.50b0 # via -r base.in -opentelemetry-instrumentation-psycopg2==0.45b0 +opentelemetry-instrumentation-psycopg==0.50b0 # via -r base.in -opentelemetry-instrumentation-redis==0.45b0 +opentelemetry-instrumentation-psycopg2==0.50b0 # via -r base.in -opentelemetry-instrumentation-requests==0.45b0 +opentelemetry-instrumentation-redis==0.50b0 # via -r base.in -opentelemetry-instrumentation-wsgi==0.45b0 +opentelemetry-instrumentation-requests==0.50b0 + # via -r base.in +opentelemetry-instrumentation-wsgi==0.50b0 # via # -r base.in # opentelemetry-instrumentation-django -opentelemetry-propagator-aws-xray==1.0.1 +opentelemetry-propagator-aws-xray==1.0.2 # via opentelemetry-instrumentation-botocore -opentelemetry-proto==1.24.0 +opentelemetry-proto==1.29.0 # via # -r base.in # opentelemetry-exporter-otlp-proto-common # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.24.0 +opentelemetry-sdk==1.29.0 # via # -r base.in # opentelemetry-exporter-otlp-proto-http - # opentelemetry-instrumentation-grpc -opentelemetry-semantic-conventions==0.45b0 +opentelemetry-semantic-conventions==0.50b0 # via # -r base.in + # opentelemetry-instrumentation # opentelemetry-instrumentation-aiohttp-client # opentelemetry-instrumentation-asgi # opentelemetry-instrumentation-botocore @@ -435,7 +444,7 @@ opentelemetry-semantic-conventions==0.45b0 # opentelemetry-instrumentation-requests # opentelemetry-instrumentation-wsgi # opentelemetry-sdk -opentelemetry-util-http==0.45b0 +opentelemetry-util-http==0.50b0 # via # -r base.in # opentelemetry-instrumentation-aiohttp-client @@ -443,7 +452,7 @@ opentelemetry-util-http==0.45b0 # opentelemetry-instrumentation-django # opentelemetry-instrumentation-requests # opentelemetry-instrumentation-wsgi -orjson==3.10.3 +orjson==3.10.13 # via langsmith packaging==23.2 # via @@ -451,19 +460,24 @@ packaging==23.2 # huggingface-hub # langchain-core # marshmallow + # opentelemetry-instrumentation pillow==10.3.0 # via -r base.in posthog==3.5.0 # via -r base.in -prometheus-client==0.20.0 +prometheus-client==0.21.1 # via flower -prompt-toolkit==3.0.46 +prompt-toolkit==3.0.48 # via click-repl +propcache==0.2.1 + # via + # aiohttp + # yarl prosemirror @ https://github.com/fellowapp/prosemirror-py/archive/refs/tags/v0.3.5.zip # via -r base.in -proto-plus==1.23.0 +proto-plus==1.25.0 # via google-api-core -protobuf==4.25.3 +protobuf==5.29.2 # via # google-api-core # googleapis-common-protos @@ -471,16 +485,16 @@ protobuf==4.25.3 # proto-plus psutil==5.9.8 # via -r base.in -psycopg2==2.9.9 +psycopg2==2.9.10 # via -r base.in -pyasn1==0.6.0 +pyasn1==0.6.1 # via # advocate # ndg-httpsclient # pyasn1-modules # rsa # service-identity -pyasn1-modules==0.4.0 +pyasn1-modules==0.4.1 # via # google-auth # service-identity @@ -496,23 +510,23 @@ pydantic==2.9.2 # openai pydantic-core==2.23.4 # via pydantic -pygments==2.18.0 +pygments==2.19.1 # via rich -pyjwt==2.8.0 +pyjwt==2.10.1 # via djangorestframework-simplejwt -pyopenssl==24.1.0 +pyopenssl==24.3.0 # via # advocate # ndg-httpsclient # pysaml2 # twisted -pyparsing==3.2.0 +pyparsing==3.2.1 # via jira2markdown pyrsistent==0.20.0 # via jsonschema pysaml2==7.5.0 # via -r base.in -python-crontab==3.1.0 +python-crontab==3.2.0 # via django-celery-beat python-dateutil==2.8.2 # via @@ -527,12 +541,12 @@ python-dateutil==2.8.2 # python-crontab python-dotenv==1.0.1 # via uvicorn -pytz==2024.1 +pytz==2024.2 # via # flower # icalendar # pysaml2 -pyyaml==6.0.1 +pyyaml==6.0.2 # via # drf-spectacular # huggingface-hub @@ -565,13 +579,16 @@ requests==2.31.0 # posthog # pysaml2 # requests-oauthlib + # requests-toolbelt requests-oauthlib==2.0.0 # via -r base.in +requests-toolbelt==1.0.0 + # via langsmith rich==13.7.1 # via -r base.in rsa==4.9 # via google-auth -s3transfer==0.10.1 +s3transfer==0.10.4 # via boto3 sentry-sdk==2.0.1 # via -r base.in @@ -579,12 +596,10 @@ service-identity==24.1.0 # via # -r base.in # twisted -six==1.16.0 +six==1.17.0 # via # advocate - # automat # azure-core - # isodate # posthog # python-dateutil sniffio==1.3.1 @@ -593,21 +608,21 @@ sniffio==1.3.1 # anyio # httpx # openai -sqlalchemy==2.0.30 +sqlalchemy==2.0.36 # via # langchain # langchain-community -sqlparse==0.5.0 +sqlparse==0.5.3 # via django -tenacity==8.3.0 +tenacity==8.5.0 # via # celery-redbeat # langchain # langchain-community # langchain-core -tokenizers==0.20.1 +tokenizers==0.21.0 # via anthropic -tornado==6.4.1 +tornado==6.4.2 # via flower tqdm==4.66.4 # via @@ -624,6 +639,7 @@ typing-extensions==4.11.0 # via # -r base.in # anthropic + # anyio # azure-core # azure-storage-blob # dj-database-url @@ -645,11 +661,12 @@ tzdata==2024.1 # -r base.in # celery # django-celery-beat + # kombu unicodecsv==0.14.1 # via -r base.in uritemplate==4.1.1 # via drf-spectacular -urllib3==1.26.18 +urllib3==1.26.20 # via # advocate # botocore @@ -657,7 +674,7 @@ urllib3==1.26.18 # sentry-sdk uvicorn[standard]==0.29.0 # via -r base.in -uvloop==0.19.0 +uvloop==0.21.0 # via uvicorn validators==0.28.1 # via -r base.in @@ -666,7 +683,7 @@ vine==5.1.0 # amqp # celery # kombu -watchfiles==0.22.0 +watchfiles==1.0.3 # via uvicorn wcwidth==0.2.13 # via prompt-toolkit @@ -674,7 +691,7 @@ websockets==12.0 # via # -r base.in # uvicorn -wrapt==1.16.0 +wrapt==1.17.0 # via # deprecated # opentelemetry-instrumentation @@ -684,7 +701,7 @@ wrapt==1.16.0 # opentelemetry-instrumentation-redis xmlschema==2.5.1 # via pysaml2 -yarl==1.9.4 +yarl==1.18.3 # via aiohttp zipp==3.18.1 # via @@ -692,7 +709,7 @@ zipp==3.18.1 # importlib-metadata zipstream-ng==1.8.0 # via -r base.in -zope-interface==6.4.post2 +zope-interface==7.2 # via twisted # The following packages are considered to be unsafe in a requirements file: diff --git a/backend/requirements/dev.txt b/backend/requirements/dev.txt index 6215089e1..1acb3f3ef 100644 --- a/backend/requirements/dev.txt +++ b/backend/requirements/dev.txt @@ -10,15 +10,15 @@ asgiref==3.8.1 # via # -c base.txt # django -asttokens==2.4.1 +asttokens==3.0.0 # via # snoop # stack-data -async-timeout==4.0.3 +async-timeout==5.0.1 # via # -c base.txt # redis -attrs==23.2.0 +attrs==24.3.0 # via # -c base.txt # jsonschema @@ -32,21 +32,21 @@ bandit==1.7.8 # via -r dev.in black==23.3.0 # via -r dev.in -build==1.2.2 +build==1.2.2.post1 # via # -r dev.in # pip-tools -certifi==2024.6.2 +certifi==2024.12.14 # via # -c base.txt # requests -charset-normalizer==3.3.2 +charset-normalizer==3.4.1 # via # -c base.txt # requests cheap-repr==0.5.2 # via snoop -click==8.1.7 +click==8.1.8 # via # -c base.txt # black @@ -98,7 +98,7 @@ httpretty==1.1.4 # via -r dev.in icdiff==2.0.7 # via pytest-icdiff -idna==3.7 +idna==3.10 # via # -c base.txt # requests @@ -106,15 +106,15 @@ iniconfig==2.0.0 # via pytest ipdb==0.13.13 # via -r dev.in -ipython==8.27.0 +ipython==8.31.0 # via # -r dev.in # ipdb isort==5.13.2 # via -r dev.in -jedi==0.19.1 +jedi==0.19.2 # via ipython -jinja2==3.1.4 +jinja2==3.1.5 # via pytest-html jsonschema==4.17.3 # via @@ -132,7 +132,7 @@ markdown-it-py==3.0.0 # via # -c base.txt # rich -markupsafe==2.1.5 +markupsafe==3.0.2 # via jinja2 matplotlib-inline==0.1.7 # via ipython @@ -174,13 +174,13 @@ pexpect==4.9.0 # via ipython pip-tools==7.4.1 # via -r dev.in -platformdirs==4.3.3 +platformdirs==4.3.6 # via black pluggy==1.5.0 # via pytest pprintpp==0.4.0 # via pytest-icdiff -prompt-toolkit==3.0.46 +prompt-toolkit==3.0.48 # via # -c base.txt # ipython @@ -196,7 +196,7 @@ pyfakefs==5.4.1 # via -r dev.in pyflakes==3.2.0 # via flake8 -pygments==2.18.0 +pygments==2.19.1 # via # -c base.txt # ipython @@ -204,7 +204,7 @@ pygments==2.18.0 # snoop pyinstrument==4.6.2 # via -r dev.in -pyproject-hooks==1.1.0 +pyproject-hooks==1.2.0 # via # build # pip-tools @@ -258,7 +258,7 @@ python-dateutil==2.8.2 # via # -c base.txt # freezegun -pyyaml==6.0.1 +pyyaml==6.0.2 # via # -c base.txt # bandit @@ -281,10 +281,9 @@ rich==13.7.1 # via # -c base.txt # bandit -six==1.16.0 +six==1.17.0 # via # -c base.txt - # asttokens # python-dateutil # rfc3339-validator # snoop @@ -292,24 +291,24 @@ snoop==0.4.3 # via -r dev.in sortedcontainers==2.4.0 # via fakeredis -sqlparse==0.5.0 +sqlparse==0.5.3 # via # -c base.txt # django # django-silk stack-data==0.6.3 # via ipython -stevedore==5.3.0 +stevedore==5.4.0 # via bandit -tomli==2.0.1 +tomli==2.2.1 # via django-stubs traitlets==5.14.3 # via # ipython # matplotlib-inline -types-pytz==2024.2.0.20240913 +types-pytz==2024.2.0.20241221 # via django-stubs -types-pyyaml==6.0.12.20240917 +types-pyyaml==6.0.12.20241230 # via django-stubs typing-extensions==4.11.0 # via @@ -318,7 +317,7 @@ typing-extensions==4.11.0 # django-stubs-ext # ipython # mypy -urllib3==1.26.18 +urllib3==1.26.20 # via # -c base.txt # requests @@ -329,7 +328,7 @@ wcwidth==0.2.13 # via # -c base.txt # prompt-toolkit -wheel==0.44.0 +wheel==0.45.1 # via pip-tools # The following packages are considered to be unsafe in a requirements file: diff --git a/backend/src/baserow/cachalot_patch.py b/backend/src/baserow/cachalot_patch.py index ea1ed16eb..da52549b8 100644 --- a/backend/src/baserow/cachalot_patch.py +++ b/backend/src/baserow/cachalot_patch.py @@ -6,49 +6,60 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.db.transaction import get_connection -from cachalot import utils as cachalot_utils -from cachalot.settings import cachalot_settings from django_redis import get_redis_connection from loguru import logger -from psycopg2.sql import Composed +from baserow.core.psycopg import sql -@contextmanager -def cachalot_enabled(): - """ - A context manager that enables cachalot for the duration of the context. This is - useful when you want to enable cachalot for a specific query but you don't want - to enable it globally. - Please note that the query have to be executed within the context of the context - manager in order for it to be cached. - """ +if settings.CACHALOT_ENABLED: + from cachalot.settings import cachalot_disabled, cachalot_settings # noqa: F401 - from cachalot.api import LOCAL_STORAGE + @contextmanager + def cachalot_enabled(): + """ + A context manager that enables cachalot for the duration of the context. This is + useful when you want to enable cachalot for a specific query but you don't want + to enable it globally. Please note that the query have to be executed within the + context of the context manager in order for it to be cached. + """ - was_enabled = getattr( - LOCAL_STORAGE, "cachalot_enabled", cachalot_settings.CACHALOT_ENABLED - ) - LOCAL_STORAGE.cachalot_enabled = True - try: + from cachalot.api import LOCAL_STORAGE + + was_enabled = getattr( + LOCAL_STORAGE, "cachalot_enabled", cachalot_settings.CACHALOT_ENABLED + ) + LOCAL_STORAGE.cachalot_enabled = True + try: + yield + finally: + LOCAL_STORAGE.cachalot_enabled = was_enabled + +else: + + @contextmanager + def cachalot_enabled(): + yield + + @contextmanager + def cachalot_disabled(): yield - finally: - LOCAL_STORAGE.cachalot_enabled = was_enabled def patch_cachalot_for_baserow(): """ - This function patches the cachalot library to make it work with baserow - dynamic models. The problem we're trying to solve here is that the only way - to limit what cachalot caches is to provide a fix list of tables, but - baserow creates dynamic models on the fly so we can't know what tables will - be created in advance, so we need to include all the tables that start with - the USER_TABLE_DATABASE_NAME_PREFIX prefix in the list of cachable tables. + This function patches the cachalot library to make it work with baserow dynamic + models. The problem we're trying to solve here is that the only way to limit what + cachalot caches is to provide a fix list of tables, but baserow creates dynamic + models on the fly so we can't know what tables will be created in advance, so we + need to include all the tables that start with the USER_TABLE_DATABASE_NAME_PREFIX + prefix in the list of cachable tables. - `filter_cachable` and `is_cachable` are called to invalidate the cache when - a table is changed. `are_all_cachable` is called to check if a query can be - cached. + `filter_cachable` and `is_cachable` are called to invalidate the cache when a table + is changed. `are_all_cachable` is called to check if a query can be cached. """ + from cachalot import utils as cachalot_utils + from baserow.contrib.database.table.constants import ( LINK_ROW_THROUGH_TABLE_PREFIX, MULTIPLE_COLLABORATOR_THROUGH_TABLE_PREFIX, @@ -97,13 +108,12 @@ def patch_cachalot_for_baserow(): @wraps(original_are_all_cachable) def patched_are_all_cachable(tables): """ - This patch works because cachalot does not explicitly set this thread - local variable, but it assumes to be True by default if CACHALOT_ENABLED - is not set otherwise. Since we are explicitly setting it to True in our - code for the query we want to cache, we can check if the value has been - set or not to exclude our dynamic tables from the list of tables that - cachalot will check, making all of them cachable for the queries - wrapped in the `cachalot_enabled` context manager. + This patch works because cachalot does not explicitly set this thread local + variable, but it assumes to be True by default if CACHALOT_ENABLED is not set + otherwise. Since we are explicitly setting it to True in our code for the query + we want to cache, we can check if the value has been set or not to exclude our + dynamic tables from the list of tables that cachalot will check, making all of + them cachable for the queries wrapped in the `cachalot_enabled` context manager. """ from cachalot.api import LOCAL_STORAGE @@ -139,21 +149,21 @@ def patch_cachalot_for_baserow(): def lower(self): """ Cachalot wants this method to lowercase the queries to check if they are - cachable, but the Composed class in psycopg2.sql does not have a lower + cachable, but the Composed class in psycopg.sql does not have a lower method, so we add it here to add the support for it. """ cursor = get_connection().cursor() return self.as_string(cursor.cursor).lower() - Composed.lower = lower + sql.Composed.lower = lower def clear_cachalot_cache(): """ - This function clears the cachalot cache. It can be used in the tests to make - sure that the cache is cleared between tests or as post_migrate receiver to - ensure to start with a clean cache after migrations. + This function clears the cachalot cache. It can be used in the tests to make sure + that the cache is cleared between tests or as post_migrate receiver to ensure to + start with a clean cache after migrations. """ from django.conf import settings @@ -179,9 +189,8 @@ def clear_cachalot_cache(): def _delete_pattern(key_prefix: str) -> int: """ - Allows deleting every redis key that matches a pattern. Copied from the - django-redis implementation but modified to allow deleting all versions in the - cache at once. + Allows deleting every redis key that matches a pattern. Copied from the django-redis + implementation but modified to allow deleting all versions in the cache at once. """ client = get_redis_connection("default") diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py index 1044e39ca..ca7c507aa 100644 --- a/backend/src/baserow/config/settings/base.py +++ b/backend/src/baserow/config/settings/base.py @@ -17,7 +17,6 @@ from corsheaders.defaults import default_headers from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.scrubber import DEFAULT_DENYLIST, EventScrubber -from baserow.cachalot_patch import patch_cachalot_for_baserow from baserow.config.settings.utils import ( Setting, get_crontab_from_env, @@ -254,68 +253,6 @@ CACHES = { }, } - -CACHALOT_TIMEOUT = int(os.getenv("BASEROW_CACHALOT_TIMEOUT", 60 * 60 * 24 * 7)) -BASEROW_CACHALOT_ONLY_CACHABLE_TABLES = os.getenv( - "BASEROW_CACHALOT_ONLY_CACHABLE_TABLES", None -) -BASEROW_CACHALOT_MODE = os.getenv("BASEROW_CACHALOT_MODE", "default") -if BASEROW_CACHALOT_MODE == "full": - CACHALOT_ONLY_CACHABLE_TABLES = [] - -elif BASEROW_CACHALOT_ONLY_CACHABLE_TABLES: - # Please avoid to add tables with more than 50 modifications per minute - # to this list, as described here: - # https://django-cachalot.readthedocs.io/en/latest/limits.html - CACHALOT_ONLY_CACHABLE_TABLES = BASEROW_CACHALOT_ONLY_CACHABLE_TABLES.split(",") -else: - CACHALOT_ONLY_CACHABLE_TABLES = [ - "auth_user", - "django_content_type", - "core_settings", - "core_userprofile", - "core_application", - "core_operation", - "core_template", - "core_trashentry", - "core_workspace", - "core_workspaceuser", - "core_workspaceuserinvitation", - "core_authprovidermodel", - "core_passwordauthprovidermodel", - "database_database", - "database_table", - "database_field", - "database_fieldependency", - "database_linkrowfield", - "database_selectoption", - "baserow_premium_license", - "baserow_premium_licenseuser", - "baserow_enterprise_role", - "baserow_enterprise_roleassignment", - "baserow_enterprise_team", - "baserow_enterprise_teamsubject", - ] - -# This list will have priority over CACHALOT_ONLY_CACHABLE_TABLES. -BASEROW_CACHALOT_UNCACHABLE_TABLES = os.getenv( - "BASEROW_CACHALOT_UNCACHABLE_TABLES", None -) - -if BASEROW_CACHALOT_UNCACHABLE_TABLES: - CACHALOT_UNCACHABLE_TABLES = list( - filter(bool, BASEROW_CACHALOT_UNCACHABLE_TABLES.split(",")) - ) - -CACHALOT_ENABLED = os.getenv("BASEROW_CACHALOT_ENABLED", "false") == "true" -CACHALOT_CACHE = "cachalot" -CACHALOT_UNCACHABLE_TABLES = [ - "django_migrations", - "core_action", - "database_token", - "baserow_enterprise_auditlogentry", -] - BUILDER_PUBLICLY_USED_PROPERTIES_CACHE_TTL_SECONDS = int( # Default TTL is 10 minutes: 60 seconds * 10 os.getenv("BASEROW_BUILDER_PUBLICLY_USED_PROPERTIES_CACHE_TTL_SECONDS") @@ -328,26 +265,6 @@ BUILDER_DISPATCH_ACTION_CACHE_TTL_SECONDS = int( ) -def install_cachalot(): - global INSTALLED_APPS - - INSTALLED_APPS.append("cachalot") - - patch_cachalot_for_baserow() - - -if CACHALOT_ENABLED: - install_cachalot() - - CACHES[CACHALOT_CACHE] = { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": REDIS_URL, - "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, - "KEY_PREFIX": f"baserow-{CACHALOT_CACHE}-cache", - "VERSION": VERSION, - } - - CELERY_SINGLETON_BACKEND_CLASS = ( "baserow.celery_singleton_backend.RedisBackendForSingleton" ) @@ -1347,3 +1264,88 @@ BASEROW_MAX_HEALTHY_CELERY_QUEUE_SIZE = int( ) BASEROW_USE_LOCAL_CACHE = str_to_bool(os.getenv("BASEROW_USE_LOCAL_CACHE", "true")) + +# -- CACHALOT SETTINGS -- +CACHALOT_TIMEOUT = int(os.getenv("BASEROW_CACHALOT_TIMEOUT", 60 * 60 * 24 * 7)) +BASEROW_CACHALOT_ONLY_CACHABLE_TABLES = os.getenv( + "BASEROW_CACHALOT_ONLY_CACHABLE_TABLES", None +) +BASEROW_CACHALOT_MODE = os.getenv("BASEROW_CACHALOT_MODE", "default") +if BASEROW_CACHALOT_MODE == "full": + CACHALOT_ONLY_CACHABLE_TABLES = [] + +elif BASEROW_CACHALOT_ONLY_CACHABLE_TABLES: + # Please avoid to add tables with more than 50 modifications per minute to this + # list, as described here: + # https://django-cachalot.readthedocs.io/en/latest/limits.html + CACHALOT_ONLY_CACHABLE_TABLES = BASEROW_CACHALOT_ONLY_CACHABLE_TABLES.split(",") +else: + CACHALOT_ONLY_CACHABLE_TABLES = [ + "auth_user", + "django_content_type", + "core_settings", + "core_userprofile", + "core_application", + "core_operation", + "core_template", + "core_trashentry", + "core_workspace", + "core_workspaceuser", + "core_workspaceuserinvitation", + "core_authprovidermodel", + "core_passwordauthprovidermodel", + "database_database", + "database_table", + "database_field", + "database_fieldependency", + "database_linkrowfield", + "database_selectoption", + "baserow_premium_license", + "baserow_premium_licenseuser", + "baserow_enterprise_role", + "baserow_enterprise_roleassignment", + "baserow_enterprise_team", + "baserow_enterprise_teamsubject", + ] + +# This list will have priority over CACHALOT_ONLY_CACHABLE_TABLES. +BASEROW_CACHALOT_UNCACHABLE_TABLES = os.getenv( + "BASEROW_CACHALOT_UNCACHABLE_TABLES", None +) + +if BASEROW_CACHALOT_UNCACHABLE_TABLES: + CACHALOT_UNCACHABLE_TABLES = list( + filter(bool, BASEROW_CACHALOT_UNCACHABLE_TABLES.split(",")) + ) + +CACHALOT_ENABLED = str_to_bool(os.getenv("BASEROW_CACHALOT_ENABLED", "")) +CACHALOT_CACHE = "cachalot" +CACHALOT_UNCACHABLE_TABLES = [ + "django_migrations", + "core_action", + "database_token", + "baserow_enterprise_auditlogentry", +] + + +def install_cachalot(): + from baserow.cachalot_patch import patch_cachalot_for_baserow + + global INSTALLED_APPS + + INSTALLED_APPS.append("cachalot") + + patch_cachalot_for_baserow() + + +if CACHALOT_ENABLED: + install_cachalot() + + CACHES[CACHALOT_CACHE] = { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, + "KEY_PREFIX": f"baserow-{CACHALOT_CACHE}-cache", + "VERSION": VERSION, + } +# -- END CACHALOT SETTINGS -- diff --git a/backend/src/baserow/config/settings/test.py b/backend/src/baserow/config/settings/test.py index 9a4f87e5c..2d6549bb6 100644 --- a/backend/src/baserow/config/settings/test.py +++ b/backend/src/baserow/config/settings/test.py @@ -36,6 +36,11 @@ CELERY_TASK_EAGER_PROPAGATES = True CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} +# Disable default optimizations for the tests because they make tests slower. +DATABASES["default"]["OPTIONS"] = { + "server_side_binding": False, + "prepare_threshold": None, +} # Open a second database connection that can be used to test transactions. DATABASES["default-copy"] = deepcopy(DATABASES["default"]) @@ -59,11 +64,6 @@ CACHES = { "KEY_PREFIX": f"baserow-{GENERATED_MODEL_CACHE_NAME}-cache", "VERSION": None, }, - CACHALOT_CACHE: { - "BACKEND": "django.core.cache.backends.locmem.LocMemCache", - "KEY_PREFIX": f"baserow-{CACHALOT_CACHE}-cache", - "VERSION": None, - }, } # Disable the default throttle classes because ConcurrentUserRequestsThrottle is @@ -71,10 +71,6 @@ CACHES = { # Look into tests.baserow.api.test_api_utils.py if you need to test the throttle REST_FRAMEWORK["DEFAULT_THROTTLE_CLASSES"] = [] -if "cachalot" not in INSTALLED_APPS: - install_cachalot() - -CACHALOT_ENABLED = False BUILDER_PUBLICLY_USED_PROPERTIES_CACHE_TTL_SECONDS = 10 BUILDER_DISPATCH_ACTION_CACHE_TTL_SECONDS = 300 @@ -105,3 +101,14 @@ STORAGES["default"] = {"BACKEND": BASE_FILE_STORAGE} BASEROW_LOGIN_ACTION_LOG_LIMIT = RateLimit.from_string("1000/s") BASEROW_WEBHOOKS_ALLOW_PRIVATE_ADDRESS = False + + +CACHALOT_ENABLED = str_to_bool(os.getenv("CACHALOT_ENABLED", "false")) +if CACHALOT_ENABLED: + CACHES[CACHALOT_CACHE] = { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "KEY_PREFIX": f"baserow-{CACHALOT_CACHE}-cache", + "VERSION": None, + } + + install_cachalot() diff --git a/backend/src/baserow/contrib/database/data_sync/postgresql_data_sync_type.py b/backend/src/baserow/contrib/database/data_sync/postgresql_data_sync_type.py index 14e27651e..218f88510 100644 --- a/backend/src/baserow/contrib/database/data_sync/postgresql_data_sync_type.py +++ b/backend/src/baserow/contrib/database/data_sync/postgresql_data_sync_type.py @@ -4,9 +4,6 @@ from typing import Any, Dict, List, Optional from django.conf import settings from django.db import DEFAULT_DB_ALIAS -import psycopg2 -from psycopg2 import sql - from baserow.contrib.database.fields.models import ( NUMBER_MAX_DECIMAL_PLACES, BooleanField, @@ -15,6 +12,7 @@ from baserow.contrib.database.fields.models import ( NumberField, TextField, ) +from baserow.core.psycopg import psycopg, sql from baserow.core.utils import ChildProgressBuilder, are_hostnames_same from .exceptions import SyncError @@ -171,7 +169,7 @@ class PostgreSQLDataSyncType(DataSyncType): if baserow_postgresql_connection or data_sync_blacklist: raise SyncError("It's not allowed to connect to this hostname.") try: - connection = psycopg2.connect( + connection = psycopg.connect( host=instance.postgresql_host, dbname=instance.postgresql_database, user=instance.postgresql_username, @@ -181,7 +179,7 @@ class PostgreSQLDataSyncType(DataSyncType): ) cursor = connection.cursor() yield cursor - except psycopg2.Error as e: + except psycopg.Error as e: raise SyncError(str(e)) finally: if cursor: diff --git a/backend/src/baserow/contrib/database/db/atomic.py b/backend/src/baserow/contrib/database/db/atomic.py index 09baba1d6..cf14b72e9 100644 --- a/backend/src/baserow/contrib/database/db/atomic.py +++ b/backend/src/baserow/contrib/database/db/atomic.py @@ -1,9 +1,7 @@ from django.db.transaction import Atomic -from cachalot.api import cachalot_disabled -from psycopg2 import sql - -from baserow.core.db import IsolationLevel, transaction_atomic +from baserow.cachalot_patch import cachalot_disabled +from baserow.core.db import IsolationLevel, sql, transaction_atomic def read_repeatable_single_database_atomic_transaction( diff --git a/backend/src/baserow/contrib/database/fields/backup_handler.py b/backend/src/baserow/contrib/database/fields/backup_handler.py index ea4f759ca..a5961c3a5 100644 --- a/backend/src/baserow/contrib/database/fields/backup_handler.py +++ b/backend/src/baserow/contrib/database/fields/backup_handler.py @@ -5,11 +5,10 @@ from django.core.management.color import no_style from django.db import connection from django.db.models import ManyToManyField -from psycopg2 import sql - from baserow.contrib.database.db.schema import safe_django_schema_editor from baserow.contrib.database.fields.models import Field from baserow.contrib.database.table.models import GeneratedTableModel, Table +from baserow.core.psycopg import sql BackupData = Dict[str, Any] diff --git a/backend/src/baserow/contrib/database/fields/dependencies/handler.py b/backend/src/baserow/contrib/database/fields/dependencies/handler.py index aac6dc57e..7cc3437ca 100644 --- a/backend/src/baserow/contrib/database/fields/dependencies/handler.py +++ b/backend/src/baserow/contrib/database/fields/dependencies/handler.py @@ -105,7 +105,7 @@ class FieldDependencyHandler: return [] query_parameters = { - "pks": tuple(field_ids), + "pks": list(field_ids), "max_depth": settings.MAX_FIELD_REFERENCE_DEPTH, "table_id": table_id, "database_id": database_id_prefilter, @@ -117,11 +117,11 @@ class FieldDependencyHandler: if associated_relations_changed: associated_relations_changed_query = f""" OR ( - first.via_id IN %(pks)s - OR linkrowfield.link_row_related_field_id IN %(pks)s + first.via_id = ANY(%(pks)s) + OR linkrowfield.link_row_related_field_id = ANY(%(pks)s) ) AND NOT ( - first.dependant_id IN %(pks)s + first.dependant_id = ANY(%(pks)s) ) """ else: @@ -167,7 +167,7 @@ class FieldDependencyHandler: */ CASE WHEN ( - first.via_id IS NOT NULL + first.via_id IS DISTINCT FROM NULL AND ( dependant.table_id != %(table_id)s OR dependency.table_id = %(table_id)s @@ -186,7 +186,7 @@ class FieldDependencyHandler: LEFT OUTER JOIN {field_table} as dependency ON first.dependency_id = dependency.id WHERE - first.dependency_id IN %(pks)s + first.dependency_id = ANY(%(pks)s) {associated_relations_changed_query} -- LIMITING_FK_EDGES_CLAUSE_1 -- DISALLOWED_ANCESTORS_NODES_CLAUSE_1 diff --git a/backend/src/baserow/contrib/database/fields/field_converters.py b/backend/src/baserow/contrib/database/fields/field_converters.py index 5825bf430..c688ed1c7 100644 --- a/backend/src/baserow/contrib/database/fields/field_converters.py +++ b/backend/src/baserow/contrib/database/fields/field_converters.py @@ -2,12 +2,11 @@ from dataclasses import dataclass from django.db import models, transaction -from psycopg2 import sql - from baserow.contrib.database.db.schema import ( lenient_schema_editor, safe_django_schema_editor, ) +from baserow.core.psycopg import sql from .models import ( AutonumberField, diff --git a/backend/src/baserow/contrib/database/fields/fields.py b/backend/src/baserow/contrib/database/fields/fields.py index c0eb1ed16..aed29c9df 100644 --- a/backend/src/baserow/contrib/database/fields/fields.py +++ b/backend/src/baserow/contrib/database/fields/fields.py @@ -309,6 +309,9 @@ class DurationField(models.DurationField): value = duration_value_to_timedelta(value, self.duration_format) return super().get_prep_value(value) + def to_python(self, value): + return super().to_python(value) + class IntegerFieldWithSequence(models.IntegerField): """ diff --git a/backend/src/baserow/contrib/database/fields/handler.py b/backend/src/baserow/contrib/database/fields/handler.py index 270e4dfcb..89736208f 100644 --- a/backend/src/baserow/contrib/database/fields/handler.py +++ b/backend/src/baserow/contrib/database/fields/handler.py @@ -22,7 +22,6 @@ from django.db.utils import DatabaseError, DataError, ProgrammingError from loguru import logger from opentelemetry import trace -from psycopg2 import sql from baserow.contrib.database.db.schema import ( lenient_schema_editor, @@ -51,7 +50,7 @@ from baserow.contrib.database.fields.operations import ( ) from baserow.contrib.database.table.models import Table from baserow.contrib.database.views.handler import ViewHandler -from baserow.core.db import specific_iterator +from baserow.core.db import specific_iterator, sql from baserow.core.handler import CoreHandler from baserow.core.models import TrashEntry, User from baserow.core.telemetry.utils import baserow_trace_methods diff --git a/backend/src/baserow/contrib/database/fields/utils/duration.py b/backend/src/baserow/contrib/database/fields/utils/duration.py index ad0333026..3f6971d09 100644 --- a/backend/src/baserow/contrib/database/fields/utils/duration.py +++ b/backend/src/baserow/contrib/database/fields/utils/duration.py @@ -14,6 +14,8 @@ from django.db.models import ( ) from django.db.models.functions import Cast, Extract, Mod +from baserow.core.psycopg import is_psycopg3 + H_M = "h:mm" H_M_S = "h:mm:ss" H_M_S_S = "h:mm:ss.s" @@ -27,6 +29,7 @@ D_H_M_S_NO_COLONS = "d h mm ss" # 1d2h3m4s, 1h 2m MOST_ACCURATE_DURATION_FORMAT = H_M_S_SSS + if typing.TYPE_CHECKING: from baserow.contrib.database.fields.models import DurationField @@ -702,3 +705,20 @@ def text_value_sql_to_duration(field: "DurationField") -> str: ] args = [f"'{arg or 'NULL'}'" for arg in db_function_args] return f"br_text_to_interval(p_in, {','.join(args)});" + + +if is_psycopg3: + from psycopg.types.datetime import IntervalLoader # noqa: BRP001 + + from baserow.core.psycopg import psycopg + + class BaserowIntervalLoader(IntervalLoader): + """ + We're not doing anything special here, but if we don't register this + adapter tests will fail when parsing negative intervals. + """ + + def load(self, data): + return super().load(data) + + psycopg.adapters.register_loader("interval", BaserowIntervalLoader) diff --git a/backend/src/baserow/contrib/database/formula/expression_generator/django_expressions.py b/backend/src/baserow/contrib/database/formula/expression_generator/django_expressions.py index fdf963762..56f04aa4b 100644 --- a/backend/src/baserow/contrib/database/formula/expression_generator/django_expressions.py +++ b/backend/src/baserow/contrib/database/formula/expression_generator/django_expressions.py @@ -22,7 +22,7 @@ class BinaryOpExpr(Transform): class IsNullExpr(Transform): - template = "(%(expressions)s) IS NULL" + template = "(%(expressions)s) IS NOT DISTINCT FROM NULL" arity = 1 diff --git a/backend/src/baserow/contrib/database/migrations/0103_fix_datetimes_timezones.py b/backend/src/baserow/contrib/database/migrations/0103_fix_datetimes_timezones.py index 1aed2e129..7cd91affc 100644 --- a/backend/src/baserow/contrib/database/migrations/0103_fix_datetimes_timezones.py +++ b/backend/src/baserow/contrib/database/migrations/0103_fix_datetimes_timezones.py @@ -2,14 +2,13 @@ from django.db import connection, migrations -from psycopg2 import sql - from baserow.contrib.database.fields.models import ( CreatedOnField, DateField, FormulaField, LastModifiedField, ) +from baserow.core.psycopg import sql def forward(apps, schema_editor): diff --git a/backend/src/baserow/contrib/database/migrations/0128_remove_duplicate_viewfieldoptions.py b/backend/src/baserow/contrib/database/migrations/0128_remove_duplicate_viewfieldoptions.py index 9794754e1..dcea2e9dc 100644 --- a/backend/src/baserow/contrib/database/migrations/0128_remove_duplicate_viewfieldoptions.py +++ b/backend/src/baserow/contrib/database/migrations/0128_remove_duplicate_viewfieldoptions.py @@ -1,8 +1,7 @@ # Generated by Django 3.2.21 on 2023-09-19 08:11 - from django.db import connection, migrations -from psycopg2 import sql +from baserow.core.psycopg import sql def remove_duplicates(model, view): diff --git a/backend/src/baserow/contrib/database/migrations/0178_remove_singleselect_missing_options.py b/backend/src/baserow/contrib/database/migrations/0178_remove_singleselect_missing_options.py index 1fae25aa4..476804d65 100644 --- a/backend/src/baserow/contrib/database/migrations/0178_remove_singleselect_missing_options.py +++ b/backend/src/baserow/contrib/database/migrations/0178_remove_singleselect_missing_options.py @@ -1,7 +1,7 @@ from django.db import ProgrammingError, connection, migrations, transaction from django.db.models.expressions import F -from psycopg2 import sql +from baserow.core.psycopg import sql def forward(apps, schema_editor): diff --git a/backend/src/baserow/contrib/database/search/handler.py b/backend/src/baserow/contrib/database/search/handler.py index a1984b24e..88f4cda6d 100644 --- a/backend/src/baserow/contrib/database/search/handler.py +++ b/backend/src/baserow/contrib/database/search/handler.py @@ -13,7 +13,6 @@ from django.utils.encoding import force_str from loguru import logger from opentelemetry import trace -from psycopg2 import sql from redis.exceptions import LockNotOwnedError from baserow.contrib.database.db.schema import safe_django_schema_editor @@ -30,6 +29,7 @@ from baserow.contrib.database.table.cache import invalidate_table_in_model_cache from baserow.contrib.database.table.constants import ( ROW_NEEDS_BACKGROUND_UPDATE_COLUMN_NAME, ) +from baserow.core.psycopg import sql from baserow.core.telemetry.utils import baserow_trace_methods from baserow.core.utils import ChildProgressBuilder, exception_capturer diff --git a/backend/src/baserow/contrib/database/table/handler.py b/backend/src/baserow/contrib/database/table/handler.py index 7a3ce1d42..221d3e0fd 100644 --- a/backend/src/baserow/contrib/database/table/handler.py +++ b/backend/src/baserow/contrib/database/table/handler.py @@ -11,7 +11,6 @@ from django.utils import translation from django.utils.translation import gettext as _ from opentelemetry import trace -from psycopg2 import sql from baserow.contrib.database.db.schema import safe_django_schema_editor from baserow.contrib.database.fields.constants import RESERVED_BASEROW_FIELD_NAMES @@ -40,6 +39,7 @@ from baserow.contrib.database.views.handler import ViewHandler from baserow.contrib.database.views.models import View from baserow.contrib.database.views.view_types import GridViewType from baserow.core.handler import CoreHandler +from baserow.core.psycopg import sql from baserow.core.registries import ImportExportConfig, application_type_registry from baserow.core.telemetry.utils import baserow_trace_methods from baserow.core.trash.handler import TrashHandler diff --git a/backend/src/baserow/contrib/database/views/handler.py b/backend/src/baserow/contrib/database/views/handler.py index 191a0cecc..ae728fa1d 100644 --- a/backend/src/baserow/contrib/database/views/handler.py +++ b/backend/src/baserow/contrib/database/views/handler.py @@ -21,7 +21,6 @@ from django.db.models.query import QuerySet import jwt from loguru import logger from opentelemetry import trace -from psycopg2 import sql from redis.exceptions import LockNotOwnedError from baserow.contrib.database.api.utils import get_include_exclude_field_ids @@ -84,7 +83,7 @@ from baserow.contrib.database.views.registries import ( view_ownership_type_registry, ) from baserow.contrib.database.views.view_filter_groups import ViewGroupedFiltersAdapter -from baserow.core.db import specific_iterator, transaction_atomic +from baserow.core.db import specific_iterator, sql, transaction_atomic from baserow.core.exceptions import PermissionDenied from baserow.core.handler import CoreHandler from baserow.core.models import Workspace diff --git a/backend/src/baserow/core/auth_provider/models.py b/backend/src/baserow/core/auth_provider/models.py index 953a3b92d..c5b8df4a7 100644 --- a/backend/src/baserow/core/auth_provider/models.py +++ b/backend/src/baserow/core/auth_provider/models.py @@ -1,13 +1,12 @@ from django.contrib.contenttypes.models import ContentType from django.db import connection, models -from psycopg2 import sql - from baserow.core.mixins import ( CreatedAndUpdatedOnMixin, PolymorphicContentTypeMixin, WithRegistry, ) +from baserow.core.psycopg import sql class BaseAuthProviderModel( diff --git a/backend/src/baserow/core/db.py b/backend/src/baserow/core/db.py index d44b083b4..be5d95a84 100644 --- a/backend/src/baserow/core/db.py +++ b/backend/src/baserow/core/db.py @@ -26,7 +26,8 @@ from django.db.models.sql.query import LOOKUP_SEP from django.db.transaction import Atomic, get_connection from loguru import logger -from psycopg2 import sql + +from baserow.core.psycopg import sql from .utils import find_intermediate_order diff --git a/backend/src/baserow/core/management/backup/backup_runner.py b/backend/src/baserow/core/management/backup/backup_runner.py index 14671a748..8b61ea652 100644 --- a/backend/src/baserow/core/management/backup/backup_runner.py +++ b/backend/src/baserow/core/management/backup/backup_runner.py @@ -9,8 +9,6 @@ from datetime import datetime, timezone from pathlib import Path from typing import List, Optional -import psycopg2 - from baserow.contrib.database.fields.models import ( LinkRowField, MultipleCollaboratorsField, @@ -18,6 +16,7 @@ from baserow.contrib.database.fields.models import ( ) from baserow.contrib.database.table.constants import USER_TABLE_DATABASE_NAME_PREFIX from baserow.core.management.backup.exceptions import InvalidBaserowBackupArchive +from baserow.core.psycopg import psycopg NO_USER_TABLES_BACKUP_SUB_FOLDER = "everything_but_user_tables" @@ -156,7 +155,7 @@ class BaserowBackupRunner: return ["pg_restore"] + self._get_postgres_tool_args() + extra_command def _build_connection(self): - return psycopg2.connect( + return psycopg.connect( host=self.host, port=self.port, database=self.database, diff --git a/backend/src/baserow/core/psycopg.py b/backend/src/baserow/core/psycopg.py new file mode 100644 index 000000000..74a5765d4 --- /dev/null +++ b/backend/src/baserow/core/psycopg.py @@ -0,0 +1,8 @@ +from django.db.backends.postgresql.psycopg_any import is_psycopg3 + +if is_psycopg3: + import psycopg # noqa: F401 + from psycopg import sql # noqa: F401 +else: + import psycopg2 as psycopg # noqa: F401 + from psycopg2 import sql # noqa: F401 diff --git a/backend/src/baserow/core/telemetry/telemetry.py b/backend/src/baserow/core/telemetry/telemetry.py index 8b6be776d..6e2d22cf9 100644 --- a/backend/src/baserow/core/telemetry/telemetry.py +++ b/backend/src/baserow/core/telemetry/telemetry.py @@ -11,7 +11,6 @@ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExport from opentelemetry.instrumentation.botocore import BotocoreInstrumentor from opentelemetry.instrumentation.celery import CeleryInstrumentor from opentelemetry.instrumentation.django import DjangoInstrumentor -from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor from opentelemetry.instrumentation.redis import RedisInstrumentor from opentelemetry.instrumentation.requests import RequestsInstrumentor from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler @@ -20,9 +19,17 @@ from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics._internal.export import PeriodicExportingMetricReader from opentelemetry.trace import ProxyTracerProvider +from baserow.core.psycopg import is_psycopg3 from baserow.core.telemetry.provider import DifferentSamplerPerLibraryTracerProvider from baserow.core.telemetry.utils import BatchBaggageSpanProcessor, otel_is_enabled +if is_psycopg3: + from opentelemetry.instrumentation.psycopg import PsycopgInstrumentor +else: + from opentelemetry.instrumentation.psycopg2 import ( + Psycopg2Instrumentor as PsycopgInstrumentor, + ) + class LogGuruCompatibleLoggerHandler(LoggingHandler): def emit(self, record: logging.LogRecord) -> None: @@ -148,7 +155,7 @@ def _setup_celery_metrics(): def _setup_standard_backend_instrumentation(): BotocoreInstrumentor().instrument() - Psycopg2Instrumentor().instrument() + PsycopgInstrumentor().instrument() RedisInstrumentor().instrument() RequestsInstrumentor().instrument() CeleryInstrumentor().instrument() diff --git a/backend/src/baserow/test_utils/helpers.py b/backend/src/baserow/test_utils/helpers.py index ea13ea487..64fa1bb99 100644 --- a/backend/src/baserow/test_utils/helpers.py +++ b/backend/src/baserow/test_utils/helpers.py @@ -13,7 +13,6 @@ from django.contrib.auth.models import AbstractUser from django.db import connection from django.utils.dateparse import parse_date, parse_datetime -import psycopg2 from freezegun import freeze_time from pytest_unordered import unordered @@ -27,6 +26,7 @@ from baserow.contrib.database.rows.handler import RowHandler from baserow.core.action.models import Action from baserow.core.action.registries import ActionType from baserow.core.models import Workspace +from baserow.core.psycopg import psycopg User = get_user_model() @@ -508,9 +508,9 @@ def assert_undo_redo_actions_fails_with_error( @contextmanager def independent_test_db_connection(): d = connection.settings_dict - conn = psycopg2.connect( + conn = psycopg.connect( host=d["HOST"], - database=d["NAME"], + dbname=d["NAME"], user=d["USER"], password=d["PASSWORD"], port=d["PORT"], diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py old mode 100644 new mode 100755 index 6a8dbb7f0..e69de29bb --- a/backend/tests/__init__.py +++ b/backend/tests/__init__.py @@ -1,9 +0,0 @@ -from django.core.signals import setting_changed -from django.dispatch import receiver - -from cachalot.settings import cachalot_settings - - -@receiver(setting_changed) -def reload_settings(sender, **kwargs): - cachalot_settings.reload() diff --git a/backend/tests/baserow/contrib/database/field/test_duration_field_type.py b/backend/tests/baserow/contrib/database/field/test_duration_field_type.py index 17648eed4..3148d728c 100644 --- a/backend/tests/baserow/contrib/database/field/test_duration_field_type.py +++ b/backend/tests/baserow/contrib/database/field/test_duration_field_type.py @@ -467,10 +467,7 @@ def test_convert_duration_field_to_text_to_duration_field( row_1 = model.objects.first() updated_value = getattr(row_1, f"field_{field.id}") - # compare timedelta values - # assert updated_value == dest_value, ( # inital_value, ( - # input_format, input_value, dest_format, dest_value, updated_value, - # ) + if updated_value is not None: formatted = format_duration_value(updated_value, dest_format) else: diff --git a/backend/tests/baserow/contrib/database/field/test_field_single_select_options.py b/backend/tests/baserow/contrib/database/field/test_field_single_select_options.py index 5d2fca6bc..4cdd4f7a6 100644 --- a/backend/tests/baserow/contrib/database/field/test_field_single_select_options.py +++ b/backend/tests/baserow/contrib/database/field/test_field_single_select_options.py @@ -4,11 +4,11 @@ from django.db import connection from django.test import override_settings import pytest -from psycopg2 import sql from baserow.contrib.database.fields.handler import FieldHandler from baserow.contrib.database.fields.registries import field_type_registry from baserow.contrib.database.rows.handler import RowHandler +from baserow.core.psycopg import sql # @pytest.mark.disabled_in_ci # Disable this test in CI in next release. diff --git a/backend/tests/baserow/contrib/database/table/test_table_models.py b/backend/tests/baserow/contrib/database/table/test_table_models.py index de9054547..c21771fc2 100644 --- a/backend/tests/baserow/contrib/database/table/test_table_models.py +++ b/backend/tests/baserow/contrib/database/table/test_table_models.py @@ -7,10 +7,8 @@ from django.conf import settings from django.core.cache import caches from django.db import connection, models from django.db.models import Field -from django.test.utils import override_settings import pytest -from cachalot.settings import cachalot_settings from pytest_unordered import unordered from baserow.contrib.database.fields.exceptions import ( @@ -991,59 +989,65 @@ def test_table_hierarchy(data_fixture): assert row.get_root() == workspace -@override_settings(CACHALOT_ENABLED=True) -@pytest.mark.django_db(transaction=True) -def test_cachalot_cache_only_count_query_correctly(data_fixture): - user = data_fixture.create_user() - workspace = data_fixture.create_workspace(user=user) - app = data_fixture.create_database_application(workspace=workspace, name="Test 1") - table = data_fixture.create_database_table(name="Cars", database=app) - cache = caches[settings.CACHALOT_CACHE] +if settings.CACHALOT_ENABLED: + from cachalot.settings import cachalot_settings - queries = {} + @pytest.mark.django_db(transaction=True) + def test_cachalot_cache_only_count_query_correctly(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + app = data_fixture.create_database_application( + workspace=workspace, name="Test 1" + ) + table = data_fixture.create_database_table(name="Cars", database=app) + cache = caches[settings.CACHALOT_CACHE] - def get_mocked_query_cache_key(compiler): - sql, _ = compiler.as_sql() - sql_lower = sql.lower() - if "count(*)" in sql_lower: - key = "count" - elif f"database_table_{table.id}" in sql_lower: - key = "select_table" - else: - key = f"{time()}" - queries[key] = sql_lower - return key + queries = {} - cachalot_settings.CACHALOT_QUERY_KEYGEN = get_mocked_query_cache_key - cachalot_settings.CACHALOT_TABLE_KEYGEN = lambda _, table: table.rsplit("_", 1)[1] + def get_mocked_query_cache_key(compiler): + sql, _ = compiler.as_sql() + sql_lower = sql.lower() + if "count(*)" in sql_lower: + key = "count" + elif f"database_table_{table.id}" in sql_lower: + key = "select_table" + else: + key = f"{time()}" + queries[key] = sql_lower + return key - table_model = table.get_model() - row = table_model.objects.create() + cachalot_settings.CACHALOT_QUERY_KEYGEN = get_mocked_query_cache_key + cachalot_settings.CACHALOT_TABLE_KEYGEN = lambda _, table: table.rsplit("_", 1)[ + 1 + ] - # listing items should not cache the result - assert [r.id for r in table_model.objects.all()] == [row.id] - assert cache.get("select_table") is None, queries["select_table"] + table_model = table.get_model() + row = table_model.objects.create() - def assert_cachalot_cache_queryset_count_of(expected_count): - # count() should save the result of the query in the cache - assert table_model.objects.count() == expected_count + # listing items should not cache the result + assert [r.id for r in table_model.objects.all()] == [row.id] + assert cache.get("select_table") is None, queries["select_table"] - # the count query has been cached - inserted_cache_entry = cache.get("count") - assert inserted_cache_entry is not None - assert inserted_cache_entry[1][0] == expected_count + def assert_cachalot_cache_queryset_count_of(expected_count): + # count() should save the result of the query in the cache + assert table_model.objects.count() == expected_count - assert_cachalot_cache_queryset_count_of(1) + # the count query has been cached + inserted_cache_entry = cache.get("count") + assert inserted_cache_entry is not None + assert inserted_cache_entry[1][0] == expected_count - # creating a new row should invalidate the cache result - table_model.objects.create() + assert_cachalot_cache_queryset_count_of(1) - # cachalot invalidate the cache by setting the timestamp for the table - # greater than the timestamp of the cache entry - invalidation_timestamp = cache.get(table.id) - assert invalidation_timestamp > cache.get("count")[0] + # creating a new row should invalidate the cache result + table_model.objects.create() - assert_cachalot_cache_queryset_count_of(2) + # cachalot invalidate the cache by setting the timestamp for the table + # greater than the timestamp of the cache entry + invalidation_timestamp = cache.get(table.id) + assert invalidation_timestamp > cache.get("count")[0] + + assert_cachalot_cache_queryset_count_of(2) @pytest.mark.django_db diff --git a/backend/tests/baserow/contrib/database/test_cachalot.py b/backend/tests/baserow/contrib/database/test_cachalot.py index a6f7551c6..f7ce090e7 100644 --- a/backend/tests/baserow/contrib/database/test_cachalot.py +++ b/backend/tests/baserow/contrib/database/test_cachalot.py @@ -2,126 +2,133 @@ from time import time from django.conf import settings from django.core.cache import caches -from django.test import override_settings from django.urls import reverse import pytest -from cachalot.settings import cachalot_settings from baserow.contrib.database.fields.handler import FieldHandler from baserow.contrib.database.rows.handler import RowHandler from baserow.contrib.database.views.handler import ViewHandler from baserow.test_utils.helpers import AnyInt +if settings.CACHALOT_ENABLED: + """ + Cachalot cannot be activated in a fixture because once it patches the Django ORM, it + remains patched for the rest of the test suite. Since it's disabled by default and + we haven't been using it lately, nor have we tested it properly after the last + Django library update, we are disabling it for now. However, we can still enable it + with the CACHALOT_ENABLED setting whenever we want to test it. + """ -@override_settings(CACHALOT_ENABLED=True) -@pytest.mark.django_db(transaction=True) -def test_cachalot_cache_count_for_filtered_views(data_fixture): - user = data_fixture.create_user() - table_a, _, link_field = data_fixture.create_two_linked_tables(user=user) - cache = caches[settings.CACHALOT_CACHE] + from cachalot.settings import cachalot_settings - grid_view = data_fixture.create_grid_view(table=table_a) + @pytest.mark.django_db(transaction=True) + def test_cachalot_cache_count_for_filtered_views(data_fixture): + user = data_fixture.create_user() + table_a, _, link_field = data_fixture.create_two_linked_tables(user=user) + cache = caches[settings.CACHALOT_CACHE] - ViewHandler().create_filter( - user=user, - view=grid_view, - field=link_field, - type_name="link_row_has", - value="1", - ) + grid_view = data_fixture.create_grid_view(table=table_a) - queries = {} + ViewHandler().create_filter( + user=user, + view=grid_view, + field=link_field, + type_name="link_row_has", + value="1", + ) - def get_mocked_query_cache_key(compiler): - sql, _ = compiler.as_sql() - sql_lower = sql.lower() - if "count(*)" in sql_lower: - key = "count" - elif f"database_table_{table_a.id}" in sql_lower: - key = "select_table" - else: - key = f"{time()}" - queries[key] = sql_lower - return key + queries = {} - cachalot_settings.CACHALOT_QUERY_KEYGEN = get_mocked_query_cache_key - cachalot_settings.CACHALOT_TABLE_KEYGEN = lambda _, table: table.rsplit("_", 1)[1] + def get_mocked_query_cache_key(compiler): + sql, _ = compiler.as_sql() + sql_lower = sql.lower() + if "count(*)" in sql_lower: + key = "count" + elif f"database_table_{table_a.id}" in sql_lower: + key = "select_table" + else: + key = f"{time()}" + queries[key] = sql_lower + return key - table_model = table_a.get_model() - table_model.objects.create() - queryset = ViewHandler().get_queryset(view=grid_view) + cachalot_settings.CACHALOT_QUERY_KEYGEN = get_mocked_query_cache_key + cachalot_settings.CACHALOT_TABLE_KEYGEN = lambda _, table: table.rsplit("_", 1)[ + 1 + ] - def assert_cachalot_cache_queryset_count_of(expected_count): - # count() should save the result of the query in the cache - assert queryset.count() == expected_count + table_model = table_a.get_model() + table_model.objects.create() + queryset = ViewHandler().get_queryset(view=grid_view) - # the count query has been cached - inserted_cache_entry = cache.get("count") - assert inserted_cache_entry is not None - assert inserted_cache_entry[1][0] == expected_count + def assert_cachalot_cache_queryset_count_of(expected_count): + # count() should save the result of the query in the cache + assert queryset.count() == expected_count - assert_cachalot_cache_queryset_count_of(0) + # the count query has been cached + inserted_cache_entry = cache.get("count") + assert inserted_cache_entry is not None + assert inserted_cache_entry[1][0] == expected_count + assert_cachalot_cache_queryset_count_of(0) -@override_settings(CACHALOT_ENABLED=True) -@pytest.mark.django_db(transaction=True) -def test_cachalot_cache_multiple_select_correctly(api_client, data_fixture): - user, token = data_fixture.create_user_and_token() - database = data_fixture.create_database_application(user=user) - table = data_fixture.create_database_table(database=database) + @pytest.mark.django_db(transaction=True) + def test_cachalot_cache_multiple_select_correctly(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + database = data_fixture.create_database_application(user=user) + table = data_fixture.create_database_table(database=database) - field_handler = FieldHandler() - row_handler = RowHandler() - grid_view = data_fixture.create_grid_view(table=table) + field_handler = FieldHandler() + row_handler = RowHandler() + grid_view = data_fixture.create_grid_view(table=table) - field = field_handler.create_field( - user=user, - table=table, - name="Multiple select", - type_name="multiple_select", - select_options=[ - {"value": "Option 1", "color": "red"}, - {"value": "Option 2", "color": "blue"}, - {"value": "Option 3", "color": "orange"}, - {"value": "Option 4", "color": "black"}, - ], - ) + field = field_handler.create_field( + user=user, + table=table, + name="Multiple select", + type_name="multiple_select", + select_options=[ + {"value": "Option 1", "color": "red"}, + {"value": "Option 2", "color": "blue"}, + {"value": "Option 3", "color": "orange"}, + {"value": "Option 4", "color": "black"}, + ], + ) - select_options = field.select_options.all() - model = table.get_model() + select_options = field.select_options.all() + model = table.get_model() - rows = row_handler.create_rows( - user, - table, - rows_values=[ - {f"field_{field.id}": [select_options[0].id, select_options[1].value]}, - {f"field_{field.id}": [select_options[2].value, select_options[0].id]}, - ], - ) + rows = row_handler.create_rows( + user, + table, + rows_values=[ + {f"field_{field.id}": [select_options[0].id, select_options[1].value]}, + {f"field_{field.id}": [select_options[2].value, select_options[0].id]}, + ], + ) - url = reverse("api:database:views:grid:list", kwargs={"view_id": grid_view.id}) - response = api_client.get(url, **{"HTTP_AUTHORIZATION": f"JWT {token}"}) - response_json = response.json() - assert response_json["count"] == 2 - assert response_json["results"][0][f"field_{field.id}"] == [ - {"id": AnyInt(), "value": "Option 1", "color": "red"}, - {"id": AnyInt(), "value": "Option 2", "color": "blue"}, - ] + url = reverse("api:database:views:grid:list", kwargs={"view_id": grid_view.id}) + response = api_client.get(url, **{"HTTP_AUTHORIZATION": f"JWT {token}"}) + response_json = response.json() + assert response_json["count"] == 2 + assert response_json["results"][0][f"field_{field.id}"] == [ + {"id": AnyInt(), "value": "Option 1", "color": "red"}, + {"id": AnyInt(), "value": "Option 2", "color": "blue"}, + ] - row_handler.update_rows( - user, - table, - [ - {"id": rows[0].id, f"field_{field.id}": []}, - ], - model, - [rows[0]], - ) + row_handler.update_rows( + user, + table, + [ + {"id": rows[0].id, f"field_{field.id}": []}, + ], + model, + [rows[0]], + ) - # Before #1772 this would raise an error because the cache would not be correctly - # invalidated when updating a row so the old value would be returned. - response = api_client.get(url, **{"HTTP_AUTHORIZATION": f"JWT {token}"}) - response_json = response.json() - assert response_json["count"] == 2 - assert response_json["results"][0][f"field_{field.id}"] == [] + # Before #1772 this would raise an error because the cache would not be + # correctly invalidated when updating a row so the old value would be returned. + response = api_client.get(url, **{"HTTP_AUTHORIZATION": f"JWT {token}"}) + response_json = response.json() + assert response_json["count"] == 2 + assert response_json["results"][0][f"field_{field.id}"] == [] diff --git a/backend/tests/baserow/core/management/test_backup_runner.py b/backend/tests/baserow/core/management/test_backup_runner.py index 508b04562..a206242a4 100644 --- a/backend/tests/baserow/core/management/test_backup_runner.py +++ b/backend/tests/baserow/core/management/test_backup_runner.py @@ -11,6 +11,7 @@ from freezegun import freeze_time from baserow.contrib.database.table.models import Table from baserow.core.management.backup.backup_runner import BaserowBackupRunner from baserow.core.management.backup.exceptions import InvalidBaserowBackupArchive +from baserow.core.psycopg import is_psycopg3 from baserow.core.trash.handler import TrashHandler @@ -71,7 +72,7 @@ def test_can_backup_and_restore_baserow_reverting_changes(data_fixture, environ) @patch("tempfile.TemporaryDirectory") -@patch("psycopg2.connect") +@patch("psycopg.connect" if is_psycopg3 else "psycopg2.connect") @patch("subprocess.check_output") def test_backup_baserow_dumps_database_in_batches( mock_check_output, mock_connect, mock_tempfile, fs, environ @@ -141,7 +142,7 @@ def test_backup_baserow_dumps_database_in_batches( @patch("tempfile.TemporaryDirectory") -@patch("psycopg2.connect") +@patch("psycopg.connect" if is_psycopg3 else "psycopg2.connect") @patch("subprocess.check_output") def test_can_change_num_jobs_and_insert_extra_args_for_baserow_backup( mock_check_output, mock_connect, mock_tempfile, fs, environ @@ -226,7 +227,7 @@ def test_can_change_num_jobs_and_insert_extra_args_for_baserow_backup( @patch("tempfile.TemporaryDirectory") -@patch("psycopg2.connect") +@patch("psycopg.connect" if is_psycopg3 else "psycopg2.connect") @patch("subprocess.check_output") def test_backup_baserow_table_batches_includes_all_tables_when_final_batch_small( mock_check_output, mock_connect, mock_tempfile, fs, environ @@ -285,7 +286,7 @@ def test_backup_baserow_table_batches_includes_all_tables_when_final_batch_small @patch("tempfile.TemporaryDirectory") -@patch("psycopg2.connect") +@patch("psycopg.connect" if is_psycopg3 else "psycopg2.connect") @patch("subprocess.check_output") def test_backup_baserow_includes_all_tables_when_batch_size_matches_num_tables( mock_check_output, mock_connect, mock_tempfile, fs, environ @@ -336,7 +337,7 @@ def test_backup_baserow_includes_all_tables_when_batch_size_matches_num_tables( @patch("tempfile.TemporaryDirectory") -@patch("psycopg2.connect") +@patch("psycopg.connect" if is_psycopg3 else "psycopg2.connect") @patch("subprocess.check_output") def test_backup_baserow_does_no_table_batches_when_no_user_tables_found( mock_check_output, mock_connect, mock_tempfile, fs, environ diff --git a/plugin-boilerplate/{{ cookiecutter.project_slug }}/plugins/{{ cookiecutter.project_module }}/backend/requirements/dev.txt b/plugin-boilerplate/{{ cookiecutter.project_slug }}/plugins/{{ cookiecutter.project_module }}/backend/requirements/dev.txt index 38f646f6a..436552c58 100644 --- a/plugin-boilerplate/{{ cookiecutter.project_slug }}/plugins/{{ cookiecutter.project_module }}/backend/requirements/dev.txt +++ b/plugin-boilerplate/{{ cookiecutter.project_slug }}/plugins/{{ cookiecutter.project_module }}/backend/requirements/dev.txt @@ -170,6 +170,7 @@ deprecated==1.2.14 # via # opentelemetry-api # opentelemetry-exporter-otlp-proto-http + # opentelemetry-semantic-conventions distro==1.9.0 # via # anthropic @@ -456,7 +457,7 @@ openapi-spec-validator==0.5.6 # via -r dev.in openpyxl==3.1.5 # via -r /baserow/backend/requirements/base.in -opentelemetry-api==1.24.0 +opentelemetry-api==1.29.0 # via # -r /baserow/backend/requirements/base.in # opentelemetry-exporter-otlp-proto-http @@ -469,17 +470,18 @@ opentelemetry-api==1.24.0 # opentelemetry-instrumentation-django # opentelemetry-instrumentation-grpc # opentelemetry-instrumentation-logging - # opentelemetry-instrumentation-psycopg2 + # opentelemetry-instrumentation-psycopg # opentelemetry-instrumentation-redis # opentelemetry-instrumentation-requests # opentelemetry-instrumentation-wsgi # opentelemetry-propagator-aws-xray # opentelemetry-sdk -opentelemetry-exporter-otlp-proto-common==1.24.0 + # opentelemetry-semantic-conventions +opentelemetry-exporter-otlp-proto-common==1.29.0 # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.24.0 +opentelemetry-exporter-otlp-proto-http==1.29.0 # via -r /baserow/backend/requirements/base.in -opentelemetry-instrumentation==0.45b0 +opentelemetry-instrumentation==0.50b0 # via # -r /baserow/backend/requirements/base.in # opentelemetry-instrumentation-aiohttp-client @@ -490,53 +492,53 @@ opentelemetry-instrumentation==0.45b0 # opentelemetry-instrumentation-django # opentelemetry-instrumentation-grpc # opentelemetry-instrumentation-logging - # opentelemetry-instrumentation-psycopg2 + # opentelemetry-instrumentation-psycopg # opentelemetry-instrumentation-redis # opentelemetry-instrumentation-requests # opentelemetry-instrumentation-wsgi -opentelemetry-instrumentation-aiohttp-client==0.45b0 +opentelemetry-instrumentation-aiohttp-client==0.50b0 # via -r /baserow/backend/requirements/base.in -opentelemetry-instrumentation-asgi==0.45b0 +opentelemetry-instrumentation-asgi==0.50b0 # via -r /baserow/backend/requirements/base.in -opentelemetry-instrumentation-botocore==0.45b0 +opentelemetry-instrumentation-botocore==0.50b0 # via -r /baserow/backend/requirements/base.in -opentelemetry-instrumentation-celery==0.45b0 +opentelemetry-instrumentation-celery==0.50b0 # via -r /baserow/backend/requirements/base.in -opentelemetry-instrumentation-dbapi==0.45b0 +opentelemetry-instrumentation-dbapi==0.50b0 # via # -r /baserow/backend/requirements/base.in - # opentelemetry-instrumentation-psycopg2 -opentelemetry-instrumentation-django==0.45b0 + # opentelemetry-instrumentation-psycopg +opentelemetry-instrumentation-django==0.50b0 # via -r /baserow/backend/requirements/base.in -opentelemetry-instrumentation-grpc==0.45b0 +opentelemetry-instrumentation-grpc==0.50b0 # via -r /baserow/backend/requirements/base.in -opentelemetry-instrumentation-logging==0.45b0 +opentelemetry-instrumentation-logging==0.50b0 # via -r /baserow/backend/requirements/base.in -opentelemetry-instrumentation-psycopg2==0.45b0 +opentelemetry-instrumentation-psycopg==0.50b0 # via -r /baserow/backend/requirements/base.in -opentelemetry-instrumentation-redis==0.45b0 +opentelemetry-instrumentation-redis==0.50b0 # via -r /baserow/backend/requirements/base.in -opentelemetry-instrumentation-requests==0.45b0 +opentelemetry-instrumentation-requests==0.50b0 # via -r /baserow/backend/requirements/base.in -opentelemetry-instrumentation-wsgi==0.45b0 +opentelemetry-instrumentation-wsgi==0.50b0 # via # -r /baserow/backend/requirements/base.in # opentelemetry-instrumentation-django opentelemetry-propagator-aws-xray==1.0.1 # via opentelemetry-instrumentation-botocore -opentelemetry-proto==1.24.0 +opentelemetry-proto==1.29.0 # via # -r /baserow/backend/requirements/base.in # opentelemetry-exporter-otlp-proto-common # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.24.0 +opentelemetry-sdk==1.29.0 # via # -r /baserow/backend/requirements/base.in # opentelemetry-exporter-otlp-proto-http - # opentelemetry-instrumentation-grpc -opentelemetry-semantic-conventions==0.45b0 +opentelemetry-semantic-conventions==0.50b0 # via # -r /baserow/backend/requirements/base.in + # opentelemetry-instrumentation # opentelemetry-instrumentation-aiohttp-client # opentelemetry-instrumentation-asgi # opentelemetry-instrumentation-botocore @@ -548,7 +550,7 @@ opentelemetry-semantic-conventions==0.45b0 # opentelemetry-instrumentation-requests # opentelemetry-instrumentation-wsgi # opentelemetry-sdk -opentelemetry-util-http==0.45b0 +opentelemetry-util-http==0.50b0 # via # -r /baserow/backend/requirements/base.in # opentelemetry-instrumentation-aiohttp-client @@ -566,6 +568,7 @@ packaging==23.2 # huggingface-hub # langchain-core # marshmallow + # opentelemetry-instrumentation # pytest parso==0.8.4 # via jedi @@ -599,7 +602,7 @@ prosemirror @ https://github.com/fellowapp/prosemirror-py/archive/refs/tags/v0.3 # via -r /baserow/backend/requirements/base.in proto-plus==1.24.0 # via google-api-core -protobuf==4.25.4 +protobuf==5.29.2 # via # google-api-core # googleapis-common-protos @@ -607,7 +610,9 @@ protobuf==4.25.4 # proto-plus psutil==5.9.8 # via -r /baserow/backend/requirements/base.in -psycopg2==2.9.9 +psycopg==3.2.3 + # via -r /baserow/backend/requirements/base.in +psycopg-binary==3.2.3 # via -r /baserow/backend/requirements/base.in ptyprocess==0.7.0 # via pexpect @@ -867,6 +872,7 @@ typing-extensions==4.11.0 # openai # opentelemetry-sdk # prosemirror + # psycopg # pydantic # pydantic-core # sqlalchemy diff --git a/premium/backend/src/baserow_premium/migrations/0013_remove_duplicate_viewfieldoptions.py b/premium/backend/src/baserow_premium/migrations/0013_remove_duplicate_viewfieldoptions.py index ca64b0b82..f6f98570a 100644 --- a/premium/backend/src/baserow_premium/migrations/0013_remove_duplicate_viewfieldoptions.py +++ b/premium/backend/src/baserow_premium/migrations/0013_remove_duplicate_viewfieldoptions.py @@ -2,7 +2,7 @@ from django.db import connection, migrations -from psycopg2 import sql +from baserow.core.psycopg import sql def remove_duplicates(model, view):