Skip to content

fix: join correctness — cross join pairs, dtype promotion, null keys, empty tables - #27

Merged
dsfulf merged 8 commits into
mainfrom
bugfix/join-correctness
Mar 27, 2026
Merged

dsfulf merged 8 commits into
mainfrom
bugfix/join-correctness

Conversation

@dsfulf

@dsfulf dsfulf commented Mar 25, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Cross join pair generation: fix np.tile/np.repeat to produce correct Cartesian product pairs
  • Dtype promotion: use np.result_type to promote to highest precision (e.g. int32 + int64 → int64) instead of rejecting mismatched precisions
  • Null key handling: filter None/NaN/NaT from join keys before matching (SQL semantics: NULL != NULL), re-insert null-key left rows in left joins
  • Empty table joins: short-circuit with correct empty result + warning instead of ValueError
  • Cross join column precedence: left table wins on name conflicts (matches inner/left join behavior)
  • Left join null-fill: StringDType columns use StringDType(na_object=None) with None, float uses NaN, datetime/timedelta uses NaT, int/bool/bytes falls back to object with warning
  • Method placement: moved _left_join_indices from base Join to LeftJoin
  • Test reorganization: grouped all tests into classes by category (TestConstruction, TestJoins, TestGroupBy, etc.)
  • Comprehensive join tests: 50+ new tests covering all join types × operators × dtype combinations × null keys × empty tables
  • Docs: new "Null Handling & Dtype Behavior" section in joins.md documenting type promotion via np.result_type, null-fill semantics, and null key behavior

Test plan

  • pytest — all tests pass
  • ruff format + ruff check — clean
  • mypy --strict tafra — no new errors
  • Cross join pair correctness verified against manual Cartesian product
  • Null key filtering verified: inner join excludes, left join preserves left-side nulls
  • Empty table joins return correct schema with zero rows
  • Dtype promotion: int32×int64→int64, float32×float64→float64, StringDType×<U→StringDType

🤖 Generated with Claude Code

dsfulf and others added 3 commits March 25, 2026 14:19
… empty tables

- Move _left_join_indices from Join to LeftJoin (Fix 1)
- Fix cross join: use np.repeat for left side, np.tile for right (Fix 2)
- Fix cross join column precedence: left wins on shared columns (Fix 3)
- Dtype promotion via np.result_type for same-family key types (Fix 4)
- Raise ValueError on composite key space overflow (Fix 5)
- Allow empty table joins with warning and shortcut results (Fix 6)
- NULL != NULL in join keys: filter null rows before encoding (Fix 7)
- Cross join select uses list for ordering, ignores empty sides (Fix 8)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move all standalone test functions into organized test classes:
TestConstruction, TestSQL, TestDestructors, TestProperties,
TestColumnOps, TestDtypes, TestFormatting, TestIteration,
TestGroupBy, TestTransform, TestMap, TestUnion, TestSlice,
TestDatetime, TestCSV, TestStringDtype, TestInnerJoin,
TestLeftJoin, TestCrossJoin, TestChunking, TestMisc.

Add comprehensive join correctness tests verifying exact row
pairings, all 6 operators, dtype promotion, null key handling,
empty tables, select filtering, column precedence, and C vs
Python result equivalence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Covers dtype validation, np.result_type promotion, NULL != NULL semantics,
null-fill by dtype, type promotion in results, empty table joins, column
selection behavior, non-equality join performance, and row ordering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR focuses on improving join correctness and documented semantics in Tafra, especially for cross joins, null join keys, empty-table joins, and dtype compatibility/promotion, alongside a major expansion/reorganization of the test suite.

Changes:

  • Fix cross-join Cartesian product pairing logic and define left-precedence behavior on name conflicts.
  • Add join behavior for null keys and empty-table joins, plus expanded documentation of null/dtype semantics.
  • Reorganize and significantly expand join-related tests (and broader suite) for coverage across operators, dtypes, nulls, and empty inputs.

Reviewed changes

Copilot reviewed 2 out of 3 changed files in this pull request and generated 1 comment.

