Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 18 additions & 16 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,17 @@ pytest tests/

## Running Tests

Tests use [testcontainers](https://testcontainers.com/) to automatically manage MySQL, PostgreSQL, and MinIO containers. No manual `docker-compose up` required.
Tests use [testcontainers](https://testcontainers.com/) to automatically manage PostgreSQL, MySQL, and MinIO containers. No manual `docker-compose up` required.

Integration tests are **backend-parameterized** — tests using the `backend` fixture run automatically against both MySQL and PostgreSQL.
Integration tests are **backend-parameterized** — tests using the `backend` fixture run automatically against both PostgreSQL and MySQL.

```bash
pixi run -e test test # All tests (both backends)
pixi run -e test test-cov # With coverage
pixi run -e test pytest tests/unit/ # Unit tests only
pixi run -e test pytest tests/integration/test_blob.py -v # Specific file
pixi run -e test pytest -m mysql # MySQL tests only
pixi run -e test pytest -m postgresql # PostgreSQL tests only
pixi run -e test pytest -m mysql # MySQL tests only
```

**macOS Docker Desktop users:** If tests fail to connect:
Expand All @@ -62,18 +62,18 @@ DataJoint supports MySQL 8.0.13+ and PostgreSQL 15+ as production database backe
pip install -e ".[postgres]" # Installs psycopg2-binary
```

Tests automatically spin up both MySQL and PostgreSQL containers via testcontainers. Backend-parameterized tests (those using the `backend` fixture in `tests/conftest.py`) run against both backends to ensure feature parity.
Tests automatically spin up both PostgreSQL and MySQL containers via testcontainers. Backend-parameterized tests (those using the `backend` fixture in `tests/conftest.py`) run against both backends to ensure feature parity. PostgreSQL is the default backend (`database.backend`), so fixtures and tests that need MySQL select it explicitly.

### External Containers (for debugging)

```bash
# MySQL + MinIO
docker compose up -d db minio
# PostgreSQL + MySQL + MinIO
docker compose up -d postgres db minio
DJ_USE_EXTERNAL_CONTAINERS=1 pixi run -e test test
docker compose down

# MySQL + PostgreSQL + MinIO
docker compose up -d db postgres minio
# MySQL + MinIO
docker compose up -d db minio
DJ_USE_EXTERNAL_CONTAINERS=1 pixi run -e test test
docker compose down
```
Expand Down Expand Up @@ -111,6 +111,15 @@ Hooks include: **ruff** (lint/format), **codespell**, YAML/JSON/TOML validation.

For `DJ_USE_EXTERNAL_CONTAINERS=1`:

### PostgreSQL

| Variable | Default | Description |
|----------|---------|-------------|
| `DJ_PG_HOST` | `localhost` | PostgreSQL hostname |
| `DJ_PG_PORT` | `5432` | PostgreSQL port |
| `DJ_PG_USER` | `postgres` | PostgreSQL username |
| `DJ_PG_PASS` | `password` | PostgreSQL password |

### MySQL

| Variable | Default | Description |
Expand All @@ -120,14 +129,7 @@ For `DJ_USE_EXTERNAL_CONTAINERS=1`:
| `DJ_USER` | `root` | MySQL username |
| `DJ_PASS` | `password` | MySQL password |

### PostgreSQL

| Variable | Default | Description |
|----------|---------|-------------|
| `DJ_PG_HOST` | `localhost` | PostgreSQL hostname |
| `DJ_PG_PORT` | `5432` | PostgreSQL port |
| `DJ_PG_USER` | `postgres` | PostgreSQL username |
| `DJ_PG_PASS` | `password` | PostgreSQL password |
The test suite reads `DJ_HOST`, `DJ_PORT`, `DJ_USER`, and `DJ_PASS` as the MySQL server. Outside the tests, these variables configure whichever backend `DJ_BACKEND` selects, PostgreSQL by default.

### Object Storage

Expand Down
47 changes: 26 additions & 21 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -1,29 +1,19 @@
# Development environment with MySQL and MinIO services
# Development environment with PostgreSQL, MySQL, and MinIO services
#
# NOTE: docker-compose is OPTIONAL for running tests.
# Tests use testcontainers to automatically manage containers.
# Just run: pytest tests/
#
# Use docker-compose for development/debugging when you want
# persistent containers that survive test runs:
# docker compose up -d db minio # Start services manually
# pytest tests/ # Tests will use these containers
# docker compose up -d postgres db minio # Start services manually
# DJ_USE_EXTERNAL_CONTAINERS=1 pytest tests/ # Tests will use these containers
#
# PostgreSQL is DataJoint's default backend. The MySQL service is `db`.
#
# Full Docker testing (CI):
# docker compose --profile test up djtest --build
services:
db:
image: datajoint/mysql:${MYSQL_VER:-8.0}
environment:
- MYSQL_ROOT_PASSWORD=${DJ_PASS:-password}
command: mysqld --default-authentication-plugin=mysql_native_password
ports:
- "3306:3306"
healthcheck:
test: [ "CMD", "mysqladmin", "ping", "-h", "localhost" ]
timeout: 30s
retries: 5
interval: 15s
postgres:
image: postgres:${POSTGRES_VER:-15}
environment:
Expand All @@ -37,6 +27,18 @@ services:
timeout: 30s
retries: 5
interval: 15s
db:
image: datajoint/mysql:${MYSQL_VER:-8.0}
environment:
- MYSQL_ROOT_PASSWORD=${DJ_PASS:-password}
command: mysqld --default-authentication-plugin=mysql_native_password
ports:
- "3306:3306"
healthcheck:
test: [ "CMD", "mysqladmin", "ping", "-h", "localhost" ]
timeout: 30s
retries: 5
interval: 15s
minio:
image: minio/minio:${MINIO_VER:-RELEASE.2025-02-28T09-55-16Z}
environment:
Expand All @@ -63,23 +65,26 @@ services:
PY_VER: ${PY_VER:-3.10}
HOST_UID: ${HOST_UID:-1000}
depends_on:
db:
condition: service_healthy
postgres:
condition: service_healthy
db:
condition: service_healthy
minio:
condition: service_healthy
environment:
- DJ_PG_HOST=postgres
- DJ_PG_USER=postgres
- DJ_PG_PASS=password
- DJ_PG_PORT=5432
# DJ_HOST, DJ_USER, and DJ_PASS name the MySQL service, which the test suite
# reads as its MySQL server, so DJ_BACKEND selects the matching backend.
- DJ_BACKEND=mysql
- DJ_HOST=db
- DJ_USER=root
- DJ_PASS=password
- DJ_TEST_HOST=db
- DJ_TEST_USER=datajoint
- DJ_TEST_PASSWORD=datajoint
- DJ_PG_HOST=postgres
- DJ_PG_USER=postgres
- DJ_PG_PASS=password
- DJ_PG_PORT=5432
- S3_ENDPOINT=minio:9000
- S3_ACCESS_KEY=datajoint
- S3_SECRET_KEY=datajoint
Expand Down
13 changes: 9 additions & 4 deletions src/datajoint/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@

Usage::

# Start REPL with database credentials
dj --user root --password secret --host localhost:3306
# Start REPL with database credentials (PostgreSQL unless DJ_BACKEND=mysql)
dj --user postgres --password secret --host localhost:5432

# Load schemas as virtual modules
dj -s my_lab:lab -s my_analysis:analysis
Expand Down Expand Up @@ -41,7 +41,8 @@ def cli(args: list[str] | None = None) -> None:
--------
From the command line::

$ dj --host localhost:3306 --user root --password secret
$ dj --host localhost:5432 --user postgres --password secret
$ DJ_BACKEND=mysql dj --host localhost:3306 --user root --password secret
$ dj -s my_lab:lab -s my_analysis:analysis

Programmatically::
Expand All @@ -52,7 +53,11 @@ def cli(args: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(
prog="dj",
description="DataJoint interactive console. Start a Python REPL with DataJoint pre-loaded.",
epilog="Example: dj -s my_lab:lab --host localhost:3306",
epilog=(
"The database backend is PostgreSQL unless database.backend is set in config; "
"for a MySQL server, set DJ_BACKEND=mysql. "
"Example: dj -s my_lab:lab --host localhost:5432"
),
)
parser.add_argument(
"-V",
Expand Down
46 changes: 35 additions & 11 deletions src/datajoint/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@

cache_key = "query_cache" # the key to lookup the query_cache folder in dj.config

# Added to a failed connection whose backend came from the default rather than from the
# configuration, since a MySQL server reached with the default backend fails this way.
DEFAULT_BACKEND_HINT = (
"The default backend is postgresql since DataJoint 2.4; "
"set database.backend='mysql' (DJ_BACKEND=mysql) for a MySQL server."
)


def translate_query_error(client_error: Exception, query: str, adapter) -> Exception:
"""
Expand Down Expand Up @@ -163,6 +170,16 @@ class Connection:
Port number. Overridden if specified in host.
use_tls : bool or dict, optional
TLS encryption option.
backend : str, optional
Database backend: ``"postgresql"`` or ``"mysql"``. Default from
``database.backend`` in config, which is ``"postgresql"`` unless set.

Raises
------
DataJointError
If the connection fails while the backend is the default rather than one
set by the ``backend`` argument or in config. The message adds how to
select the MySQL backend.

Attributes
----------
Expand Down Expand Up @@ -211,20 +228,27 @@ def __init__(
self._query_cache = None
self._is_closed = True # Mark as closed until connect() succeeds

# Select adapter: explicit backend > config backend
# Select adapter: explicit backend > config backend. When neither names one,
# the backend is the default and a failure to connect says how to change it.
backend_is_default = backend is None and "backend" not in self._config.database.model_fields_set
if backend is None:
backend = self._config["database.backend"]
self.adapter = get_adapter(backend)

if database_name and self.adapter.backend == "mysql":
warnings.warn(
"database.name is set but the MySQL backend does not support database selection. "
"This setting only applies to PostgreSQL connections.",
UserWarning,
stacklevel=2,
)
try:
self.adapter = get_adapter(backend)

if database_name and self.adapter.backend == "mysql":
warnings.warn(
"database.name is set but the MySQL backend does not support database selection. "
"This setting only applies to PostgreSQL connections.",
UserWarning,
stacklevel=2,
)

self.connect()
self.connect()
except Exception as error:
if backend_is_default:
raise errors.DataJointError(f"{str(error).rstrip()}\n{DEFAULT_BACKEND_HINT}") from error
raise
if self.is_connected:
db = self.conn_info.get("database_name")
db_str = f"/{db}" if db else ""
Expand Down
9 changes: 5 additions & 4 deletions src/datajoint/instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,12 @@ class Instance:
password : str
Database password.
port : int, optional
Database port. Defaults to 3306 for MySQL, 5432 for PostgreSQL.
Database port. Defaults to 5432 for PostgreSQL, 3306 for MySQL.
use_tls : bool or dict, optional
TLS configuration.
backend : str, optional
Database backend: ``"mysql"`` or ``"postgresql"``. Default from config.
Database backend: ``"postgresql"`` or ``"mysql"``. Default from config,
which is ``"postgresql"`` unless set.
**kwargs : Any
Additional config overrides applied to this instance's config.

Expand Down Expand Up @@ -112,14 +113,14 @@ def __init__(
if port is None:
port = self.config.database.port

# Create connection with this instance's config and backend
# Create connection with this instance's config. The backend comes from that
# config, which records whether it was set or left at the default.
self.connection = Connection(
host,
user,
password,
port,
use_tls,
backend=self.config.database.backend,
config_override=self.config,
)

Expand Down
41 changes: 30 additions & 11 deletions src/datajoint/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@
>>> import datajoint as dj
>>> dj.config.database.host
'localhost'
>>> dj.config.database.backend
'mysql'
>>> dj.config.database.port # Auto-detects: 3306 for MySQL, 5432 for PostgreSQL
3306
>>> dj.config.database.backend # PostgreSQL is the default; set 'mysql' for a MySQL server
'postgresql'
>>> dj.config.database.port # Follows the backend unless set: 5432 for PostgreSQL, 3306 for MySQL
5432
>>> with dj.config.override(safemode=False):
... # dangerous operations here
... pass
Expand Down Expand Up @@ -199,11 +199,15 @@ class DatabaseSettings(BaseSettings):
user: str | None = Field(default=None, validation_alias="DJ_USER")
password: SecretStr | None = Field(default=None, validation_alias="DJ_PASS")
backend: Literal["mysql", "postgresql"] = Field(
default="mysql",
default="postgresql",
validation_alias="DJ_BACKEND",
description="Database backend: 'mysql' or 'postgresql'",
description="Database backend: 'postgresql' (default) or 'mysql'",
)
port: int | None = Field(
default=None,
validation_alias="DJ_PORT",
description="Database port. Defaults to the backend's port: 5432 for PostgreSQL, 3306 for MySQL.",
)
port: int | None = Field(default=None, validation_alias="DJ_PORT")
name: str | None = Field(
default=None,
validation_alias="DJ_DATABASE_NAME",
Expand All @@ -225,9 +229,19 @@ class DatabaseSettings(BaseSettings):

@model_validator(mode="after")
def set_default_port_from_backend(self) -> "DatabaseSettings":
"""Set default port based on backend if not explicitly provided."""
"""
Use the backend's default port unless a port was set explicitly.

The validator also runs when a setting is assigned, so a port that was
not set follows later changes of the backend (for example
``backend: "mysql"`` in ``datajoint.json``). Setting the port to None
returns it to the backend's default.
"""
if self.port is None:
self.port = 5432 if self.backend == "postgresql" else 3306
self.model_fields_set.discard("port")
if "port" not in self.model_fields_set:
# Write the derived port directly so that it is not recorded as set explicitly.
self.__dict__["port"] = 5432 if self.backend == "postgresql" else 3306
return self


Expand Down Expand Up @@ -939,6 +953,9 @@ def save_template(
Set ``DJ_IGNORE_CONFIG_FILE=true`` to skip both ``datajoint.json`` and
the secrets directory entirely (env-var-only configuration).

The template selects the PostgreSQL backend on port 5432. For a MySQL
server, set ``backend`` to ``"mysql"`` and ``port`` to 3306.

Parameters
----------
path : str or Path, optional
Expand Down Expand Up @@ -973,15 +990,17 @@ def save_template(
if minimal:
template = {
"database": {
"backend": "postgresql",
"host": "localhost",
"port": 3306,
"port": 5432,
},
}
else:
template = {
"database": {
"backend": "postgresql",
"host": "localhost",
"port": 3306,
"port": 5432,
"reconnect": True,
"use_tls": None,
},
Expand Down
Loading
Loading