Skip to content

Commit 86e3038

Browse files
Merge branch 'master' into docs/remove-changelog-archive
2 parents c71388a + a00b89f commit 86e3038

12 files changed

Lines changed: 390 additions & 30 deletions

File tree

‎src/datajoint/builtin_codecs/filepath.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ def encode(
164164

165165
return {
166166
"path": path,
167-
"store": store_name,
167+
"store": config.resolve_store_name(store_name, use_filepath_default=True),
168168
"size": size,
169169
"is_dir": is_dir,
170170
"timestamp": datetime.now(timezone.utc).isoformat(),

‎src/datajoint/builtin_codecs/npy.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ def encode(
356356
# Return metadata (includes numpy-specific shape/dtype)
357357
return {
358358
"path": path,
359-
"store": store_name,
359+
"store": config.resolve_store_name(store_name),
360360
"dtype": str(value.dtype),
361361
"shape": list(value.shape),
362362
}

‎src/datajoint/builtin_codecs/object.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ def encode(
168168
timestamp = datetime.now(timezone.utc)
169169
metadata = {
170170
"path": path,
171-
"store": store_name,
171+
"store": config.resolve_store_name(store_name),
172172
"size": size,
173173
"ext": ext,
174174
"is_dir": is_dir,

‎src/datajoint/builtin_codecs/schema.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def encode(self, value, *, key=None, context=None, store_name=None):
6666
)
6767
backend = self._get_backend(store_name, config=config)
6868
backend.put_buffer(serialize(value), path)
69-
return {"path": path, "store": store_name, ...}
69+
return {"path": path, "store": config.resolve_store_name(store_name), ...}
7070
7171
def decode(self, stored, *, key=None, context=None):
7272
config = self._codec_config(key, context)

‎src/datajoint/declare.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ def compile_foreign_key(
203203
adapter,
204204
fk_attribute_map: dict[str, tuple[str, str]] | None = None,
205205
fk_index_candidates: list[list[str]] | None = None,
206+
column_comments: dict[str, str] | None = None,
206207
) -> None:
207208
"""
208209
Parse a foreign key line and update declaration components.
@@ -233,6 +234,11 @@ def compile_foreign_key(
233234
candidate supporting index; the redundancy/coverage decision is made
234235
post-parse (once the full primary key and index list are known).
235236
Updated in place.
237+
column_comments : dict, optional
238+
Mapping of ``column -> comment`` for backends that set column comments
239+
out of line (PostgreSQL's ``COMMENT ON COLUMN``). Each copied column's
240+
comment, with its core-type marker (e.g. ``:int32:``), is added so the
241+
child keeps the parent's declared type. Updated in place.
236242
237243
Raises
238244
------
@@ -288,14 +294,17 @@ def compile_foreign_key(
288294
# Enum type names start with "enum_" (generated hash-based names)
289295
if sql_type.startswith("enum_") and adapter.backend == "postgresql":
290296
sql_type = f"{adapter.quote_identifier(ref.database)}.{adapter.quote_identifier(sql_type)}"
297+
comment = parent_attr.sql_comment
291298
col_def = adapter.format_column_definition(
292299
name=attr,
293300
sql_type=sql_type,
294301
nullable=is_nullable,
295302
default=None,
296-
comment=parent_attr.sql_comment,
303+
comment=comment,
297304
)
298305
attr_sql.append(col_def)
306+
if comment and column_comments is not None:
307+
column_comments[attr] = comment
299308

300309
# Track FK attribute mapping for lineage: child_attr -> (parent_table, parent_attr)
301310
if fk_attribute_map is not None:
@@ -459,6 +468,7 @@ def add_attribute(line: str, in_key: bool) -> None:
459468
adapter,
460469
fk_attribute_map,
461470
fk_index_candidates,
471+
column_comments,
462472
)
463473
elif re.match(r"^(unique\s+)?index\s*\(.*\)\s*(#.*)?$", line, re.I): # index
464474
compile_index(re.sub(r"\s*#.*$", "", line), index_sql, adapter)

‎src/datajoint/gc.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,14 @@ def __init__(self, *schemas: "Schema", store: str | None = None, config=None) ->
113113
if not schemas:
114114
raise DataJointError("At least one schema must be provided")
115115
self.schemas = schemas
116-
self.store = store
116+
# An empty name (the bare ``@`` form) means the default store, as None
117+
# does. Kept as "", it would match no reference in _references, and every
118+
# stored file would look orphaned.
119+
self.store = store or None
117120
self.config = config if config is not None else schemas[0].connection._config
121+
# References record the resolved store name. Metadata written before
122+
# that may record an empty name or none; both mean the default store.
123+
self._default_store = self.config.stores.get("default")
118124
# Resolve the store eagerly: validates it exists and pins the backend
119125
# and section prefixes for this collector's lifetime (one store only).
120126
self.backend = get_store_backend(store, config=self.config)
@@ -174,7 +180,7 @@ def _references(self, heading_attr: str, verbose: bool) -> set[str]:
174180
try:
175181
for row in table.proj(attr_name).cursor(as_dict=True):
176182
for path, ref_store in attr.codec.referenced_paths(row[attr_name]):
177-
if self.store is None or ref_store == self.store:
183+
if self.store is None or (ref_store or self._default_store) == self.store:
178184
referenced.add(path)
179185
except Exception as e:
180186
logger.warning(f"Error scanning {table_name}.{attr_name}: {e}")

‎src/datajoint/hash_registry.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ def put_hash(
250250
"hash": content_hash,
251251
"path": path, # Always stored for retrieval
252252
"schema": schema_name,
253-
"store": store_name,
253+
"store": config.resolve_store_name(store_name),
254254
"size": len(data),
255255
}
256256

‎src/datajoint/heading.py‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,23 @@ def sql_comment(self) -> str:
127127
"""
128128
Return the full SQL comment including type markers.
129129
130+
A core type (``int32``, ``uuid``, ``float64``, ...) is recorded in the column
131+
comment as ``:type:`` so it can be reconstructed from the database; heading
132+
parsing strips the marker into ``original_type``. Restoring it here keeps the
133+
core type on columns copied by a foreign key.
134+
130135
Returns
131136
-------
132137
str
133-
Comment with optional ``:uuid:`` prefix.
138+
Comment with the core-type prefix, e.g. ``:int32:``, when there is one.
134139
"""
135-
# UUID info is stored in the comment for reconstruction
136-
return (":uuid:" if self.uuid else "") + (self.comment or "")
140+
if self.original_type:
141+
marker = f":{self.original_type}:"
142+
elif self.uuid:
143+
marker = ":uuid:"
144+
else:
145+
marker = ""
146+
return marker + (self.comment or "")
137147

138148
@property
139149
def sql(self) -> str:

‎src/datajoint/settings.py‎

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,58 @@ def convert_path(cls, v: Any) -> Path | None:
447447
return None
448448
return Path(v) if not isinstance(v, Path) else v
449449

450+
def resolve_store_name(self, store: str | None = None, *, use_filepath_default: bool = False) -> str:
451+
"""
452+
Name the configured store that a store reference resolves to.
453+
454+
An empty or missing name means the default store: ``stores.default``,
455+
or ``stores.filepath_default`` for filepath references. Codecs record
456+
the resolved name in their metadata, so a stored object stays tied to
457+
the store it was written to even if the default is later re-pointed.
458+
459+
Parameters
460+
----------
461+
store : str, optional
462+
Store name as declared. ``""`` (the bare ``@`` form) and None both
463+
mean the default store.
464+
use_filepath_default : bool, optional
465+
If True, resolve the default to stores.filepath_default instead of
466+
stores.default. Default: False.
467+
468+
Returns
469+
-------
470+
str
471+
Name of a key in ``stores``.
472+
473+
Raises
474+
------
475+
DataJointError
476+
If the default store is needed but not configured.
477+
"""
478+
if store:
479+
return store
480+
# An empty name is the bare ``@`` form (``<npy@>``, ``<object@>``, ...),
481+
# which the declaration keeps as "" so the codec can tell "store
482+
# requested" from "no store"; it means the default store.
483+
if use_filepath_default:
484+
# Filepath references use separate default (not part of OAS)
485+
if "filepath_default" not in self.stores:
486+
raise DataJointError(
487+
"stores.filepath_default is not configured. "
488+
"Set stores.filepath_default or specify store explicitly with <filepath@store>"
489+
)
490+
store = self.stores["filepath_default"]
491+
else:
492+
# Integrated storage (hash, schema) uses stores.default
493+
if "default" not in self.stores:
494+
raise DataJointError("stores.default is not configured")
495+
store = self.stores["default"]
496+
497+
if not isinstance(store, str):
498+
default_key = "filepath_default" if use_filepath_default else "default"
499+
raise DataJointError(f"stores.{default_key} must be a string")
500+
return store
501+
450502
def get_store_spec(self, store: str | None = None, *, use_filepath_default: bool = False) -> dict[str, Any]:
451503
"""
452504
Get configuration for a storage store.
@@ -470,25 +522,7 @@ def get_store_spec(self, store: str | None = None, *, use_filepath_default: bool
470522
DataJointError
471523
If store is not configured or has invalid config.
472524
"""
473-
# Handle default store
474-
if store is None:
475-
if use_filepath_default:
476-
# Filepath references use separate default (not part of OAS)
477-
if "filepath_default" not in self.stores:
478-
raise DataJointError(
479-
"stores.filepath_default is not configured. "
480-
"Set stores.filepath_default or specify store explicitly with <filepath@store>"
481-
)
482-
store = self.stores["filepath_default"]
483-
else:
484-
# Integrated storage (hash, schema) uses stores.default
485-
if "default" not in self.stores:
486-
raise DataJointError("stores.default is not configured")
487-
store = self.stores["default"]
488-
489-
if not isinstance(store, str):
490-
default_key = "filepath_default" if use_filepath_default else "default"
491-
raise DataJointError(f"stores.{default_key} must be a string")
525+
store = self.resolve_store_name(store, use_filepath_default=use_filepath_default)
492526

493527
# Check store exists
494528
if store not in self.stores:
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""
2+
A column copied by a foreign key keeps the parent's core type.
3+
4+
Core types (``int32``, ``int16``, ``uuid``, ...) are recorded in the column comment as
5+
``:type:``. A foreign key copies the parent's primary-key columns into the child; the copy
6+
must carry the same marker, or the child's heading reports the native SQL type and every
7+
table derived from it (a jobs table, a further child) inherits the loss.
8+
"""
9+
10+
import logging
11+
12+
import pytest
13+
14+
import datajoint as dj
15+
16+
17+
class _Records(logging.Handler):
18+
def __init__(self):
19+
super().__init__(level=logging.WARNING)
20+
self.messages = []
21+
22+
def emit(self, record):
23+
self.messages.append(record.getMessage())
24+
25+
26+
@pytest.mark.backend_agnostic
27+
def test_fk_columns_keep_core_types(connection_by_backend, backend, prefix):
28+
schema = dj.Schema(f"{prefix}_fk_core_types_{backend}", connection=connection_by_backend)
29+
try:
30+
31+
@schema
32+
class Subject(dj.Manual):
33+
definition = """
34+
subject_id : int32
35+
"""
36+
37+
@schema
38+
class Session(dj.Manual):
39+
definition = """
40+
-> Subject
41+
session_idx : int16
42+
"""
43+
44+
@schema
45+
class Summary(dj.Manual):
46+
definition = """
47+
-> Session
48+
---
49+
n : int32
50+
"""
51+
52+
assert Subject.heading["subject_id"].original_type == "int32"
53+
# inherited one level down
54+
assert Session.heading["subject_id"].original_type == "int32"
55+
assert Session.heading["session_idx"].original_type == "int16"
56+
# inherited two levels down
57+
assert Summary.heading["subject_id"].original_type == "int32"
58+
assert Summary.heading["session_idx"].original_type == "int16"
59+
# the comment text itself is unchanged
60+
assert Summary.heading["subject_id"].comment == Subject.heading["subject_id"].comment
61+
finally:
62+
schema.drop(prompt=False)
63+
64+
65+
@pytest.mark.backend_agnostic
66+
def test_fk_columns_keep_uuid(connection_by_backend, backend, prefix):
67+
schema = dj.Schema(f"{prefix}_fk_core_types_uuid_{backend}", connection=connection_by_backend)
68+
try:
69+
70+
@schema
71+
class Item(dj.Manual):
72+
definition = """
73+
item_id : uuid # identifier of the item
74+
"""
75+
76+
@schema
77+
class Tag(dj.Manual):
78+
definition = """
79+
-> Item
80+
tag : varchar(16)
81+
"""
82+
83+
assert Tag.heading["item_id"].uuid
84+
assert Tag.heading["item_id"].original_type == "uuid"
85+
assert Tag.heading["item_id"].comment == "identifier of the item"
86+
finally:
87+
schema.drop(prompt=False)
88+
89+
90+
@pytest.mark.backend_agnostic
91+
def test_jobs_table_keeps_inherited_core_types(connection_by_backend, backend, prefix):
92+
"""A jobs table built from inherited keys declares core types and warns about nothing."""
93+
schema = dj.Schema(f"{prefix}_fk_core_types_jobs_{backend}", connection=connection_by_backend)
94+
records = _Records()
95+
logger = logging.getLogger("datajoint")
96+
try:
97+
98+
@schema
99+
class Subject(dj.Manual):
100+
definition = """
101+
subject_id : int32
102+
"""
103+
104+
@schema
105+
class Session(dj.Manual):
106+
definition = """
107+
-> Subject
108+
session_idx : int16
109+
"""
110+
111+
@schema
112+
class Summary(dj.Computed):
113+
definition = """
114+
-> Session
115+
---
116+
n : int32
117+
"""
118+
119+
def make(self, key):
120+
self.insert1(dict(key, n=1))
121+
122+
logger.addHandler(records)
123+
jobs = Summary.jobs
124+
assert jobs.heading["subject_id"].original_type == "int32"
125+
assert jobs.heading["session_idx"].original_type == "int16"
126+
native = [m for m in records.messages if m.startswith("Native type")]
127+
assert native == [], native
128+
finally:
129+
logger.removeHandler(records)
130+
schema.drop(prompt=False)

0 commit comments

Comments
 (0)