File Description
test/test_tafra.py Reorganized tests into classes and added extensive new join/dtype/null/empty-table test coverage.
tafra/group.py Updated join implementations (null-key filtering, empty shortcuts, cross join pairing) and adjusted dtype validation logic.
docs/guide/joins.md Added a detailed “Null Handling & Dtype Behavior” section and updated left-join docs to reference it.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tafra/group.py Outdated
Comment on lines +926 to +930
# Check base type via _reduce_dtype (collapses str variants)
l_base = Tafra._reduce_dtype(l_dtype)
r_base = Tafra._reduce_dtype(r_dtype)
if l_base == r_base:
# Same base type (e.g. both 'str') — allow

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_validate_dtypes uses Tafra._reduce_dtype() to allow “same base type” before the numeric-promotion branch. Since _reduce_dtype('int32') == _reduce_dtype('int64') == 'int' (and similarly for float widths), this makes the np.result_type promotion block effectively unreachable for width mismatches. If the intent is to promote int/float precisions, the base-type shortcut should be limited to string-like dtypes (or use a reducer that only collapses string variants).

Suggested change
# Check base type via _reduce_dtype (collapses str variants)
l_base = Tafra._reduce_dtype(l_dtype)
r_base = Tafra._reduce_dtype(r_dtype)
if l_base == r_base:
# Same base type (e.g. both 'str') — allow
# Check base type via _reduce_dtype (intended to collapse str variants)
l_base = Tafra._reduce_dtype(l_dtype)
r_base = Tafra._reduce_dtype(r_dtype)
# Only treat string-like columns with the same reduced dtype as compatible
# without further checks; allow numeric types to fall through so they can
# be promoted via np.result_type when widths differ.
if l_base == r_base == 'str':
# Same base string type (e.g. 'str32' vs 'str64') — allow

Copilot uses AI. Check for mistakes.
_reduce_dtype collapses int32/int64 both to 'int', so the base-type
comparison skipped the np.result_type promotion block. Now only use
_reduce_dtype shortcut for string types where variant collapsing is
needed. Numeric types fall through to kind-based promotion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
_non_null_mask now returns early for non-nullable dtypes (int, uint, bool).
Join apply methods skip filtering/index-remapping when all keys are valid.
Recovers ~30-40% join performance regression from null-key handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 3 changed files in this pull request and generated 10 comments.

Comments suppressed due to low confidence (1)

tafra/group.py:505

  • Docstring for GroupSet._build_composite_key says it “Falls back to structured array if values would overflow int64”, but the implementation now raises ValueError instead. Update the docstring to reflect the new behavior (or reintroduce the structured-array fallback if that was still intended).
    def _build_composite_key(encoded: list[np.ndarray[Any, Any]]) -> np.ndarray[Any, Any]:
        """
        Combine multiple integer-coded columns into a single flat key.
        Uses positional encoding: key = c0 * N1*N2*... + c1 * N2*... + c2 * ...
        Falls back to structured array if values would overflow int64.
        """

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tafra/group.py Outdated
Comment on lines +942 to +961
continue

# Collapse string variants: StringDType, <U8, <U12 all reduce to 'str'
l_base = Tafra._reduce_dtype(l_dtype)
r_base = Tafra._reduce_dtype(r_dtype)
if l_base == r_base == "str":
# Both are string types — allow without promotion
continue

# Check numpy kind compatibility for numeric promotion
l_np = np.dtype(l_dtype)
r_np = np.dtype(r_dtype)
if l_np.kind == r_np.kind:
# Same family (e.g. int32 vs int64) — promote via result_type
promoted = np.result_type(l_np, r_np)
promoted_label = Tafra._format_dtype(promoted)
l_table._data[l_column] = l_table._data[l_column].astype(promoted)
l_table._dtypes[l_column] = promoted_label
r_table._data[r_column] = r_table._data[r_column].astype(promoted)
r_table._dtypes[r_column] = promoted_label

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_validate_dtypes promotes numeric join-key dtypes by casting and updating _data/_dtypes on the input Tafra objects. This introduces a surprising side effect (joins mutating their inputs) and can affect subsequent operations on the original tables. Consider promoting/casting into temporary key arrays (or shallow copies of just the key columns) used for matching, and keep the original left_t/right_t unchanged.

