Skip to content

Commit 6fb88d8

Browse files
Merge pull request #1306 from datajoint/empty_insert
Allow empty insert for tables with all defaults (#1280)
2 parents 9207d83 + 1cf37b8 commit 6fb88d8

File tree

3 files changed

+88
-7
lines changed

3 files changed

+88
-7
lines changed

src/datajoint/codecs.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ def __init_subclass__(cls, *, register: bool = True, **kwargs):
119119
_codec_registry[cls.name] = cls()
120120
logger.debug(f"Registered codec <{cls.name}> from {cls.__module__}.{cls.__name__}")
121121

122+
@abstractmethod
122123
def get_dtype(self, is_external: bool) -> str:
123124
"""
124125
Return the storage dtype for this codec.
@@ -136,12 +137,10 @@ def get_dtype(self, is_external: bool) -> str:
136137
137138
Raises
138139
------
139-
NotImplementedError
140-
If not overridden by subclass.
141140
DataJointError
142141
If external storage not supported but requested.
143142
"""
144-
raise NotImplementedError(f"Codec <{self.name}> must implement get_dtype()")
143+
...
145144

146145
@abstractmethod
147146
def encode(self, value: Any, *, key: dict | None = None, store_name: str | None = None) -> Any:

src/datajoint/table.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -704,10 +704,12 @@ def _insert_rows(self, rows, replace, skip_duplicates, ignore_extra_fields):
704704
rows = list(self.__make_row_to_insert(row, field_list, ignore_extra_fields) for row in rows)
705705
if rows:
706706
try:
707-
query = "{command} INTO {destination}(`{fields}`) VALUES {placeholders}{duplicate}".format(
707+
# Handle empty field_list (all-defaults insert)
708+
fields_clause = f"(`{'`,`'.join(field_list)}`)" if field_list else "()"
709+
query = "{command} INTO {destination}{fields} VALUES {placeholders}{duplicate}".format(
708710
command="REPLACE" if replace else "INSERT",
709711
destination=self.from_clause(),
710-
fields="`,`".join(field_list),
712+
fields=fields_clause,
711713
placeholders=",".join("(" + ",".join(row["placeholders"]) + ")" for row in rows),
712714
duplicate=(
713715
" ON DUPLICATE KEY UPDATE `{pk}`=`{pk}`".format(pk=self.primary_key[0]) if skip_duplicates else ""
@@ -1239,8 +1241,19 @@ def check_fields(fields):
12391241
if ignore_extra_fields:
12401242
attributes = [a for a in attributes if a is not None]
12411243

1242-
assert len(attributes), "Empty tuple"
1243-
row_to_insert = dict(zip(("names", "placeholders", "values"), zip(*attributes)))
1244+
if not attributes:
1245+
# Check if empty insert is allowed (all attributes have defaults)
1246+
required_attrs = [
1247+
attr.name
1248+
for attr in self.heading.attributes.values()
1249+
if not (attr.autoincrement or attr.nullable or attr.default is not None)
1250+
]
1251+
if required_attrs:
1252+
raise DataJointError(f"Cannot insert empty row. The following attributes require values: {required_attrs}")
1253+
# All attributes have defaults - allow empty insert
1254+
row_to_insert = {"names": (), "placeholders": (), "values": ()}
1255+
else:
1256+
row_to_insert = dict(zip(("names", "placeholders", "values"), zip(*attributes)))
12441257
if not field_list:
12451258
# first row sets the composition of the field list
12461259
field_list.extend(row_to_insert["names"])

tests/integration/test_insert.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,3 +438,72 @@ def test_validation_result_summary_truncated(self, schema_insert):
438438
result = dj.ValidationResult(is_valid=False, errors=errors, rows_checked=20)
439439
summary = result.summary()
440440
assert "and 10 more errors" in summary
441+
442+
443+
class AllDefaultsTable(dj.Manual):
444+
"""Table where all attributes have defaults."""
445+
446+
definition = """
447+
id : int auto_increment
448+
---
449+
timestamp=CURRENT_TIMESTAMP : datetime
450+
notes=null : varchar(200)
451+
"""
452+
453+
454+
class TestEmptyInsert:
455+
"""Tests for inserting empty dicts (GitHub issue #1280)."""
456+
457+
@pytest.fixture
458+
def schema_empty_insert(self, connection_test, prefix):
459+
schema = dj.Schema(
460+
prefix + "_empty_insert_test",
461+
context=dict(AllDefaultsTable=AllDefaultsTable, SimpleTable=SimpleTable),
462+
connection=connection_test,
463+
)
464+
schema(AllDefaultsTable)
465+
schema(SimpleTable)
466+
yield schema
467+
schema.drop()
468+
469+
def test_empty_insert_all_defaults(self, schema_empty_insert):
470+
"""Test that empty insert succeeds when all attributes have defaults."""
471+
table = AllDefaultsTable()
472+
assert len(table) == 0
473+
474+
# Insert empty dict - should use all defaults
475+
table.insert1({})
476+
assert len(table) == 1
477+
478+
# Check that values were populated with defaults
479+
row = table.fetch1()
480+
assert row["id"] == 1 # auto_increment starts at 1
481+
assert row["timestamp"] is not None # CURRENT_TIMESTAMP
482+
assert row["notes"] is None # nullable defaults to NULL
483+
484+
def test_empty_insert_multiple(self, schema_empty_insert):
485+
"""Test inserting multiple empty dicts."""
486+
table = AllDefaultsTable()
487+
488+
# Insert multiple empty dicts
489+
table.insert([{}, {}, {}])
490+
assert len(table) == 3
491+
492+
# Each should have unique auto_increment id
493+
ids = set(table.to_arrays("id"))
494+
assert ids == {1, 2, 3}
495+
496+
def test_empty_insert_required_fields_error(self, schema_empty_insert):
497+
"""Test that empty insert raises clear error when fields are required."""
498+
table = SimpleTable()
499+
500+
# SimpleTable has required fields (id, value)
501+
with pytest.raises(dj.DataJointError) as exc_info:
502+
table.insert1({})
503+
504+
error_msg = str(exc_info.value)
505+
assert "Cannot insert empty row" in error_msg
506+
assert "require values" in error_msg
507+
# Should list the required attributes
508+
assert "id" in error_msg
509+
assert "value" in error_msg

0 commit comments

Comments
 (0)