Repository navigation
fix: join correctness — cross join pairs, dtype promotion, null keys, empty tables - #27
Conversation
… 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>
There was a problem hiding this comment.
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.
| # 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 |
There was a problem hiding this comment.
_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).
| # 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 |
_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>
There was a problem hiding this comment.
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_keysays it “Falls back to structured array if values would overflow int64”, but the implementation now raisesValueErrorinstead. 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.
| 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 |
There was a problem hiding this comment.
_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.
| 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}`." | |
| ) |
| kind = c.dtype.kind | ||
| if kind == "f": | ||
| valid &= ~np.isnan(c) | ||
| elif kind in ("M", "m"): |
There was a problem hiding this comment.
_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.
| 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) |
| ---------- | ||
| 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"): |
There was a problem hiding this comment.
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.
| np.ndarray[Any, Any], | ||
| np.empty(len(li), dtype=object), |
There was a problem hiding this comment.
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.
| 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 |
| 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 |
There was a problem hiding this comment.
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.
| - 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') |
There was a problem hiding this comment.
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.
| 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: |
There was a problem hiding this comment.
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.
| 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]] |
| 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, |
There was a problem hiding this comment.
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.
| ) | ||
|
|
||
| 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] | ||
|
|
There was a problem hiding this comment.
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.
| | 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]` | |
There was a problem hiding this comment.
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.
… 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>
There was a problem hiding this comment.
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.
| # 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, | ||
| ) |
There was a problem hiding this comment.
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).
| # 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) |
| 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)) | ||
|
|
There was a problem hiding this comment.
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.
| 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 = {} |
| 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) |
There was a problem hiding this comment.
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.
| 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 |
| else: | ||
| out = cast( | ||
| np.ndarray[Any, Any], | ||
| np.empty(n_left, dtype=object), | ||
| ) | ||
| out[:] = None # type: ignore[assignment] | ||
| dtypes[c] = "object" | ||
| shortcut[c] = out |
There was a problem hiding this comment.
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.
| - **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 |
There was a problem hiding this comment.
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.
| - **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) |
| promotions[l_column] = promoted | ||
| promotions[r_column] = promoted |
There was a problem hiding this comment.
_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.
| 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}`." | |
| ) |
| @@ -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" | |||
| ) | |||
There was a problem hiding this comment.
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.
- _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>
Summary
np.tile/np.repeatto produce correct Cartesian product pairsnp.result_typeto promote to highest precision (e.g.int32+int64→int64) instead of rejecting mismatched precisionsNone/NaN/NaTfrom join keys before matching (SQL semantics:NULL != NULL), re-insert null-key left rows in left joinsValueErrorStringDTypecolumns useStringDType(na_object=None)withNone, float usesNaN, datetime/timedelta usesNaT, int/bool/bytes falls back toobjectwith warning_left_join_indicesfrom baseJointoLeftJoinnp.result_type, null-fill semantics, and null key behaviorTest plan
pytest— all tests passruff format+ruff check— cleanmypy --strict tafra— no new errors🤖 Generated with Claude Code