diff --git a/.gitignore b/.gitignore index d2fea82..0ec9a24 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,4 @@ Cargo.lock # test files testfiles/*.csv !testfiles/generated-100.csv +!testfiles/err-*-col*.csv diff --git a/Cargo.toml b/Cargo.toml index 756019f..49bad94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/LICENSE b/LICENSE index 6c7f562..1817579 100644 --- a/LICENSE +++ b/LICENSE @@ -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 diff --git a/README.md b/README.md index 8092a69..649ee13 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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') @@ -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 @@ -176,3 +231,7 @@ git push GitHub actions will do the rest job for you. > WARN: Don't run `npm publish` manually. + +## License + +MIT diff --git a/__test__/index.spec.ts b/__test__/index.spec.ts index 5f442bb..3f62df5 100644 --- a/__test__/index.spec.ts +++ b/__test__/index.spec.ts @@ -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' @@ -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 = { test: { type: 'String', field0: ['hello', 'world'] }, @@ -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 = { name: { type: 'String', field0: ['hello', 'world'] }, other: { type: 'Boolean', field0: [false, true] }, @@ -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 = { + 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 = { + 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 = { + 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') +}) diff --git a/benchmark/bench-alt.ts b/benchmark/bench-alt.ts index 92d79c6..05e9187 100644 --- a/benchmark/bench-alt.ts +++ b/benchmark/bench-alt.ts @@ -1,28 +1,13 @@ +/* eslint-disable */ + import { Bench } from 'tinybench' import { parse } from 'csv-parse' -import fs from 'node:fs' +import fs, { unlinkSync, writeFileSync } from 'node:fs' import { finished } from 'stream/promises' +import { stringify } from 'csv-stringify/sync' const b = new Bench() -// const processFile = async () => { -// const records = []; -// const parser = fs.createReadStream(`${os.tmpdir()}/input.csv`).pipe( -// parse({ -// // CSV options if any -// }), -// ); -// parser.on("readable", function () { -// let record; -// while ((record = parser.read()) !== null) { -// // Work with each record -// records.push(record); -// } -// }); -// await finished(parser); -// return records; -// }; - b.add('Read a 100-lines CSV', async () => { console.log('Started 100 lines bench') const parser = fs.createReadStream(`testfiles/generated-100.csv`).pipe(parse({ toLine: 100 })) @@ -73,6 +58,143 @@ b.add('Read a 1000000-lines CSV', async () => { console.log('Ended 1.000.000 lines bench') }) +let records_100: any[][] | undefined +let records_1000: any[][] | undefined +let records_100000: any[][] | undefined +let records_1000000: any[][] | undefined + +b.add( + 'Write a 100-lines CSV', + async () => { + console.log('Started writing 100 lines bench') + const output = stringify(records_100!) + writeFileSync('testfiles/written-alt-100.csv', output) + console.log('Ended writing 100 lines bench') + }, + { + beforeAll: () => { + const col1 = new Array(100).fill('something') + const col2 = new Array(100).fill(1.0) + const col3 = new Array(100).fill(3) + const col4 = new Array(100).fill(true) + const cols = [col1, col2, col3, col4] + let i = 0 + const matrix: any[][] = [] + while (i < 100) { + let row = [] + for (const col of cols) { + row.push(col[i]) + } + matrix.push(row) + i++ + } + records_100 = matrix + }, + afterEach: () => { + unlinkSync('testfiles/written-alt-100.csv') + }, + }, +) + +b.add( + 'Write a 1000-lines CSV', + async () => { + console.log('Started writing 1000 lines bench') + const output = stringify(records_1000!) + writeFileSync('testfiles/written-alt-1000.csv', output) + console.log('Ended writing 1000 lines bench') + }, + { + beforeAll: () => { + const col1 = new Array(1000).fill('something') + const col2 = new Array(1000).fill(1.0) + const col3 = new Array(1000).fill(3) + const col4 = new Array(1000).fill(true) + const cols = [col1, col2, col3, col4] + let i = 0 + const matrix: any[][] = [] + while (i < 1000) { + const row = [] + for (const col of cols) { + row.push(col[i]) + } + matrix.push(row) + i++ + } + records_1000 = matrix + }, + afterEach: () => { + unlinkSync('testfiles/written-alt-1000.csv') + }, + }, +) + +b.add( + 'Write a 100.000-lines CSV', + async () => { + console.log('Started writing 100.000 lines bench') + const output = stringify(records_100000!) + writeFileSync('testfiles/written-alt-100000.csv', output) + console.log('Ended writing 100.000 lines bench') + }, + { + beforeAll: () => { + const col1 = new Array(100000).fill('something') + const col2 = new Array(100000).fill(1.0) + const col3 = new Array(100000).fill(3) + const col4 = new Array(100000).fill(true) + const cols = [col1, col2, col3, col4] + let i = 0 + const matrix: any[][] = [] + while (i < 100000) { + const row = [] + for (const col of cols) { + row.push(col[i]) + } + matrix.push(row) + i++ + } + records_100000 = matrix + }, + afterEach: () => { + unlinkSync('testfiles/written-alt-100000.csv') + }, + }, +) + +b.add( + 'Write a 1.000.000-lines CSV', + async () => { + console.log('Started writing 1.000.000 lines bench') + const output = stringify(records_1000000!) + writeFileSync('testfiles/written-alt-1000000.csv', output) + console.log('Ended writing 1.000.000 lines bench') + }, + { + beforeAll: () => { + const col1 = new Array(1000000).fill('something') + const col2 = new Array(1000000).fill(1.0) + const col3 = new Array(1000000).fill(3) + const col4 = new Array(1000000).fill(true) + const cols = [col1, col2, col3, col4] + let i = 0 + const matrix: any[][] = [] + while (i < 1000000) { + const row = [] + for (const col of cols) { + row.push(col[i]) + } + matrix.push(row) + i++ + } + records_1000000 = matrix + }, + afterEach: () => { + unlinkSync('testfiles/written-alt-1000000.csv') + }, + }, +) + await b.run() console.table(b.table()) diff --git a/benchmark/bench.ts b/benchmark/bench.ts index dd47ba0..9069203 100644 --- a/benchmark/bench.ts +++ b/benchmark/bench.ts @@ -1,6 +1,9 @@ +/* eslint-disable */ + import { Bench } from 'tinybench' -import { readCsv } from '../index.js' +import { DataFrame, readCsv, toBoolColumn, toFloatColumn, toIntColumn, toStringColumn } from '../index.js' +import { unlinkSync } from 'fs' const b = new Bench() @@ -28,6 +31,115 @@ b.add('Read a 1000000-lines CSV', () => { console.log('Ended 1.000.000 lines bench') }) +let df: DataFrame | undefined = undefined +let df_100: DataFrame | undefined = undefined +let df_1000: DataFrame | undefined = undefined +let df_100000: DataFrame | undefined = undefined + +b.add( + 'Write a 100-lines CSV', + () => { + console.log('Started writing 100 lines bench') + df_100!.writeCsv('testfiles/written-100.csv') + console.log('Ended writing 100 lines bench') + }, + { + beforeAll: () => { + const col1 = toStringColumn(new Array(100).fill('something')) + const col2 = toFloatColumn(new Array(100).fill(1.0)) + const col3 = toIntColumn(new Array(100).fill(3)) + const col4 = toBoolColumn(new Array(100).fill(true)) + df_100 = DataFrame.fromColumns({ + col1: col1, + col2: col2, + col3: col3, + col4: col4, + }) + }, + afterEach: () => { + unlinkSync('testfiles/written-100.csv') + }, + }, +) + +b.add( + 'Write a 1000-lines CSV', + () => { + console.log('Started writing 1000 lines bench') + df_1000!.writeCsv('testfiles/written-1000.csv') + console.log('Ended writing 1000 lines bench') + }, + { + beforeAll: () => { + const col1 = toStringColumn(new Array(1000).fill('something')) + const col2 = toFloatColumn(new Array(1000).fill(1.0)) + const col3 = toIntColumn(new Array(1000).fill(3)) + const col4 = toBoolColumn(new Array(1000).fill(true)) + df_1000 = DataFrame.fromColumns({ + col1: col1, + col2: col2, + col3: col3, + col4: col4, + }) + }, + afterEach: () => { + unlinkSync('testfiles/written-1000.csv') + }, + }, +) + +b.add( + 'Write a 100000-lines CSV', + () => { + console.log('Started writing 100.000 lines bench') + df_100000!.writeCsv('testfiles/written-100000.csv') + console.log('Ended writing 100.000 lines bench') + }, + { + beforeAll: () => { + const col1 = toStringColumn(new Array(100_000).fill('something')) + const col2 = toFloatColumn(new Array(100_000).fill(1.0)) + const col3 = toIntColumn(new Array(100_000).fill(3)) + const col4 = toBoolColumn(new Array(100_000).fill(true)) + df_100000 = DataFrame.fromColumns({ + col1: col1, + col2: col2, + col3: col3, + col4: col4, + }) + }, + afterEach: () => { + unlinkSync('testfiles/written-100000.csv') + }, + }, +) + +b.add( + 'Write a 1000000-lines CSV', + () => { + console.log('Started writing 1.000.000 lines bench') + df!.writeCsv('testfiles/written-1000000.csv') + console.log('Ended writing 1.000.000 lines bench') + }, + { + beforeAll: () => { + const col1 = toStringColumn(new Array(1_000_000).fill('something')) + const col2 = toFloatColumn(new Array(1_000_000).fill(1.0)) + const col3 = toIntColumn(new Array(1_000_000).fill(3)) + const col4 = toBoolColumn(new Array(1_000_000).fill(true)) + df = DataFrame.fromColumns({ + col1: col1, + col2: col2, + col3: col3, + col4: col4, + }) + }, + afterEach: () => { + unlinkSync('testfiles/written-1000000.csv') + }, + }, +) + await b.run() console.table(b.table()) diff --git a/index.d.ts b/index.d.ts index fdf0cdb..25b9470 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3,9 +3,11 @@ export declare class DataFrame { len: number constructor(columns: Record, len: number) + static fromColumns(columns: Record): DataFrame colDtype(col: string): DataType | null get(col: string): ColumnData | null get columns(): Record + writeCsv(path: string): void } export declare function asBooleanArray(column: ColumnData): Array | null @@ -30,3 +32,11 @@ export declare const enum DataType { } export declare function readCsv(path: string): DataFrame + +export declare function toBoolColumn(data: Array): ColumnData + +export declare function toFloatColumn(data: Array): ColumnData + +export declare function toIntColumn(data: Array): ColumnData + +export declare function toStringColumn(data: Array): ColumnData diff --git a/index.js b/index.js index 834c7fe..c53cb9c 100644 --- a/index.js +++ b/index.js @@ -583,3 +583,7 @@ module.exports.asIntArray = nativeBinding.asIntArray module.exports.asStringArray = nativeBinding.asStringArray module.exports.DataType = nativeBinding.DataType module.exports.readCsv = nativeBinding.readCsv +module.exports.toBoolColumn = nativeBinding.toBoolColumn +module.exports.toFloatColumn = nativeBinding.toFloatColumn +module.exports.toIntColumn = nativeBinding.toIntColumn +module.exports.toStringColumn = nativeBinding.toStringColumn diff --git a/package.json b/package.json index b9c794c..19b05b4 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ ], "napi": { "binaryName": "sunbears", - "name": "sunbears", "targets": [ "x86_64-pc-windows-msvc", "x86_64-apple-darwin", @@ -71,6 +70,7 @@ "ava": "^7.0.0", "chalk": "^5.6.2", "csv-parse": "^6.2.1", + "csv-stringify": "^6.7.0", "husky": "^9.1.7", "lint-staged": "^16.1.6", "npm-run-all2": "^8.0.4", diff --git a/src/lib.rs b/src/lib.rs index f34be11..3e7c43c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,12 +1,14 @@ #![deny(clippy::all)] -use std::{collections::HashMap, fmt}; +use std::{collections::HashMap, fmt, fs::File, io::BufWriter}; use anyhow::{anyhow, Result}; -use csv::Reader; +use csv::{Reader, Writer}; use napi_derive::napi; +// ---------------------------------------- DATA MODELS ---------------------------------------- + #[napi] #[derive(Clone)] pub enum ColumnData { @@ -16,6 +18,17 @@ pub enum ColumnData { Boolean(Vec), } +impl ColumnData { + fn len(&self) -> usize { + match self { + Self::Boolean(b) => b.len(), + Self::Float(f) => f.len(), + Self::Integer(i) => i.len(), + Self::String(s) => s.len(), + } + } +} + #[napi] #[derive(PartialEq, Debug, Clone)] pub enum DataType { @@ -37,6 +50,8 @@ impl fmt::Display for DataType { } } +// ---------------------------------------- HELPER FUNCTIONS FOR DATA MODELS ---------------------------------------- + #[napi] pub fn as_float_array(column: ColumnData) -> Option> { match column { @@ -69,6 +84,28 @@ pub fn as_string_array(column: ColumnData) -> Option> { } } +#[napi] +pub fn to_string_column(data: Vec) -> ColumnData { + ColumnData::String(data) +} + +#[napi] +pub fn to_float_column(data: Vec) -> ColumnData { + ColumnData::Float(data) +} + +#[napi] +pub fn to_int_column(data: Vec) -> ColumnData { + ColumnData::Integer(data) +} + +#[napi] +pub fn to_bool_column(data: Vec) -> ColumnData { + ColumnData::Boolean(data) +} + +// ---------------------------------------- DATAFRAME ---------------------------------------- + #[napi(js_name = "DataFrame")] pub struct DataFrame { columns: HashMap, @@ -78,8 +115,26 @@ pub struct DataFrame { #[napi] impl DataFrame { #[napi(constructor)] - pub fn new(columns: HashMap, len: u32) -> Self { - Self { columns, len } + pub fn new(columns: HashMap, len: u32) -> Result { + if !columns.iter().all(|(_, c)| c.len() as u32 == len) { + return Err(anyhow!("Not all columns are of the declared length")); + } + Ok(Self { columns, len }) + } + + #[napi(factory)] + pub fn from_columns(columns: HashMap) -> Result { + let keys: Vec<&String> = columns.keys().collect(); + let col = &columns[keys[0]]; + let l = col.len(); + if !columns.iter().all(|(_, c)| c.len() == l) { + return Err(anyhow!("Not all columns are the same length")); + } + + Ok(Self { + columns, + len: l as u32, + }) } #[napi] @@ -107,8 +162,52 @@ impl DataFrame { pub fn columns(&self) -> HashMap { self.columns.clone() } + + #[napi] + pub fn write_csv(&self, path: String) -> Result<()> { + let file = File::create(&path)?; + let buf = BufWriter::with_capacity(1 << 20, file); // 1 MB buffer size + let mut writer = Writer::from_writer(buf); + let mut float_buf = ryu::Buffer::new(); + let mut int_buf = itoa::Buffer::new(); + let keys: Vec<&String> = self.columns.keys().collect(); + writer.write_record(keys.iter().map(|k| k.as_str()))?; + for i in 0..self.len as usize { + let mut row = Vec::with_capacity(keys.len()); + for key in &keys { + let col = &self.columns[*key]; + match col { + ColumnData::Boolean(b) => { + let v = if b[i] { + "true".to_string() + } else { + "false".to_string() + }; + row.push(v); + } + ColumnData::Float(f) => { + let v = float_buf.format(f[i]).to_string(); + row.push(v); + } + ColumnData::Integer(j) => { + let v = int_buf.format(j[i]).to_string(); + row.push(v); + } + ColumnData::String(s) => { + row.push(s[i].clone()); + } + } + } + writer.write_record(&row)?; + } + writer.flush()?; + + Ok(()) + } } +// ---------------------------------------- CSV READING ---------------------------------------- + fn infer_dtype(s: &str) -> DataType { if s.eq_ignore_ascii_case("true") || s.eq_ignore_ascii_case("false") { return DataType::Boolean; @@ -166,24 +265,35 @@ pub fn read_csv(path: String) -> Result { let vc = &col_to_vec[*c]; let data: ColumnData = match d { DataType::Boolean => { - let v = vc - .iter() - .enumerate() - .map(|(i, s)| { - str_to_bool(s).unwrap_or_else(|| panic!("Expecting bool at line {:?} for col {}", i, c)) - }) - .collect(); + let mut v: Vec = vec![]; + for el in vc { + match str_to_bool(el) { + Some(val) => v.push(val), + None => { + return Err(anyhow!( + "Expecting bool at line {:?} for col {}", + i + 1, + col_idx[*c] + )) + } + } + } ColumnData::Boolean(v) } DataType::Float => { - let v = vc - .iter() - .enumerate() - .map(|(i, s)| { - s.parse::() - .unwrap_or_else(|_| panic!("Expecting float at line {:?} for col {}", i, c)) - }) - .collect(); + let mut v: Vec = vec![]; + for s in vc { + match s.parse::() { + Ok(val) => v.push(val), + Err(_) => { + return Err(anyhow!( + "Expecting float at line {:?} for col {}", + i + 1, + col_idx[*c] + )) + } + } + } ColumnData::Float(v) } @@ -192,14 +302,19 @@ pub fn read_csv(path: String) -> Result { ColumnData::String(v) } DataType::Integer => { - let v = vc - .iter() - .enumerate() - .map(|(i, s)| { - s.parse::() - .unwrap_or_else(|_| panic!("Expecting integer at line {:?} for col {}", i, c)) - }) - .collect(); + let mut v: Vec = vec![]; + for s in vc { + match s.parse::() { + Ok(val) => v.push(val), + Err(_) => { + return Err(anyhow!( + "Expecting integer at line {:?} for col {}", + i + 1, + col_idx[*c] + )) + } + } + } ColumnData::Integer(v) } @@ -207,7 +322,7 @@ pub fn read_csv(path: String) -> Result { cols.insert(col_idx[*c].to_owned(), data); } - let df = DataFrame::new(cols, i); + let df = DataFrame::new(cols, i)?; Ok(df) } diff --git a/testfiles/comparative_bench.py b/testfiles/comparative_bench.py index 00ceed6..4498365 100755 --- a/testfiles/comparative_bench.py +++ b/testfiles/comparative_bench.py @@ -6,12 +6,36 @@ # /// +import os import timeit +from glob import glob import pandas as pd import polars as pl +def generate_df_with_n_rows(n: int, pandas: bool) -> pl.DataFrame | pd.DataFrame: + data = { + "col1": ["something"] * n, + "col2": [1.0] * n, + "col3": [3] * n, + "col4": [True] * n, + } + if pandas: + return pd.DataFrame(data) + return pl.DataFrame(data) + + +pl_100_df = generate_df_with_n_rows(100, False) +pl_1000_df = generate_df_with_n_rows(1000, False) +pl_100000_df = generate_df_with_n_rows(100000, False) +pl_1000000_df = generate_df_with_n_rows(1000000, False) +pd_100_df = generate_df_with_n_rows(100, True) +pd_1000_df = generate_df_with_n_rows(1000, True) +pd_100000_df = generate_df_with_n_rows(100000, True) +pd_1000000_df = generate_df_with_n_rows(1000000, True) + + def read_100_with_polars() -> None: """ Read a 100-rows CSV with Polars. @@ -69,6 +93,44 @@ def read_1000000_with_pandas() -> None: pd.read_csv("testfiles/generated-1000000.csv") +def write_100_with_pandas() -> None: + pd_100_df.to_csv("testfiles/pd-100.csv", index=False) + + +def write_1000_with_pandas() -> None: + pd_1000_df.to_csv("testfiles/pd-1000.csv", index=False) + + +def write_100000_with_pandas() -> None: + pd_100000_df.to_csv("testfiles/pd-100000.csv", index=False) + + +def write_1000000_with_pandas() -> None: + pd_1000000_df.to_csv("testfiles/pd-1000000.csv", index=False) + + +def write_100_with_polars() -> None: + pl_100_df.write_csv("testfiles/pl-100.csv") + + +def write_1000_with_polars() -> None: + pl_1000_df.write_csv("testfiles/pl-1000.csv") + + +def write_100000_with_polars() -> None: + pl_100000_df.write_csv("testfiles/pl-100000.csv") + + +def write_1000000_with_polars() -> None: + pl_1000000_df.write_csv("testfiles/pl-1000000.csv") + + +def cleanup() -> None: + files = glob("testfiles/p?-1*.csv") + for file in files: + os.remove(file) + + def main() -> None: t100_pd = timeit.timeit(read_100_with_pandas, number=200) t1000_pd = timeit.timeit(read_1000_with_pandas, number=100) @@ -79,11 +141,24 @@ def main() -> None: t100000_pl = timeit.timeit(read_100000_with_polars, number=20) t1000000_pl = timeit.timeit(read_1000000_with_polars, number=20) + wt100_pd = timeit.timeit(write_100_with_pandas, number=200) + wt1000_pd = timeit.timeit(write_1000_with_pandas, number=100) + wt100000_pd = timeit.timeit(write_100000_with_pandas, number=20) + wt1000000_pd = timeit.timeit(write_1000000_with_pandas, number=20) + wt100_pl = timeit.timeit(write_100_with_polars, number=200) + wt1000_pl = timeit.timeit(write_1000_with_polars, number=100) + wt100000_pl = timeit.timeit(write_100000_with_polars, number=20) + wt1000000_pl = timeit.timeit(write_1000000_with_polars, number=20) + rows = [ - ("100 lines", t100_pd, t100_pl), - ("1000 lines", t1000_pd, t1000_pl), - ("100000 lines", t100000_pd, t100000_pl), - ("1000000 lines", t1000000_pd, t1000000_pl), + ("Read 100 lines", t100_pd, t100_pl), + ("Read 1000 lines", t1000_pd, t1000_pl), + ("Read 100000 lines", t100000_pd, t100000_pl), + ("Read 1000000 lines", t1000000_pd, t1000000_pl), + ("Write 100 lines", wt100_pd, wt100_pl), + ("Write 1000 lines", wt1000_pd, wt1000_pl), + ("Write 100000 lines", wt100000_pd, wt100000_pl), + ("Write 1000000 lines", wt1000000_pd, wt1000000_pl), ] col0, col1, col2 = "Dataset", "Pandas (s)", "Polars (s)" @@ -101,6 +176,8 @@ def main() -> None: print(f"| {name:<{w0}} | {pd_t:<{w1}.6f} | {pl_t:<{w2}.6f} |") print(sep) + cleanup() + if __name__ == "__main__": main() diff --git a/testfiles/err-typed-col-1.csv b/testfiles/err-typed-col-1.csv new file mode 100644 index 0000000..8909933 --- /dev/null +++ b/testfiles/err-typed-col-1.csv @@ -0,0 +1,3 @@ +col1,col2,colErr +1,true,1 +2,false,2.3 diff --git a/testfiles/err-typed-col-2.csv b/testfiles/err-typed-col-2.csv new file mode 100644 index 0000000..ecebe36 --- /dev/null +++ b/testfiles/err-typed-col-2.csv @@ -0,0 +1,3 @@ +col1,col2,colErr +1,true,12345678 +2,false,+1 345-6-78 diff --git a/testfiles/generated-100.csv b/testfiles/generated-100.csv index a91b020..ea61a40 100644 --- a/testfiles/generated-100.csv +++ b/testfiles/generated-100.csv @@ -1,101 +1,101 @@ name,age,is_working,height,phone_number,years_of_experience -Enid,45,false,151.5466823228966,0043-1234,2.5778831651313947 -Anne,27,false,157.32057957197924,0043-1234,3.920121676134699 -Anne,33,false,172.136358978696,0043-1234,0.8587354612575759 -Charles,37,false,171.33244976651685,0043-1234,2.903354645818903 -Anne,34,true,174.21281615745679,0043-1234,3.152821943088975 -Charles,24,false,161.73810910236352,0043-1234,1.24912967135455 -Enid,26,false,163.20241491839687,0043-1234,1.3843500318447932 -Enid,50,true,174.58942179951632,0043-1234,0.29440806510794815 -Charles,40,true,160.32882761029387,+1 1234,4.750086123138027 -Francis,20,false,177.84463400681972,0043-1234,3.590579920066384 -Anne,28,false,180.7559581929724,0043-1234,3.8079258128713915 -Enid,43,true,162.2578081838651,+1 1234,3.943149128054852 -Francis,45,true,161.15792835686182,+1 1234,3.275121349491939 -Charles,28,true,173.52374548303726,0043-1234,2.2907905374973434 -Charles,27,false,167.53436253284644,+1 1234,3.036343574334585 -Francis,41,false,151.6681706051119,+1 1234,4.704139386820414 -Charles,43,true,158.46248923827525,+1 1234,3.6361156343617385 -Charles,43,true,176.16091128359244,+1 1234,4.526527468976222 -Francis,29,false,171.5240388288034,+1 1234,1.3341152606202553 -Dylan,36,false,177.03758652443665,+1 1234,3.5017422747629494 -Dylan,45,true,168.21283391386254,0043-1234,2.1148839392310013 -Enid,34,true,152.4336293492877,0043-1234,4.938747197130386 -Charles,37,true,158.39485545404779,0043-1234,2.076732739300934 -Bob,36,true,158.75970242956387,0043-1234,2.9789814987774665 -Francis,44,false,177.36154981808326,0043-1234,4.197012112866403 -Francis,21,true,163.58058955398462,0043-1234,0.1290727333852506 -Charles,23,true,176.4620090811987,+1 1234,1.9565531871692177 -Francis,46,true,173.67591891696253,0043-1234,3.475410636322101 -Francis,43,false,174.1497585797949,+1 1234,2.4334043109864156 -Dylan,35,false,163.3179439068544,+1 1234,4.456175047352048 -Bob,26,false,156.89588160262397,0043-1234,1.7440901131079198 -Bob,25,true,174.02640553149135,+1 1234,2.96007370677206 -Dylan,27,false,163.06869544510883,0043-1234,1.1350907683849853 -Francis,36,true,163.92732636598666,0043-1234,3.837165706004641 -Dylan,22,false,169.22480371321228,0043-1234,3.241646789199385 -Dylan,39,false,159.94975264421598,0043-1234,1.4793931230161022 -Enid,27,true,180.63096161699352,0043-1234,1.731196161419024 -Bob,31,true,180.27010334955744,0043-1234,3.3868422932758686 -Bob,38,false,166.5457493309321,0043-1234,2.5500213541433325 -Francis,37,true,159.4830413538787,0043-1234,1.0761979352612872 -Francis,45,false,150.8110611015065,+1 1234,3.766570934362565 -Enid,29,true,159.5999294435609,+1 1234,3.444842149699685 -Francis,44,true,179.9218787546004,0043-1234,1.0536416152316959 -Dylan,32,true,159.9604202358432,0043-1234,3.6030971831385328 -Francis,45,false,172.2728449812043,+1 1234,3.1972307584450004 -Dylan,23,true,178.38682904606802,+1 1234,1.1139663330328458 -Dylan,43,true,165.91595703550433,0043-1234,1.663690739056932 -Charles,37,true,164.97907400959514,+1 1234,1.059942097552784 -Francis,21,true,152.51438354447447,+1 1234,3.1689985168144226 -Francis,27,true,163.12175423186218,0043-1234,3.8489905588458333 -Charles,25,false,178.02960891566104,0043-1234,4.6216027506403865 -Enid,23,false,166.83457465997702,0043-1234,3.8774566196190325 -Francis,31,false,165.31805063687807,+1 1234,3.4656451539345836 -Bob,43,false,168.21950087249613,+1 1234,1.0603604183311441 -Charles,32,false,177.4090685505179,0043-1234,4.604355730257678 -Enid,34,false,178.72611537672864,0043-1234,1.5270268273472656 -Anne,29,false,166.57616492581164,+1 1234,0.8760047145100074 -Anne,22,false,165.40220309671685,0043-1234,4.8832531600674445 -Francis,46,true,167.74924727046664,0043-1234,1.868036709110598 -Francis,39,false,171.34656089350054,0043-1234,2.839303404514995 -Anne,43,false,171.70729294284888,+1 1234,1.026264394218072 -Enid,27,true,170.97086309150654,0043-1234,2.4077883659503927 -Anne,21,true,155.54580394718937,0043-1234,1.9208241110559379 -Bob,36,true,180.10669124042226,+1 1234,1.9623607711430755 -Anne,21,false,150.30057047559959,0043-1234,1.5292597422560075 -Enid,42,false,155.3581224019092,0043-1234,3.2138244136599496 -Dylan,43,false,167.56206691233936,0043-1234,0.8422819556789753 -Bob,35,false,162.86264479579643,+1 1234,0.5569161225719821 -Enid,33,true,154.0846693644162,+1 1234,1.4359544202248402 -Bob,41,true,172.57865473074597,+1 1234,2.055424188216807 -Enid,26,false,152.15799419687647,+1 1234,4.719488876086064 -Francis,23,false,169.878528671469,0043-1234,2.187081578657277 -Enid,38,false,154.97149898230037,+1 1234,4.359584669131257 -Anne,33,true,174.05165187931777,0043-1234,2.0777051150234804 -Anne,24,false,158.67705046306142,0043-1234,1.9469860232357636 -Anne,35,true,177.64575406781483,0043-1234,2.5616839816914085 -Dylan,21,true,179.70924371031091,0043-1234,4.463147220830667 -Charles,38,false,153.8473652863645,+1 1234,1.8976826379764433 -Anne,26,false,162.19056921456087,+1 1234,2.2791663503653314 -Anne,44,false,168.74112625639967,0043-1234,3.998571722571157 -Anne,20,true,155.2211316180539,0043-1234,3.037807748091206 -Dylan,49,false,163.3226306089724,+1 1234,4.190044181325576 -Enid,31,true,165.3500162565441,+1 1234,1.5201238059560775 -Anne,23,false,180.67216842351866,0043-1234,0.7375719808853958 -Dylan,50,false,159.00118638113378,+1 1234,1.7967453440495245 -Charles,48,false,151.18747081584493,+1 1234,4.6827749333675595 -Charles,38,false,164.73927282624376,+1 1234,1.9827473672350309 -Anne,41,false,154.90888085576222,0043-1234,1.458068273448839 -Bob,31,false,168.7056602769985,0043-1234,4.755019954654738 -Bob,40,true,164.39532455208624,+1 1234,4.7388916895511795 -Francis,43,true,179.21558184516408,+1 1234,4.786612934599447 -Anne,23,true,174.06420167103988,+1 1234,3.441708127758816 -Enid,25,false,152.23513887479515,+1 1234,0.40725044518799813 -Dylan,27,false,162.08722522981748,+1 1234,2.2732454408147174 -Enid,44,false,170.37728837288006,+1 1234,2.5495128532876676 -Francis,25,true,156.60365018377416,+1 1234,1.888663703839749 -Anne,28,false,176.65580422987716,0043-1234,0.5165139300906969 -Charles,43,false,169.77672722144214,+1 1234,2.2958058569771893 -Enid,35,true,180.90442290293274,+1 1234,1.2124514098332435 -Anne,50,false,152.19948439286932,0043-1234,3.628541290339011 +Anne,24,false,172.06195385839587,+1 1234,3.767044082274923 +Enid,36,true,164.17546867223956,+1 1234,3.9987730641281765 +Francis,45,true,167.66954681406762,0043-1234,0.4383413027655949 +Enid,34,true,161.30458950930952,+1 1234,1.0983771829465512 +Charles,25,false,151.67913999785412,0043-1234,2.434259647739991 +Dylan,31,true,161.16597974594205,0043-1234,3.276539501458718 +Charles,43,false,153.78110220498175,+1 1234,4.134995277849777 +Charles,25,true,166.40188604691568,0043-1234,3.4263596384326083 +Bob,23,false,158.38593329361092,0043-1234,1.0320864141187804 +Bob,22,false,174.30421224335603,0043-1234,0.30139282444920124 +Charles,25,false,167.36986172098074,+1 1234,3.6070863321158333 +Bob,47,true,155.77584934965734,+1 1234,0.3358753495974762 +Anne,47,true,152.07245609048525,0043-1234,3.978476753629136 +Anne,35,false,163.8284506948372,0043-1234,4.7375086847218375 +Anne,49,true,180.1237482982292,+1 1234,1.4734837130167757 +Francis,31,false,171.39476207206496,+1 1234,2.057482207163191 +Charles,20,false,150.85891722659494,+1 1234,1.8076413190188547 +Charles,26,true,157.73556835468887,0043-1234,3.2390834201030048 +Bob,28,false,180.22280208552263,0043-1234,0.11382435925677648 +Francis,50,true,162.39526970619806,+1 1234,0.8249470790353828 +Charles,39,true,154.01212102360162,+1 1234,1.6846034197031907 +Francis,27,true,173.5486689216849,+1 1234,3.9985312398191337 +Francis,23,false,177.5849194905182,0043-1234,2.6625711310679345 +Francis,20,true,176.23107677217902,+1 1234,2.36102935780334 +Francis,34,true,180.7011235918709,0043-1234,1.482859764730454 +Anne,28,true,172.2611271479767,+1 1234,0.8026755462524306 +Francis,35,true,163.02339898361976,+1 1234,0.13368333645175268 +Dylan,21,false,164.56067064798725,+1 1234,1.1321786041675934 +Anne,41,true,179.442405598062,0043-1234,2.4910230451061053 +Dylan,40,true,169.0704375228679,+1 1234,3.767070661054649 +Bob,45,false,158.52554354403676,0043-1234,3.389188917679733 +Anne,49,true,178.3749017440682,+1 1234,1.2717571652842483 +Francis,42,false,179.89635183986815,+1 1234,0.7228721084532994 +Francis,50,false,162.16345519279804,0043-1234,2.1467121727357386 +Anne,25,false,151.85714506267925,0043-1234,2.0106703515331454 +Enid,43,true,154.44252150760803,0043-1234,0.10012231117163695 +Francis,33,false,169.75224887442954,0043-1234,2.1155764577654237 +Dylan,21,true,163.42614142253774,+1 1234,4.1636259380519896 +Charles,39,false,177.19248309034617,0043-1234,0.7043319312506218 +Dylan,27,false,163.78268872264073,0043-1234,0.7933675094289101 +Enid,22,true,151.69475029878475,0043-1234,3.427832466709869 +Anne,24,true,167.0624790612009,0043-1234,3.208757157964802 +Enid,22,true,161.48780558514278,+1 1234,1.8090922295976357 +Francis,49,true,161.18349998788094,+1 1234,1.0264327081269466 +Enid,30,true,155.8820687964176,0043-1234,1.0565103129209166 +Anne,50,false,163.58681127771385,0043-1234,2.834026981707148 +Enid,25,true,165.3165808966044,0043-1234,2.907435266125858 +Dylan,33,true,178.4105287916339,0043-1234,3.1364447532695845 +Francis,24,false,153.3751836951103,0043-1234,3.573562444922654 +Francis,33,true,175.3551671500021,0043-1234,0.8777011127810928 +Bob,28,false,167.79756345857308,0043-1234,2.5037076919560795 +Dylan,31,true,151.22287551814932,+1 1234,3.909823087240767 +Anne,24,true,159.32157999459298,0043-1234,3.304525677644712 +Dylan,45,false,179.506794367215,0043-1234,1.94228733904784 +Enid,47,false,153.09902751239227,0043-1234,1.2257610300558768 +Enid,24,false,155.81295425867603,0043-1234,2.5198447985382337 +Dylan,35,true,177.8206091922323,0043-1234,3.11915384637018 +Charles,36,false,169.68986298923735,+1 1234,2.040447345083472 +Bob,26,true,175.03218264042204,+1 1234,0.969361969266481 +Bob,33,false,172.40702314693513,0043-1234,3.0529996726430175 +Dylan,21,true,180.24598488538015,+1 1234,2.4544469347421005 +Charles,47,false,152.5271909335372,+1 1234,4.862982108147397 +Bob,31,true,174.82341433179184,+1 1234,4.772525888375782 +Dylan,47,false,159.68297409447837,0043-1234,2.910096160363199 +Dylan,26,false,163.5897866306247,0043-1234,4.5364911850626335 +Dylan,23,true,150.732788122647,0043-1234,4.315366231031446 +Enid,23,false,171.53990655114706,+1 1234,0.5693450705816476 +Enid,48,false,163.53419005719897,0043-1234,1.2921866236585322 +Francis,47,false,151.76618196211368,+1 1234,2.3720314158733604 +Charles,27,true,159.52354929775623,+1 1234,4.867960488247686 +Enid,49,false,162.074604287669,0043-1234,3.7767032840851327 +Bob,40,true,180.6973555225904,+1 1234,4.976864922452478 +Dylan,43,true,173.8940011972604,+1 1234,3.840024105102519 +Enid,35,false,170.53003190168505,0043-1234,3.146912731530201 +Charles,45,false,175.9191299427927,0043-1234,0.13449729759980267 +Francis,37,false,178.81323593962227,+1 1234,1.8046065918879781 +Francis,37,true,171.64751648501152,0043-1234,1.2121177903237317 +Dylan,21,true,174.23500345515865,0043-1234,2.40193633389855 +Enid,23,true,166.64177130219286,0043-1234,1.3485286041527245 +Enid,28,true,167.84237767602943,+1 1234,3.5604680454430335 +Anne,47,false,173.3829667919652,0043-1234,3.4355520047255492 +Charles,42,true,172.32056905611879,0043-1234,4.572668974525715 +Francis,26,true,153.87122280420334,0043-1234,1.1098493391586837 +Bob,38,false,154.80156174298156,+1 1234,1.09022613940166 +Charles,30,true,175.78649302190107,+1 1234,2.8155094550286215 +Anne,24,false,173.6683115337848,0043-1234,3.411235438552893 +Anne,47,true,164.74234862130373,+1 1234,4.395743541956674 +Charles,39,true,160.344588323495,0043-1234,4.617831663085374 +Anne,41,false,154.21935376553893,0043-1234,3.8765168318277285 +Enid,38,true,161.36130856960816,0043-1234,4.292390362269066 +Dylan,22,true,151.97403458363888,0043-1234,1.511052542912881 +Charles,30,true,160.8150715590866,+1 1234,3.4227094005312617 +Francis,36,false,153.92000405422075,0043-1234,2.57570905818981 +Francis,49,true,162.03322009490762,+1 1234,1.3991670356908592 +Enid,39,false,180.7453190324777,0043-1234,4.170054416280345 +Dylan,30,true,157.385569900398,0043-1234,4.456103378715245 +Enid,24,false,151.93522138656664,0043-1234,3.456877765620685 +Charles,31,false,178.33999739259212,+1 1234,4.708568405839408 +Anne,50,false,151.1441208606709,0043-1234,3.0190218516256966 +Anne,20,false,180.56604162487267,+1 1234,2.47731278312586 diff --git a/tsconfig.json b/tsconfig.json index 5056788..df7d9d9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,8 @@ "noUnusedLocals": true, "noUnusedParameters": true, "esModuleInterop": true, - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "types": ["node"] }, "include": ["."], "exclude": ["node_modules", "bench", "__test__"] diff --git a/yarn.lock b/yarn.lock index 301f211..949950c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19,6 +19,7 @@ __metadata: ava: "npm:^7.0.0" chalk: "npm:^5.6.2" csv-parse: "npm:^6.2.1" + csv-stringify: "npm:^6.7.0" husky: "npm:^9.1.7" lint-staged: "npm:^16.1.6" npm-run-all2: "npm:^8.0.4" @@ -1840,6 +1841,13 @@ __metadata: languageName: node linkType: hard +"csv-stringify@npm:^6.7.0": + version: 6.7.0 + resolution: "csv-stringify@npm:6.7.0" + checksum: 10c0/2f1272e0462c31935650b42f34cb5dafbbc9a3b479074283ec4ed6491756c791d2d6230226c530198d85002d4d3f3b69da14b1f424ab93ad3263124ea25e5ea3 + languageName: node + linkType: hard + "currently-unhandled@npm:^0.4.1": version: 0.4.1 resolution: "currently-unhandled@npm:0.4.1"