Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.

---

## [Unreleased]

### Added

### Fixed

- **`SafeOperation` never actually recovered panics** — `recover()` was called inside a helper function invoked by the deferred closure, where Go defines it as a no-op, so a panic inside the wrapped function escaped to the caller — the exact failure mode the "no panics" design promise forbids. The recovery helper is now installed directly as the deferred function (`defer recoverFromPanic(op, &err)`) and is covered by tests that drive string, error, arbitrary-value, and genuine runtime panics through `SafeOperation`.

### Added

- **Column derivation & transforms** — `Apply(newColumn, fn)` derives a column from whole rows, `Map(src, dst, fn)` transforms one column element-wise, `FillNA(column, value)` replaces missing values, and `DropNA(columns...)` drops rows with missing values (all columns when none given). "Missing" means the column type's zero value, the library's existing CSV/JSONL convention — documented explicitly, since a legitimate 0 is indistinguishable from missing in an int column. Derived-column types are inferred from the returned values (all ints → `Int64Type`, int/float mix → `Float64Type`, uniform string/bool/time kept, other mixes fall back to `StringType` with canonical formatting; `nil` fills the zero value). An existing target column is replaced in place, receivers are never mutated, and a panic inside the user function is captured as an error on the returned frame instead of escaping.

- **Join operations** — `Join(other, on, how)` and `JoinOn(other, leftKeys, rightKeys, how)` with all four join types (`InnerJoin`, `LeftJoin`, `RightJoin`, `OuterJoin`). Hash-join implementation with typed fast paths for single int64/float64/string/bool/time keys and a length-prefixed tuple encoding for composite keys (the same collision-safe scheme GroupBy uses). Key columns appear once in the output under the left frame's name; colliding non-key columns are suffixed `_left`/`_right`. An int64 key may join a float64 key (matched in float64 space, output promoted to `Float64Type`); any other key-type mismatch is an error carried via `Error()`. A key equal to its column type's zero value is treated as missing and never matches, mirroring the CSV/JSONL missing-cell convention; unmatched sides zero-fill the same way. Output order is deterministic: left-frame row order (multi-matches expand in right-frame order), then unmatched right rows in right-frame order. A 100k×10k single-key join runs in ~3 ms with ~60 allocations.

---

## [1.0.8] — 2026-07-16

### Documentation
Expand Down
58 changes: 54 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,34 @@ ranked := df.SortBy(
)
```

### Joining DataFrames

```go
// Combine two frames on a shared key column
result := orders.Join(customers, []string{"customer_id"}, otters.InnerJoin)

// All four join types: InnerJoin, LeftJoin, RightJoin, OuterJoin
all := orders.Join(customers, []string{"customer_id"}, otters.LeftJoin)

// Keys named differently in each frame
res := employees.JoinOn(departments, []string{"dept_code"}, []string{"code"}, otters.InnerJoin)

// Composite keys join on the tuple of all key columns
byRegionYear := sales.Join(targets, []string{"region", "year"}, otters.InnerJoin)
```

Join semantics worth knowing:

- Key columns appear **once** in the output (under the left frame's name). Non-key
columns present in both frames are suffixed `_left` / `_right`.
- An `int64` key can join a `float64` key — matching happens in float64 space and
the output key column is promoted to `float64`. Any other type mismatch errors.
- A key equal to its type's zero value (`""`, `0`, `0.0`, `false`, zero time) is
treated as **missing** and never matches — consistent with how CSV/JSONL fill
missing cells. Unmatched sides are zero-filled the same way.
- Output order is deterministic: left-frame row order, then (for right/outer)
unmatched right rows in right-frame order.

### Aggregations and Statistics

```go
Expand All @@ -157,9 +185,22 @@ fmt.Println(summary)
### Data Transformation

```go
// Create new columns
df_with_bonus := df.Copy()
// Add 10% bonus calculation (implementation coming soon)
// Derive a column from the whole row
withBonus := df.Apply("bonus", func(row map[string]any) any {
return row["salary"].(float64) * 0.10
})

// Element-wise transform of a single column (newColumn == srcColumn replaces it)
upper := df.Map("name", "name_upper", func(v any) any {
return strings.ToUpper(v.(string))
})

// Missing-value handling. Otters has no explicit null: the column type's
// zero value ("", 0, 0.0, false, zero time) is the missing-value sentinel,
// so a legitimate 0 in an int column is indistinguishable from missing.
filled := df.FillNA("department", "Unassigned") // replace zero values
complete := df.DropNA("salary", "department") // drop rows with zero values
allComplete := df.DropNA() // check every column

// Rename columns
clean_df := df.RenameColumn("hired_date", "start_date")
Expand All @@ -168,6 +209,13 @@ clean_df := df.RenameColumn("hired_date", "start_date")
essential := df.Drop("internal_id", "notes")
```