Suggested change
continue
# Collapse string variants: StringDType, <U8, <U12 all reduce to 'str'
l_base = Tafra._reduce_dtype(l_dtype)
r_base = Tafra._reduce_dtype(r_dtype)
if l_base == r_base == "str":
# Both are string types — allow without promotion
continue
# Check numpy kind compatibility for numeric promotion
l_np = np.dtype(l_dtype)
r_np = np.dtype(r_dtype)
if l_np.kind == r_np.kind:
# Same family (e.g. int32 vs int64) — promote via result_type
promoted = np.result_type(l_np, r_np)
promoted_label = Tafra._format_dtype(promoted)
l_table._data[l_column] = l_table._data[l_column].astype(promoted)
l_table._dtypes[l_column] = promoted_label
r_table._data[r_column] = r_table._data[r_column].astype(promoted)
r_table._dtypes[r_column] = promoted_label
# Exact dtype match — nothing to validate further.
continue
# Collapse string variants: StringDType, <U8, <U12 all reduce to 'str'
l_base = Tafra._reduce_dtype(l_dtype)
r_base = Tafra._reduce_dtype(r_dtype)
if l_base == r_base == "str":
# Both are string types — allow without promotion or mutation.
continue
# Check numpy kind compatibility for non-string types
l_np = np.dtype(l_dtype)
r_np = np.dtype(r_dtype)
if l_np.kind == r_np.kind:
# Same family (e.g. int32 vs int64, float32 vs float64, etc.).
# Treat as compatible as long as there is a safe cast in at least
# one direction. Do not mutate the original Tafra tables here.
if not (
np.can_cast(l_np, r_np, casting="safe")
or np.can_cast(r_np, l_np, casting="safe")
):
raise TypeError(
f"This `Tafra` column `{l_column}` dtype `{l_dtype}` "
f"cannot be safely cast to or from other `Tafra` dtype "
f"`{r_dtype}`."
)

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py
kind = c.dtype.kind
if kind == "f":
valid &= ~np.isnan(c)
elif kind in ("M", "m"):

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_non_null_mask only treats None as null for object-dtype key columns, but the PR description/docs also claim NaN/NaT are filtered. For object arrays containing np.nan or np.datetime64('NaT'), those rows will currently be considered valid and may incorrectly match (especially with !=). Update the object-path to also exclude NaN and NaT values.

Suggested change
elif kind in ("M", "m"):
# Object-dtype: treat None, NaN, and NaT as nulls
def _is_valid_obj(x: Any) -> bool:
if x is None:
return False
# Treat NaN as null for numeric-like objects
if isinstance(x, (float, np.floating)):
try:
if np.isnan(x):
return False
except TypeError:
# Non-numeric objects may raise here; consider them non-null
pass
# Treat NaT as null for datetime/timedelta-like objects
if isinstance(x, (np.datetime64, np.timedelta64)):
if np.isnat(x):
return False
return True
valid &= np.fromiter((_is_valid_obj(x) for x in c), dtype=bool, count=n)

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py
Comment on lines 1292 to +1344
----------
left_t: Tafra
The left tafra to join.

right_t: Tafra
The right tafra to join.

