Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,4 @@ Cargo.lock
# test files
testfiles/*.csv
!testfiles/generated-100.csv
!testfiles/err-*-col*.csv
6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ crate-type = ["cdylib"]
[dependencies]
anyhow = "1.0.102"
csv = "1.4.0"
napi = { version = "3.0.0", features = ["anyhow", "serde_json"] }
itoa = "1.0.18"
napi = { version = "3.0.0", features = ["anyhow"] }
napi-derive = "3.0.0"
serde = "1.0.228"
serde_json = "1.0.149"
ryu = "1.0.23"

[build-dependencies]
napi-build = "2"
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2020 N-API for Rust
Copyright (c) 2026 Clelia Astra Bertelli

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
105 changes: 82 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ npm install @cle-does-things/sunbears

## Usage

### `readCsv`

The main function for `sunbears` is `readCsv`, which loads the data contained in a CSV file as a `DataFrame`, a columnar data format.

```typescript
Expand All @@ -24,6 +26,7 @@ The `DataFrame` class exposes two methods:

- `colDtype`: retrieve the data type of the records contained within a column (integer, float, boolean or string)
- `get`: get a column
- `writeCsv`: write the dataframe to CSV (see the next paragraph)

```typescript
const dt = df.colDtype('name')
Expand Down Expand Up @@ -61,36 +64,88 @@ const filteredNames = asStringArray(readCsv('test.csv').get('name'))?.filter((n)
const mappedNames = asStringArray(readCsv('test.csv').get('name'))?.map((n) => n.toUpperCase())
```

## Benchmarking
### `DataFrame.writeCsv`

`sunbears` was benchmarked using the `tinybench`-based script you can find [here](./benchmark/bench.ts). The script reports latency statistics related to the `readCsv` function reading increasingly large CSV files (100, 1000, 100.000 and 1.000.000 rows).
The `writeCsv` method writes a DataFrame to CSV.

The latest benchmark run was:
You can construct a DataFrame simply starting from arrays, using the following helper functions:

| Task | Latency avg (s) | Latency med (s) | Throughput avg (ops/s) | Throughput med (ops/s) | Samples |
| ------------------------ | ---------------- | -------------------- | ---------------------- | ---------------------- | ------- |
| Read a 100-lines CSV | 0.000073 ± 0.20% | 0.000073 ± 0.0000015 | 13814 ± 0.11% | 13841 ± 285 | 13727 |
| Read a 1000-lines CSV | 0.000423 ± 0.47% | 0.000412 ± 0.000010 | 2383 ± 0.31% | 2427 ± 60 | 2362 |
| Read a 100000-lines CSV | 0.041591 ± 2.07% | 0.040895 ± 0.001126 | 24 ± 1.86% | 24 ± 1 | 64 |
| Read a 1000000-lines CSV | 0.424404 ± 0.73% | 0.421294 ± 0.008276 | 2 ± 0.70% | 2 ± 0 | 64 |
```typescript
import { DataFrame, toIntColumn, toFloatColumn, toStringColumn, toBoolColumn } from '@cle-does-things/sunbears'

Here is how the tool compares to the `read_csv` function in Pandas and Polars ([script](./testfiles/comparative_bench.py)):
const col1 = toStringColumn(['hello', 'world'])
const col2 = toFloatColumn([1.2, 2.3])
const col3 = toIntColumn([4, 5])
const col4 = toBoolColumn([true, false])
```

| Dataset | Pandas (s) | Polars (s) |
| ------------- | ---------- | ---------- |
| 100 lines | 0.030637 | 0.022748 |
| 1000 lines | 0.033386 | 0.017368 |
| 100000 lines | 0.453596 | 0.027075 |
| 1000000 lines | 3.986837 | 0.198708 |
You can then use the `fromColumns` factory for the `DataFrame` class to turn column data into a DataFrame: if the columns do not have the same length, an error will be thrown.

And here it how it compares with `csv-parse` ([script](./benchmark/bench-alt.ts)):
```typescript
const df = DataFrame.fromColumns({
col1: col1,
col2: col2,
col3: col3,
col4: col4,
})
```

Writing to the CSV file is then trivial:

```typescript
df.writeCsv('test.csv')
```

| Task | Latency avg (s) | Latency med (s) | Throughput avg (ops/s) | Throughput med (ops/s) | Samples |
| -------------------------- | ---------------- | ------------------- | ---------------------- | ---------------------- | ------- |
| Read a 100-lines CSV | 0.000188 ± 0.98% | 0.000179 ± 0.000003 | 5,442 ± 0.23% | 5,580 ± 94 | 5,312 |
| Read a 1,000-lines CSV | 0.001235 ± 0.69% | 0.001189 ± 0.000032 | 816 ± 0.58% | 841 ± 23 | 810 |
| Read a 100,000-lines CSV | 0.11656 ± 0.26% | 0.11648 ± 0.000642 | 9 ± 0.25% | 9 ± 0 | 64 |
| Read a 1,000,000-lines CSV | 1.19114 ± 0.37% | 1.18707 ± 0.007191 | 1 ± 0.36% | 1 ± 0 | 64 |
The file will look like this:

```csv
col1,col2,col3,col4
hello,1.2,4,true
world,2.3,5,false
```

## Benchmarking

`sunbears` was benchmarked using the `tinybench`-based script you can find [here](./benchmark/bench.ts). The script reports latency statistics related to the `readCsv` and `writeCsv` functions reading/writing increasingly large CSV files (100, 1000, 100.000 and 1.000.000 rows).

The latest benchmark run was:

| Task | Latency avg (s) | Latency med (s) | Throughput avg (ops/s) | Throughput med (ops/s) | Samples |
| ------------------------- | ---------------- | -------------------- | ---------------------- | ---------------------- | ------- |
| Read a 100-lines CSV | 0.000054 ± 0.24% | 0.000050 ± 0.0000023 | 18964 ± 0.16% | 19967 ± 921 | 18654 |
| Read a 1000-lines CSV | 0.000289 ± 0.54% | 0.000279 ± 0.0000090 | 3518 ± 0.32% | 3583 ± 116 | 3464 |
| Read a 100000-lines CSV | 0.028000 ± 1.38% | 0.027537 ± 0.000254 | 36 ± 1.19% | 36 ± 0 | 64 |
| Read a 1000000-lines CSV | 0.310751 ± 0.75% | 0.308330 ± 0.004228 | 3 ± 0.70% | 3 ± 0 | 64 |
| Write a 100-lines CSV | 0.000076 ± 0.46% | 0.000069 ± 0.0000052 | 13665 ± 0.28% | 14467 ± 1148 | 13140 |
| Write a 1000-lines CSV | 0.000213 ± 0.43% | 0.000209 ± 0.0000056 | 4724 ± 0.16% | 4785 ± 130 | 4700 |
| Write a 100000-lines CSV | 0.013886 ± 0.86% | 0.013756 ± 0.000171 | 72 ± 0.78% | 73 ± 1 | 73 |
| Write a 1000000-lines CSV | 0.146282 ± 1.39% | 0.146108 ± 0.005752 | 7 ± 1.31% | 7 ± 0 | 64 |

Here is how the tool compares to the `read_csv` and `to_csv`/`write_csv` functions in Pandas and Polars ([script](./testfiles/comparative_bench.py)):

| Dataset | Pandas (s) | Polars (s) |
| ------------------- | ---------- | ---------- |
| Read 100 lines | 0.038291 | 0.033831 |
| Read 1000 lines | 0.037794 | 0.016517 |
| Read 100000 lines | 0.471109 | 0.029076 |
| Read 1000000 lines | 4.153507 | 0.216254 |
| Write 100 lines | 0.035926 | 0.043052 |
| Write 1000 lines | 0.067816 | 0.017617 |
| Write 100000 lines | 0.892885 | 0.031329 |
| Write 1000000 lines | 8.549390 | 0.331897 |

And here it how it compares with `csv-parse` and `csv-stringify`+`writeFileSync` ([script](./benchmark/bench-alt.ts)):

| Task | Latency avg (s) | Latency med (s) | Throughput avg (ops/s) | Throughput med (ops/s) | Samples |
| ------------------------- | ---------------- | -------------------- | ---------------------- | ---------------------- | ------- |
| Read a 100-lines CSV | 0.000207 ± 2.31% | 0.000191 ± 0.0000087 | 5086 ± 0.31% | 5224 ± 244 | 4842 |
| Read a 1000-lines CSV | 0.001244 ± 0.42% | 0.001233 ± 0.0000229 | 806 ± 0.33% | 811 ± 15 | 805 |
| Read a 100000-lines CSV | 0.120565 ± 0.63% | 0.119515 ± 0.001141 | 8 ± 0.60% | 8 ± 0 | 64 |
| Read a 1000000-lines CSV | 1.216019 ± 0.46% | 1.209978 ± 0.006709 | 1 ± 0.44% | 1 ± 0 | 64 |
| Write a 100-lines CSV | 0.000087 ± 0.52% | 0.000080 ± 0.0000078 | 12010 ± 0.31% | 12526 ± 1267 | 11503 |
| Write a 1000-lines CSV | 0.000290 ± 1.14% | 0.000275 ± 0.0000192 | 3555 ± 0.42% | 3635 ± 258 | 3451 |
| Write a 100000-lines CSV | 0.027303 ± 2.96% | 0.027014 ± 0.000900 | 37 ± 1.87% | 37 ± 1 | 64 |
| Write a 1000000-lines CSV | 0.273814 ± 2.08% | 0.265154 ± 0.005824 | 4 ± 1.77% | 4 ± 0 | 64 |

## Development

Expand Down Expand Up @@ -176,3 +231,7 @@ git push
GitHub actions will do the rest job for you.

> WARN: Don't run `npm publish` manually.

## License

MIT
111 changes: 110 additions & 1 deletion __test__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import {
asFloatArray,
asIntArray,
asStringArray,
toIntColumn,
toFloatColumn,
toStringColumn,
toBoolColumn,
} from '../index'
import { unlinkSync } from 'fs'

test('readCsv reads a CSV and returns a DataFrame with correct datatypes', (t) => {
const csvPath = 'testfiles/generated-100.csv'
Expand All @@ -36,6 +41,23 @@ test('readCsv reads a CSV and returns a DataFrame with correct datatypes', (t) =
t.truthy(df.get('years_of_experience'))
})

test('readCsv throws expected type-dependent errors', (t) => {
t.throws(
() => {
readCsv('testfiles/err-typed-col-1.csv')
},
undefined,
'Expecting integer at line 2 for column colErr',
)
t.throws(
() => {
readCsv('testfiles/err-typed-col-2.csv')
},
undefined,
'Expecting integer at line 2 for column colErr',
)
})

test('DataFrame class methods work correctly', (t) => {
const columns: Record<string, ColumnData> = {
test: { type: 'String', field0: ['hello', 'world'] },
Expand All @@ -56,7 +78,7 @@ test('DataFrame class methods work correctly', (t) => {
t.deepEqual(df.get('last_one'), columns['last_one'])
})

test('Column to array functions work', (t) => {
test('Column to array functions work correctly', (t) => {
const columns: Record<string, ColumnData> = {
name: { type: 'String', field0: ['hello', 'world'] },
other: { type: 'Boolean', field0: [false, true] },
Expand All @@ -80,3 +102,90 @@ test('Column to array functions work', (t) => {
t.truthy(lastArray)
t.deepEqual(lastArray, columns['last_one'].field0)
})

test('Array to column functions work correctly', (t) => {
const intArr = [0, 1, 2, 3]
const floatArr = [0.1, 1.2, 2.3, 3.4]
const stringArr = ['hello', 'world', 'how', 'are']
const boolArr = [true, true, false, false]
const intCol = toIntColumn(intArr)
t.deepEqual(intCol.field0, intArr)
t.is(intCol.type, 'Integer')
const floatCol = toFloatColumn(floatArr)
t.deepEqual(floatCol.field0, floatArr)
t.is(floatCol.type, 'Float')
const stringCol = toStringColumn(stringArr)
t.deepEqual(stringCol.field0, stringArr)
t.is(stringCol.type, 'String')
const boolCol = toBoolColumn(boolArr)
t.deepEqual(boolCol.field0, boolArr)
t.is(boolCol.type, 'Boolean')
})

test('DataFrame constructor and factory work correctly', (t) => {
const columns: Record<string, ColumnData> = {
name: { type: 'String', field0: ['hello', 'world'] },
other: { type: 'Boolean', field0: [false, true] },
another: { type: 'Integer', field0: [1, 2] },
last_one: { type: 'Float', field0: [0.3, 2.6] },
}
const df = new DataFrame(columns, 2)
t.is(df.len, 2)
for (const k of Object.keys(columns)) {
t.deepEqual(df.get(k), columns[k])
}
const df1 = DataFrame.fromColumns(columns)
t.is(df1.len, 2)
for (const k of Object.keys(columns)) {
t.deepEqual(df1.get(k), columns[k])
}
})

test('DataFrame constructor and factory throw expected errors', (t) => {
const columns: Record<string, ColumnData> = {
name: { type: 'String', field0: ['hello', 'world'] },
other: { type: 'Boolean', field0: [false, true] },
another: { type: 'Integer', field0: [1, 2] },
last_one: { type: 'Float', field0: [0.3, 2.6] },
}
const columns1: Record<string, ColumnData> = {
name: { type: 'String', field0: ['hello'] },
other: { type: 'Boolean', field0: [false, true] },
another: { type: 'Integer', field0: [1, 2] },
last_one: { type: 'Float', field0: [0.3, 2.6] },
}
t.throws(
() => {
new DataFrame(columns, 3)
},
undefined,
'Not all columns are of the declared length',
)
t.throws(
() => {
DataFrame.fromColumns(columns1)
},
undefined,
'Not all columns are the same length',
)
})

test('Write to CSV works correctly', (t) => {
const col1 = toStringColumn(['hello', 'world'])
const col2 = toFloatColumn([1.2, 2.3])
const col3 = toIntColumn([4, 5])
const col4 = toBoolColumn([true, false])
const dfw = DataFrame.fromColumns({
col1: col1,
col2: col2,
col3: col3,
col4: col4,
})
dfw.writeCsv('testfiles/write-test.csv')
const dfr = readCsv('testfiles/write-test.csv')
const colw = dfw.columns
for (const k of Object.keys(colw)) {
t.deepEqual(dfr.get(k), colw[k])
}
unlinkSync('testfiles/write-test.csv')
})
Loading