The derived column's type is inferred from what the function returns: all
integers → `int64`, integer/float mix → `float64`, uniform string/bool/time
keep their type, any other mix falls back to `string`. Returning `nil` fills
that row with the zero value. A panic inside your function is caught and
carried as an error on the returned DataFrame — it will not crash your
program.

## 🏗️ API Reference

### DataFrame Creation
Expand Down Expand Up @@ -297,6 +345,7 @@ Coming from Pandas? Here's how Otters compares:
| `df[df.age > 25]` | `df.Filter("age", ">", 25)` | Explicit syntax |
| `df[['name', 'age']]` | `df.Select("name", "age")` | Method-based selection |
| `df.sort_values()` | `df.Sort("column", true)` | Simple sort syntax |
| `df.merge(other)` | `df.Join(other, keys, how)` | Explicit join type |
| `df.describe()` | `df.Describe()` | Similar functionality |

## 🚧 Roadmap
Expand All @@ -311,11 +360,12 @@ Coming from Pandas? Here's how Otters compares:
- [x] Simple query strings (`Query("age > 25")`) and `Where`
- [x] Statistics (describe, median, variance, quantiles, correlation, value counts)
- [x] Lazy views for chained operations (`df.Lazy()...Collect()`)
- [x] Join operations (inner, left, right, outer) — `Join` / `JoinOn`
- [x] Column derivation & transforms — `Apply`, `Map`, `FillNA`, `DropNA`
- [x] Fluent API with error handling

### 🔄 Coming Soon

- [ ] Join operations (inner, left, right, outer)
- [ ] More file formats (JSON arrays, Parquet)
- [ ] Data visualization helpers
- [ ] Streaming operations for large files
Expand Down
38 changes: 22 additions & 16 deletions err.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,28 +223,34 @@ func (df *DataFrame) Error() error {
return df.err
}

// recoverFromPanic recovers from panics and converts them to OtterErrors
func recoverFromPanic(op string) error {
// panicToError converts a recovered panic value into an OtterError.
func panicToError(op string, r any) error {
switch v := r.(type) {
case error:
return wrapError(op, v)
case string:
return newOpError(op, v)
default:
return newOpError(op, fmt.Sprintf("panic: %v", r))
}
}

// recoverFromPanic converts an in-flight panic into an error assigned to
// *errp. It must be installed directly as the deferred function:
//
// defer recoverFromPanic("Op", &err)
//
// Calling it from inside another deferred closure does not work: recover
// only intercepts a panic when called directly by a deferred function.
func recoverFromPanic(op string, errp *error) {
if r := recover(); r != nil {
switch v := r.(type) {
case error:
return wrapError(op, v)
case string:
return newOpError(op, v)
default:
return newOpError(op, fmt.Sprintf("panic: %v", r))
}
*errp = panicToError(op, r)
}
return nil
}

// SafeOperation wraps a function to handle panics and convert them to errors
func SafeOperation(op string, fn func() error) (err error) {
defer func() {
if panicErr := recoverFromPanic(op); panicErr != nil {
err = panicErr
}
}()
defer recoverFromPanic(op, &err)
return fn()
}

Expand Down
89 changes: 85 additions & 4 deletions err_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,26 @@ func TestDataFrame_ErrorMethods(t *testing.T) {
}

func TestRecoverFromPanic(t *testing.T) {
// Test that recoverFromPanic works when there's no panic
err := recoverFromPanic("TestOp")
if err != nil {
t.Error("recoverFromPanic should return nil when no panic")
// No-panic path: the error must stay untouched.
run := func() (err error) {
defer recoverFromPanic("TestOp", &err)
return nil
}
if err := run(); err != nil {
t.Error("recoverFromPanic should leave err nil when no panic occurred")
}

// Panic path: err is populated with an OtterError.
boom := func() (err error) {
defer recoverFromPanic("TestOp", &err)
panic("kaboom")
}
err := boom()
if err == nil {
t.Fatal("recoverFromPanic should convert the panic into an error")
}
if !strings.Contains(err.Error(), "kaboom") {
t.Errorf("recovered error should carry the panic message, got: %v", err)
}
}

Expand Down Expand Up @@ -225,3 +241,68 @@ func TestSentinelErrors(t *testing.T) {
})
}()
}

// TestSafeOperationRecoversPanics is the core "no panics escape" guarantee:
// a panic inside the wrapped function must come back as an error, not crash.
func TestSafeOperationRecoversPanics(t *testing.T) {
// String panic.
err := SafeOperation("StringPanic", func() error { panic("exploded") })
if err == nil {
t.Fatal("SafeOperation must convert a string panic into an error")
}
if !strings.Contains(err.Error(), "exploded") {
t.Errorf("recovered error should mention the panic message, got: %v", err)
}
var oe *OtterError
if !errors.As(err, &oe) || oe.Op != "StringPanic" {
t.Errorf("recovered error should be an *OtterError carrying the op, got: %#v", err)
}

// Error panic: the original error must remain reachable via errors.Is.
cause := errors.New("root cause")
err = SafeOperation("ErrorPanic", func() error { panic(cause) })
if err == nil {
t.Fatal("SafeOperation must convert an error panic into an error")
}
if !errors.Is(err, cause) {
t.Errorf("recovered error should wrap the panicked error, got: %v", err)
}

// Arbitrary value panic.
err = SafeOperation("IntPanic", func() error { panic(42) })
if err == nil {
t.Fatal("SafeOperation must convert a non-error panic into an error")
}
if !strings.Contains(err.Error(), "42") {
t.Errorf("recovered error should mention the panic value, got: %v", err)
}
}

// TestSafeOperationRecoversRuntimePanic drives a genuine runtime panic
// (index out of range) through the safety net.
func TestSafeOperationRecoversRuntimePanic(t *testing.T) {
err := SafeOperation("Runtime", func() error {
s := make([]int, 0)
i := len(s) - 1
_ = s[i] // index out of range at runtime
return nil
})
if err == nil {
t.Fatal("SafeOperation must recover a runtime panic")
}
if !strings.Contains(err.Error(), "range") {
t.Errorf("recovered error should carry the runtime message, got: %v", err)
}
}

// TestSentinelMatchingSurvivesRecoveredPath guards the Round-2 sentinel fix:
// errors produced after a recovery still match via errors.Is.
func TestSentinelMatchingSurvivesRecoveredPath(t *testing.T) {
df, _ := NewDataFrameFromMap(map[string]any{"a": []int64{1}})
err := SafeOperation("Wrapped", func() error {
return df.validateColumnExists("ghost")
})
if !errors.Is(err, ErrColumnNotFound) {
t.Errorf("sentinel matching lost through SafeOperation, got: %v", err)
}
}
55 changes: 55 additions & 0 deletions example_join_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package otters

import (
"fmt"
)

// Example_join demonstrates combining two DataFrames on a key column.
func Example_join() {
orderID, _ := NewSeries("order_id", []int64{1, 2, 3})
orderCust, _ := NewSeries("customer_id", []int64{10, 20, 30})
amount, _ := NewSeries("amount", []float64{250, 125.5, 300})
orders, _ := NewDataFrameFromSeries(orderID, orderCust, amount)

custID, _ := NewSeries("customer_id", []int64{10, 20, 40})
name, _ := NewSeries("name", []string{"Alice", "Bob", "Carol"})
customers, _ := NewDataFrameFromSeries(custID, name)

// Inner join: only orders with a matching customer survive.
matched := orders.Join(customers, []string{"customer_id"}, InnerJoin)
if matched.Error() != nil {
fmt.Println("join failed:", matched.Error())
return
}
fmt.Print(matched)

// Left join keeps every order; unmatched rows zero-fill customer columns.
all := orders.Join(customers, []string{"customer_id"}, LeftJoin)
rows, cols := all.Shape()
fmt.Printf("left join keeps all orders: %d rows, %d columns\n", rows, cols)

// Output:
// order_id customer_id amount name
// 1 10 250 Alice
// 2 20 125.5 Bob
// left join keeps all orders: 3 rows, 4 columns
}

// ExampleDataFrame_JoinOn joins frames whose key columns are named differently.
func ExampleDataFrame_JoinOn() {
empID, _ := NewSeries("emp", []string{"ann", "bob"})
deptRef, _ := NewSeries("dept_code", []string{"ENG", "OPS"})
employees, _ := NewDataFrameFromSeries(empID, deptRef)

code, _ := NewSeries("code", []string{"ENG", "OPS"})
deptName, _ := NewSeries("dept_name", []string{"Engineering", "Operations"})
departments, _ := NewDataFrameFromSeries(code, deptName)

res := employees.JoinOn(departments, []string{"dept_code"}, []string{"code"}, InnerJoin)
fmt.Print(res)

// Output:
// emp dept_code dept_name
// ann ENG Engineering
// bob OPS Operations
}
29 changes: 29 additions & 0 deletions example_transform_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package otters

import (
"fmt"
)

// Example_transform demonstrates deriving columns and handling missing values.
func Example_transform() {
name, _ := NewSeries("name", []string{"ann", "", "cy"})
salary, _ := NewSeries("salary", []float64{1000, 2000, 0})
df, _ := NewDataFrameFromSeries(name, salary)

res := df.
FillNA("name", "unknown").
DropNA("salary").
Apply("bonus", func(row map[string]any) any {
return row["salary"].(float64) * 0.1
})
if res.Error() != nil {
fmt.Println("transform failed:", res.Error())
return
}
fmt.Print(res)

// Output:
// name salary bonus
// ann 1000 100
// unknown 2000 200
}
Loading