Returns
-------
tafra: Tafra
The joined `Tafra`.
"""
# Empty table shortcut
if left_t._rows < 1:
warnings.warn(
"Join: one or both tables have zero rows. Returning shortcut result.", stacklevel=2
)
join_cols, dtypes = self._resolve_join_cols(left_t, right_t)
return Tafra(
{
c: np.array(
[],
dtype=left_t._data[c].dtype
if c in left_t._data
else right_t._data[c].dtype,
)
for c in join_cols
},
dtypes,
)
if right_t._rows < 1:
warnings.warn(
"Join: one or both tables have zero rows. Returning shortcut result.", stacklevel=2
)
join_cols, dtypes = self._resolve_join_cols(left_t, right_t)
n_left = left_t._rows
shortcut: dict[str, np.ndarray[Any, Any]] = {}
for c in join_cols:
if c in left_t._data:
shortcut[c] = left_t._data[c].copy()
else:
# Null-fill right columns
col_kind = right_t._data[c].dtype.kind
if col_kind in ("T", "U"):
out = np.empty(
n_left,
dtype=np.dtypes.StringDType(na_object=None), # type: ignore[call-arg]
)
out[:] = None # type: ignore[assignment]
dtypes[c] = "str"
elif col_kind == "f":
out = np.full(n_left, np.nan, dtype=right_t._data[c].dtype)
elif col_kind in ("M", "m"):

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The left-join empty-table shortcuts return before validating on columns/operators. That means invalid join specifications may not raise when left_t or right_t has zero rows. Consider performing the usual _validate(...)/_validate_dtypes(...)/_validate_ops(...) checks (at least for schema) before returning the shortcut result.

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py
Comment on lines 1461 to 1462
np.ndarray[Any, Any],
np.empty(len(li), dtype=object),

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue in the left-join row-by-row fallback: without explicit null filtering, null keys (e.g. NaN) can match many right rows for != and produce non-SQL semantics. Apply the same null-key exclusion logic used in the equi-join path (or otherwise treat any null comparison as non-matching) in this fallback loop.

Suggested change
left_val = left_t._data[left_col][i]
# Treat any null/NaN left key as non-matching for this row
is_left_null = left_val is None
if not is_left_null:
try:
# np.isnan handles float/np.nan; will raise for non-numerics
is_left_null = bool(np.isnan(left_val)) # type: ignore[arg-type]
except (TypeError, ValueError):
is_left_null = False
if is_left_null:
right_rows[:] = False
break
right_col_data = right_t._data[right_col]
# Compute null mask for right-hand join column and exclude nulls
try:
right_null_mask = np.isnan(right_col_data)
except (TypeError, ValueError):
right_null_mask = np.zeros(right_col_data.shape, dtype=bool)
cmp_result = op(left_val, right_col_data)
right_rows &= cmp_result & ~right_null_mask

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py
Comment on lines +1579 to 1589
else right_t._data[c].dtype,
)
for c in join_cols
},
dtypes,
)

self._validate_dtypes(left_t, right_t)

left_rows = left_t._rows
right_rows = right_t._rows

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CrossJoin.apply builds the result via Tafra(result) without passing the merged _dtypes metadata, so user-declared dtypes (and string handling conventions) may be lost compared to Inner/Left joins. Also, the current dict construction inserts right-only columns before left columns, which differs from Inner/Left join column ordering. Consider using _resolve_join_cols(...) to supply consistent dtypes, and build the result dict in left-then-right order while still letting left win on name conflicts.

Copilot uses AI. Check for mistakes.
Comment thread docs/guide/joins.md Outdated
Comment on lines +410 to +421
- If omitted, all unique column names are returned
- Column order follows the `select` list
- Nonexistent column names are silently ignored
- When a column name exists in both tables, the left table's version is used

```python
result = left.inner_join(
right,
on=[('k', 'k', '==')],
select=['k', 'info', 'v'], # order is preserved
)
print(result.columns) # ('k', 'info', 'v')

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs claim “Column order follows the select list”, but Inner/Left joins build join_cols by iterating table column order and filtering by membership (_resolve_join_cols), so the output column order is table-defined, not select-defined. Either update the implementation to honor select ordering or adjust this documentation and example to reflect current behavior.

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py
left_orig_idx = np.where(left_valid)[0]
right_orig_idx = np.where(right_valid)[0]
left_null_idx = np.where(~left_valid)[0]
else:

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ri_mapped = np.where(ri >= 0, right_orig_idx[ri], -1) can raise when right_orig_idx is empty (e.g. all right-side join keys are null so right_valid filters everything out) because right_orig_idx[ri] is evaluated even when all ri are -1. Build ri_mapped via masked assignment (initialize to -1, then fill only ri >= 0) to avoid indexing with -1 into an empty array.

Suggested change
else:
# Initialize all to -1, then fill only positions where ri >= 0
ri_mapped = np.full_like(ri, -1, dtype=np.intp)
nonneg_mask = ri >= 0
if np.any(nonneg_mask):
ri_mapped[nonneg_mask] = right_orig_idx[ri[nonneg_mask]]

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py
Comment on lines 1099 to +1116
Returns
-------
tafra: Tafra
The joined `Tafra`.
"""
# Empty table shortcut — inner join with either side empty → empty result
if left_t._rows < 1 or right_t._rows < 1:
warnings.warn(
"Join: one or both tables have zero rows. Returning shortcut result.", stacklevel=2
)
join_cols, dtypes = self._resolve_join_cols(left_t, right_t)
return Tafra(
{
c: np.array(
[],
dtype=left_t._data[c].dtype
if c in left_t._data
else right_t._data[c].dtype,

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The empty-table shortcut returns before validating on columns and operators. This can silently hide user errors (e.g., joining on a missing column) whenever either side has zero rows. Consider still running _validate(...), _validate_dtypes(...), and _validate_ops(...) before returning the shortcut result so schema mistakes are caught consistently.

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py
Comment on lines 1181 to 1189
)

result: dict[str, np.ndarray[Any, Any]] = {}
for c in join_cols:
if c in left_t._data:
result[c] = left_t._data[c][li]
else:
result[c] = right_t._data[c][ri]

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Null-key filtering is only applied in the all_equi fast path. In the row-by-row fallback, rows with NaN/NaT/None keys can still match (notably NaN != NaN evaluates True), which violates the stated SQL-like NULL semantics. The fallback path should explicitly exclude null-key rows from matching for all operators.

Copilot uses AI. Check for mistakes.
Comment thread docs/guide/joins.md Outdated
Comment on lines +269 to +278
| Left key dtype | Right key dtype | Allowed? | Result dtype |
|---|---|---|---|
| `StringDType` | `<U10` | Yes | `StringDType` |
| `<U8` | `<U12` | Yes | `<U12` |
| `int32` | `int64` | Yes (promoted) | `int64` |
| `float32` | `float64` | Yes (promoted) | `float64` |
| `int32` | `int32` | Yes | `int32` |
| `int64` | `float64` | No — `TypeError` | — |
| `int64` | `StringDType` | No — `TypeError` | — |
| `datetime64[ns]` | `datetime64[us]` | Yes | `datetime64[ns]` |

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The table implies <U8 + <U12 join keys yield a “Result dtype” of <U12, but the join implementation does not promote/cast string key columns (it only allows them to join). Depending on column naming/precedence, the output dtype may remain the left dtype. Consider clarifying what “Result dtype” refers to here (matching key encoding vs output column dtype), or adjust the statement to match actual behavior.

Copilot uses AI. Check for mistakes.
… accuracy

Performance:
- _non_null_mask returns None for non-nullable keys (no array allocation)
- frozenset moved to module-level constant
- matched array only allocated when has_null is True
- _validate_dtypes returns promotions dict, no input mutation

Correctness:
- _validate_dtypes no longer mutates caller's Tafra objects
- _non_null_mask handles NaN in object arrays (not just None)
- Empty table shortcuts validate column names + ops before returning
- ri_mapped uses safe indexing (np.full + masked assignment)
- CrossJoin passes dtypes metadata to output Tafra

Docs:
- select acts as filter, not ordering spec
- String dtypes keep originals (no promotion to <U12)
- datetime64 dtypes keep originals (no unit promotion)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 3 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tafra/group.py Outdated
Comment on lines +995 to +999
# Object arrays can hold None, NaN, and NaT simultaneously
valid &= np.array(
[x is not None and not (isinstance(x, float) and np.isnan(x)) for x in c],
dtype=bool,
)

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Join._non_null_mask doesn’t correctly detect nulls in object key columns: it only treats isinstance(x, float) and np.isnan(x) as null, which misses common cases like np.float64(np.nan) (np.floating) and np.datetime64('NaT')/np.timedelta64('NaT') stored in object arrays. This can allow NULL keys to participate in matches (e.g., NaT == NaT), violating the documented SQL-like semantics. Consider expanding the object-path predicate to handle np.floating NaNs and np.isnat(...) for datetime/timedelta scalars (and/or vectorize with np.asarray(c, dtype=...) where possible).

Suggested change
# Object arrays can hold None, NaN, and NaT simultaneously
valid &= np.array(
[x is not None and not (isinstance(x, float) and np.isnan(x)) for x in c],
dtype=bool,
)
# Object arrays can hold None, NaN (including np.floating),
# and NaT (np.datetime64/np.timedelta64) simultaneously.
def _is_object_null(x: Any) -> bool:
if x is None:
return True
# NaN in Python float or numpy.floating
if isinstance(x, (float, np.floating)) and np.isnan(x):
return True
# NaT for datetime64/timedelta64 scalars stored in object arrays
if isinstance(x, (np.datetime64, np.timedelta64)):
try:
if np.isnat(x):
return True
except TypeError:
# np.isnat may not accept some scalar-like objects; treat as non-null
pass
return False
valid &= np.array([not _is_object_null(x) for x in c], dtype=bool)

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py Outdated
Comment on lines 1639 to 1641
left_data = dict(left_t[left_cols].key_map(np.repeat, repeats=right_rows))
right_data = dict(right_t[right_cols].key_map(np.tile, reps=left_rows))

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CrossJoin select handling can produce an empty left_cols or right_cols (e.g., select=['only_right_col']). The current code then calls left_t[left_cols] / right_t[right_cols], which constructs a Tafra with no columns and fails in Tafra.__post_init__ (“No data provided…”). Either explicitly error with a clear message when one side selects zero columns, or support this case by skipping the empty-side selection and only repeating/tiling the non-empty side.

Suggested change
left_data = dict(left_t[left_cols].key_map(np.repeat, repeats=right_rows))
right_data = dict(right_t[right_cols].key_map(np.tile, reps=left_rows))
# Handle cases where select leaves one side with no columns:
# avoid constructing an empty-column Tafra, which would fail __post_init__.
if left_cols:
left_data = dict(
left_t[left_cols].key_map(np.repeat, repeats=right_rows)
)
else:
left_data = {}
if right_cols:
right_data = dict(
right_t[right_cols].key_map(np.tile, reps=left_rows)
)
else:
right_data = {}

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py Outdated
Comment on lines +1643 to +1647
result: dict[str, np.ndarray[Any, Any]] = {}
for c, v in right_data.items():
if c not in left_data:
result[c] = v
result.update(left_data)

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CrossJoin output column order is reversed: result is populated with (non-conflicting) right columns first, then result.update(left_data) appends left columns last. This contradicts the stated behavior elsewhere (left-table columns first, then right-table columns) and differs from update_inplace behavior used previously. Build the result dict starting from left_data and then append non-conflicting right_data to preserve left→right ordering.

Suggested change
result: dict[str, np.ndarray[Any, Any]] = {}
for c, v in right_data.items():
if c not in left_data:
result[c] = v
result.update(left_data)
# Start with left_data to preserve left→right column ordering, then append
# only non-conflicting right columns.
result: dict[str, np.ndarray[Any, Any]] = dict(left_data)
for c, v in right_data.items():
if c not in result:
result[c] = v

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py
Comment on lines +1379 to +1386
else:
out = cast(
np.ndarray[Any, Any],
np.empty(n_left, dtype=object),
)
out[:] = None # type: ignore[assignment]
dtypes[c] = "object"
shortcut[c] = out

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LeftJoin empty-right shortcut fills non-nullable right columns by casting to object and setting values to None, but it doesn’t emit the warning that the main left-join path emits when it has to cast to object due to missing native nulls. This makes the empty-table behavior inconsistent with non-empty joins and with the docs’ stated warning behavior. Consider warning here as well when falling back to object.

Copilot uses AI. Check for mistakes.
Comment thread docs/guide/joins.md Outdated
Comment on lines +263 to +289
- **Same-family numeric types are promoted**: `int32` vs `int64`, or
`float32` vs `float64`, are promoted to the higher precision type using
`np.result_type`. The narrower column is cast automatically.
- **Different families are rejected**: `int64` vs `float64`, or `int64` vs
`str`, raise `TypeError`.

| Left key dtype | Right key dtype | Allowed? | Result dtype |
|---|---|---|---|
| `StringDType` | `<U10` | Yes | both keep original dtypes |
| `<U8` | `<U12` | Yes | both keep original dtypes |
| `int32` | `int64` | Yes (promoted) | `int64` |
| `float32` | `float64` | Yes (promoted) | `float64` |
| `int32` | `int32` | Yes | `int32` |
| `int64` | `float64` | No — `TypeError` | — |
| `int64` | `StringDType` | No — `TypeError` | — |
| `datetime64[ns]` | `datetime64[us]` | Yes | both keep original dtypes |

Type promotion uses `np.result_type` under the hood — the same function numpy
uses to determine output dtypes for binary operations. Users familiar with
numpy's promotion rules will find no surprises here.

```python
# int32 key joins with int64 key — promoted automatically
left = Tafra({'k': np.array([1, 2], dtype=np.int32), 'v': np.array([10, 20])})
right = Tafra({'k': np.array([1, 2], dtype=np.int64), 'info': np.array(['a', 'b'])})
result = left.inner_join(right, on=[('k', 'k', '==')])
# Works — k promoted to int64

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The “Dtype validation on join keys” section says mixed-precision numeric keys are “promoted” and the “Result dtype” becomes the promoted dtype (e.g., int32 + int64 → int64). In the current implementation, the promotion is only used to compute temporary join keys; the output columns keep their original dtypes (and if the join key name is shared, the left column wins). Please clarify in the docs whether promotion affects only matching or also the resulting column dtypes, and adjust the table/examples accordingly.

Suggested change
- **Same-family numeric types are promoted**: `int32` vs `int64`, or
`float32` vs `float64`, are promoted to the higher precision type using
`np.result_type`. The narrower column is cast automatically.
- **Different families are rejected**: `int64` vs `float64`, or `int64` vs
`str`, raise `TypeError`.
| Left key dtype | Right key dtype | Allowed? | Result dtype |
|---|---|---|---|
| `StringDType` | `<U10` | Yes | both keep original dtypes |
| `<U8` | `<U12` | Yes | both keep original dtypes |
| `int32` | `int64` | Yes (promoted) | `int64` |
| `float32` | `float64` | Yes (promoted) | `float64` |
| `int32` | `int32` | Yes | `int32` |
| `int64` | `float64` | No — `TypeError` | — |
| `int64` | `StringDType` | No — `TypeError` | — |
| `datetime64[ns]` | `datetime64[us]` | Yes | both keep original dtypes |
Type promotion uses `np.result_type` under the hood — the same function numpy
uses to determine output dtypes for binary operations. Users familiar with
numpy's promotion rules will find no surprises here.
```python
# int32 key joins with int64 key — promoted automatically
left = Tafra({'k': np.array([1, 2], dtype=np.int32), 'v': np.array([10, 20])})
right = Tafra({'k': np.array([1, 2], dtype=np.int64), 'info': np.array(['a', 'b'])})
result = left.inner_join(right, on=[('k', 'k', '==')])
# Works — k promoted to int64
- **Same-family numeric types are promoted for matching**: `int32` vs
`int64`, or `float32` vs `float64`, are promoted to the higher precision
type using `np.result_type` to compute temporary join keys. The underlying
columns in the result keep their original dtypes; if the join key name is
shared, the left column (and its dtype) wins.
- **Different families are rejected**: `int64` vs `float64`, or `int64` vs
`str`, raise `TypeError`.
| Left key dtype | Right key dtype | Allowed? | Result dtype (output columns) |
|---|---|---|---|
| `StringDType` | `<U10` | Yes | both keep original dtypes |
| `<U8` | `<U12` | Yes | both keep original dtypes |
| `int32` | `int64` | Yes (promoted for matching) | both keep original dtypes; left wins on name clash |
| `float32` | `float64` | Yes (promoted for matching) | both keep original dtypes; left wins on name clash |
| `int32` | `int32` | Yes | `int32` (left wins on name clash) |
| `int64` | `float64` | No — `TypeError` | — |
| `int64` | `StringDType` | No — `TypeError` | — |
| `datetime64[ns]` | `datetime64[us]` | Yes | both keep original dtypes |
Type promotion uses `np.result_type` under the hood — the same function numpy
uses to determine output dtypes for binary operations. Promotion only affects
the temporary keys used for matching; the dtypes of the columns that appear
in the joined result are not changed.
```python
# int32 key joins with int64 key — promoted automatically for matching
left = Tafra({'k': np.array([1, 2], dtype=np.int32), 'v': np.array([10, 20])})
right = Tafra({'k': np.array([1, 2], dtype=np.int64), 'info': np.array(['a', 'b'])})
result = left.inner_join(right, on=[('k', 'k', '==')])
# Works — join keys are temporarily promoted, but result['k'] keeps the left dtype (int32)

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py Outdated
Comment on lines +964 to +965
promotions[l_column] = promoted
promotions[r_column] = promoted

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_validate_dtypes returns a single promotions dict keyed only by column name, but join conditions can reference the same column name on different sides across multiple predicates (e.g. on=[('a','b','=='), ('b','c','==')] where 'b' is right-side in one predicate and left-side in another). In that case promotions['b'] gets overwritten and the wrong dtype can be applied when building key arrays, producing incorrect matches. Consider returning separate promotion maps for left vs right (or keying promotions by (side, column)), and use those when casting left_cols_data / right_cols_data.

Suggested change
promotions[l_column] = promoted
promotions[r_column] = promoted
# Record promotions for each side, detecting conflicts when the
# same column name participates in multiple join predicates.
for col_name in (l_column, r_column):
existing = promotions.get(col_name)
if existing is None:
promotions[col_name] = promoted
elif existing != promoted:
raise TypeError(
"Inconsistent dtype promotion for join key "
f"column `{col_name}` across multiple join "
f"predicates: previously promoted to `{existing}` "
f"but now would promote to `{promoted}`."
)

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py
Comment on lines 503 to +545
@@ -473,6 +509,10 @@ def _build_composite_key(
if len(encoded) == 1:
return encoded[0]

# empty arrays: return empty int64
if len(encoded[0]) == 0:
return np.array([], dtype=np.int64)

# compute cardinality of each column
cards = [int(c.max()) + 1 for c in encoded]

@@ -499,13 +539,10 @@ def _build_composite_key(
multiplier *= card
return key
else:
# fallback: structured array (slow but handles any cardinality)
n_rows = len(encoded[0])
dt = np.dtype([(f'f{i}', c.dtype) for i, c in enumerate(encoded)])
data = np.empty(n_rows, dtype=dt)
for i, c in enumerate(encoded):
data[f'f{i}'] = c
return data
raise ValueError(
"Composite key space overflow: too many unique key "
"combinations for multi-column join"
)

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GroupSet._build_composite_key docstring says it “Falls back to structured array if values would overflow int64”, but the implementation now raises ValueError on overflow. This is both a behavioral regression (previously large multi-key groupby/join could still work via slower fallback) and a docs mismatch. Consider restoring the structured-array fallback (and bypassing the C accel path when the key isn’t int64) or updating the docstring and documenting the new hard limit.

Copilot uses AI. Check for mistakes.
- _non_null_mask: handle np.floating NaN and np.datetime64 NaT in object arrays
- _validate_dtypes: separate left/right promotion dicts to avoid key collision
- CrossJoin: left-first column order, handle empty select side, pass dtypes
- LeftJoin empty-right shortcut: emit warning when casting to object
- Non-equi join paths: add null key filtering (SQL NULL != NULL semantics)
- _build_composite_key: fix stale docstring (raises, not falls back)
- Docs: promotion affects matching only, not output column dtypes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…U from nullable

- Replace assert left_valid/right_valid with explicit np.ones mask synthesis
  for the non-nullable side — safe under python -O, handles mixed nullable/
  non-nullable key types
- Fix stacklevel=2 → 3 in empty-right shortcut object-cast warning
- Remove 'U' (fixed-width unicode) from _NULLABLE_KINDS — <U arrays cannot
  hold None, scanning them was a no-op

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@dsfulf
dsfulf merged commit 4ab4f8d into main Mar 27, 2026
7 checks passed
@dsfulf
dsfulf deleted the bugfix/join-correctness branch April 20, 2026 16:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants