Skip to content

Commit cfd6918

Browse files
Merge branch 'master' into fix/traversal-foundations-2.3.3
2 parents 006d06d + 210276b commit cfd6918

11 files changed

Lines changed: 486 additions & 24 deletions

File tree

‎.github/workflows/test.yaml‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,38 @@ jobs:
6868

6969
- name: Run unit tests
7070
run: pixi run -e test pytest tests/unit -v
71+
72+
# Windows unit tests: guard OS-specific behavior (path separators, etc.) that
73+
# the Linux jobs above cannot catch — e.g. #1520, where file-protocol paths
74+
# rendered with native backslashes broke garbage collection on Windows.
75+
# pixi targets linux/osx only (see [tool.pixi] platforms), so this leg uses
76+
# pip. Unit tests need no containers, so no Docker/DB is required on Windows.
77+
unit-tests-windows:
78+
runs-on: windows-latest
79+
strategy:
80+
fail-fast: false
81+
matrix:
82+
# Exercise both ends of the supported range (requires-python >=3.10,<3.15).
83+
python-version: ["3.10", "3.14"]
84+
name: unit-tests-windows (py${{ matrix.python-version }})
85+
steps:
86+
- uses: actions/checkout@v4
87+
with:
88+
fetch-depth: 0 # hatch-vcs derives the version from git history/tags
89+
90+
- name: Set up Python
91+
uses: actions/setup-python@v5
92+
with:
93+
python-version: ${{ matrix.python-version }}
94+
95+
# NOTE: pip resolves `[project.optional-dependencies].test`, which is a
96+
# DIFFERENT set than the pixi Linux legs' `[dependency-groups].test`
97+
# (e.g. it omits graphviz). A unit test importing a dep present in only
98+
# one set would then pass on Linux but error on Windows (or vice versa) —
99+
# a dep-set mismatch that reads like an OS bug. Keep the two `test` sets
100+
# in sync when unit-test dependencies change.
101+
- name: Install package with test extras
102+
run: pip install -e ".[test]"
103+
104+
- name: Run unit tests
105+
run: pytest tests/unit -v

‎src/datajoint/adapters/base.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,22 @@ def supports_inline_indexes(self) -> bool:
617617
"""
618618
return True # Default for MySQL, override in PostgreSQL
619619

620+
@property
621+
def auto_indexes_foreign_keys(self) -> bool:
622+
"""
623+
Whether this backend implicitly indexes a foreign key's referencing
624+
(child) columns as part of enforcing the constraint.
625+
626+
MySQL/InnoDB does; PostgreSQL does not (and offers no server setting to
627+
make it), so DataJoint must emit an explicit index on that backend.
628+
629+
Returns
630+
-------
631+
bool
632+
True for MySQL, False for PostgreSQL.
633+
"""
634+
return True # Default for MySQL, override in PostgreSQL
635+
620636
def create_index_ddl(
621637
self,
622638
full_table_name: str,

‎src/datajoint/adapters/postgres.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,14 @@ def supports_inline_indexes(self) -> bool:
720720
"""
721721
return False
722722

723+
@property
724+
def auto_indexes_foreign_keys(self) -> bool:
725+
"""
726+
PostgreSQL never indexes a foreign key's referencing columns
727+
automatically, so DataJoint emits an explicit (coverage-aware) index.
728+
"""
729+
return False
730+
723731
# =========================================================================
724732
# Introspection
725733
# =========================================================================

‎src/datajoint/declare.py‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ def compile_foreign_key(
190190
index_sql: list[str],
191191
adapter,
192192
fk_attribute_map: dict[str, tuple[str, str]] | None = None,
193+
fk_index_candidates: list[list[str]] | None = None,
193194
) -> None:
194195
"""
195196
Parse a foreign key line and update declaration components.
@@ -215,6 +216,11 @@ def compile_foreign_key(
215216
Database adapter for backend-specific SQL generation.
216217
fk_attribute_map : dict, optional
217218
Mapping of ``child_attr -> (parent_table, parent_attr)``. Updated in place.
219+
fk_index_candidates : list, optional
220+
PostgreSQL only. Collects each non-unique foreign key's columns as a
221+
candidate supporting index; the redundancy/coverage decision is made
222+
post-parse (once the full primary key and index list are known).
223+
Updated in place.
218224
219225
Raises
220226
------
@@ -301,10 +307,22 @@ def compile_foreign_key(
301307
f"FOREIGN KEY ({fk_cols}) REFERENCES {ref_table_name} ({pk_cols}) ON UPDATE CASCADE ON DELETE RESTRICT"
302308
)
303309

304-
# declare unique index
310+
# Supporting index on the foreign-key columns.
311+
#
312+
# The `unique` option is a uniqueness constraint, always emitted, on every
313+
# backend. For the non-unique case: MySQL/InnoDB indexes every foreign key
314+
# implicitly, but PostgreSQL never indexes the referencing (child) columns
315+
# and offers no server setting to make it (see #1512), so DataJoint must.
316+
# Whether that index is *redundant* (the columns are already a left-prefix of
317+
# the primary key or of another declared index) can only be decided once the
318+
# whole definition is parsed, so here we merely record the candidate; the
319+
# coverage decision happens post-parse in `prepare_declare`.
320+
fk_attrs = list(ref.primary_key)
305321
if is_unique:
306-
index_cols = ", ".join(adapter.quote_identifier(attr) for attr in ref.primary_key)
322+
index_cols = ", ".join(adapter.quote_identifier(attr) for attr in fk_attrs)
307323
index_sql.append(f"UNIQUE INDEX ({index_cols})")
324+
elif not adapter.auto_indexes_foreign_keys and fk_index_candidates is not None:
325+
fk_index_candidates.append(fk_attrs)
308326

309327

310328
def prepare_declare(
@@ -351,6 +369,7 @@ def prepare_declare(
351369
external_stores = []
352370
fk_attribute_map = {} # child_attr -> (parent_table, parent_attr)
353371
column_comments = {} # column_name -> comment (for PostgreSQL COMMENT ON)
372+
fk_index_candidates = [] # PostgreSQL: FK column-lists that may need a support index (#1512)
354373

355374
for line in definition:
356375
if not line or line.startswith("#"): # ignore additional comments
@@ -368,6 +387,7 @@ def prepare_declare(
368387
index_sql,
369388
adapter,
370389
fk_attribute_map,
390+
fk_index_candidates,
371391
)
372392
elif re.match(r"^(unique\s+)?index\s*\(.*\)\s*(#.*)?$", line, re.I): # index
373393
compile_index(re.sub(r"\s*#.*$", "", line), index_sql, adapter)
@@ -383,6 +403,33 @@ def prepare_declare(
383403
if comment:
384404
column_comments[name] = comment
385405

406+
# Foreign-key support indexes (PostgreSQL; #1512). Now that the whole
407+
# definition is parsed, emit an index on each candidate foreign key's columns
408+
# UNLESS they are already a left-prefix of the primary key, of a declared
409+
# index, or of a longer FK-support index already emitted — in which case that
410+
# existing index already serves foreign-key lookups and cascades. Candidates
411+
# are only collected on PostgreSQL (MySQL/InnoDB indexes FKs implicitly), so
412+
# this loop is a no-op elsewhere.
413+
if fk_index_candidates:
414+
415+
def _index_columns(index_def: str) -> list[str]:
416+
match = re.match(r"(?:unique\s+)?index\s*\(([^)]+)\)", index_def, re.I)
417+
return [col.strip().strip('`"') for col in match.group(1).split(",")] if match else []
418+
419+
# Existing prefixes an FK index could be redundant against: the primary
420+
# key and every already-declared index. Grows as we accept FK indexes.
421+
existing_prefixes = [list(primary_key)] + [_index_columns(s) for s in index_sql]
422+
423+
def _covered(candidate: list[str]) -> bool:
424+
return any(prefix[: len(candidate)] == candidate for prefix in existing_prefixes)
425+
426+
# Longest first, so a shorter FK index left-covered by a longer one is skipped.
427+
for candidate in sorted(fk_index_candidates, key=len, reverse=True):
428+
if not _covered(candidate):
429+
index_cols = ", ".join(adapter.quote_identifier(attr) for attr in candidate)
430+
index_sql.append(f"INDEX ({index_cols})")
431+
existing_prefixes.append(candidate)
432+
386433
return (
387434
table_comment,
388435
primary_key,

‎src/datajoint/settings.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,7 @@ def get_store_spec(self, store: str | None = None, *, use_filepath_default: bool
466466
# Define required and allowed keys by protocol
467467
required_keys: dict[str, tuple[str, ...]] = {
468468
"file": ("protocol", "location"),
469-
"s3": ("protocol", "endpoint", "bucket", "access_key", "secret_key", "location"),
469+
"s3": ("protocol", "endpoint", "bucket", "location"),
470470
"gcs": ("protocol", "bucket", "location"),
471471
"azure": ("protocol", "container", "location"),
472472
}

‎src/datajoint/storage.py‎

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,27 @@ def is_url(path: str) -> bool:
4545
return path.lower().startswith(URL_PROTOCOLS)
4646

4747

48+
def _path_to_file_url(resolved_path: Path | PurePosixPath) -> str:
49+
"""
50+
Convert an already-resolved absolute path to a ``file://`` URL.
51+
52+
Uses ``as_posix()`` so the same logic handles both POSIX paths (which
53+
already start with ``/``) and Windows paths (``C:/...``, no leading
54+
slash) without OS-specific branching.
55+
56+
Parameters
57+
----------
58+
resolved_path : Path or PurePosixPath
59+
Absolute, already-resolved path.
60+
61+
Returns
62+
-------
63+
str
64+
``file://`` URL.
65+
"""
66+
return f"file:///{resolved_path.as_posix().lstrip('/')}"
67+
68+
4869
def normalize_to_url(path: str) -> str:
4970
"""
5071
Normalize a path to URL form.
@@ -72,15 +93,7 @@ def normalize_to_url(path: str) -> str:
7293
"""
7394
if is_url(path):
7495
return path
75-
# Convert local path to file:// URL
76-
# Ensure absolute path and proper format
77-
abs_path = str(Path(path).resolve())
78-
# Handle Windows paths (C:\...) vs Unix paths (/...)
79-
if abs_path.startswith("/"):
80-
return f"file://{abs_path}"
81-
else:
82-
# Windows: file:///C:/path
83-
return f"file:///{abs_path.replace(chr(92), '/')}"
96+
return _path_to_file_url(Path(path).resolve())
8497

8598

8699
def parse_url(url: str) -> tuple[str, str]:
@@ -327,10 +340,21 @@ def _validate_spec(self):
327340
if location and not Path(location).is_dir():
328341
raise FileNotFoundError(f"Inaccessible local directory {location}")
329342
elif self.protocol == "s3":
330-
required = ["endpoint", "bucket", "access_key", "secret_key"]
343+
required = ["endpoint", "bucket"]
331344
missing = [k for k in required if not self.spec.get(k)]
332345
if missing:
333346
raise errors.DataJointError(f"Missing S3 configuration: {', '.join(missing)}")
347+
# access_key/secret_key are optional: when both are absent the
348+
# underlying botocore credential chain resolves an ambient identity
349+
# (instance profile, IRSA, ECS task role, SSO), matching gcs/azure.
350+
# But botocore treats exactly one as a partial credential and fails
351+
# late (PartialCredentialsError at first access), so reject that here
352+
# with a clear message.
353+
if bool(self.spec.get("access_key")) != bool(self.spec.get("secret_key")):
354+
raise errors.DataJointError(
355+
"Incomplete S3 credentials: set both access_key and secret_key, "
356+
"or neither to use ambient AWS credentials."
357+
)
334358

335359
@property
336360
def fs(self) -> fsspec.AbstractFileSystem:
@@ -363,10 +387,14 @@ def _create_filesystem(self) -> fsspec.AbstractFileSystem:
363387
else:
364388
endpoint_url = endpoint
365389

390+
# Coerce falsy (missing or empty-string) credentials to None so s3fs
391+
# drops them and botocore falls through to the default chain. A
392+
# forwarded "" is NOT equivalent: it survives s3fs's None-filter and
393+
# botocore reads it as an explicit (invalid) credential.
366394
return fsspec.filesystem(
367395
"s3",
368-
key=self.spec["access_key"],
369-
secret=self.spec["secret_key"],
396+
key=self.spec.get("access_key") or None,
397+
secret=self.spec.get("secret_key") or None,
370398
client_kwargs={"endpoint_url": endpoint_url},
371399
)
372400

@@ -418,7 +446,7 @@ def _full_path(self, path: str | PurePosixPath) -> str:
418446
elif self.protocol == "file":
419447
location = self.spec.get("location", "")
420448
if location:
421-
return str(Path(location) / path)
449+
return (Path(location) / path).as_posix()
422450
return path
423451
else:
424452
return self._require_adapter().full_path(self.spec, path)
@@ -453,13 +481,7 @@ def get_url(self, path: str | PurePosixPath) -> str:
453481
full_path = self._full_path(path)
454482

455483
if self.protocol == "file":
456-
# Ensure absolute path for file:// URL
457-
abs_path = str(Path(full_path).resolve())
458-
if abs_path.startswith("/"):
459-
return f"file://{abs_path}"
460-
else:
461-
# Windows path
462-
return f"file:///{abs_path.replace(chr(92), '/')}"
484+
return _path_to_file_url(Path(full_path).resolve())
463485
elif self.protocol == "s3":
464486
return f"s3://{full_path}"
465487
elif self.protocol == "gcs":

0 commit comments

Comments
 (0)