node-pandas
Version:
An npm package that incorporates minimal features of python pandas.
1,450 lines (1,354 loc) • 74.8 kB
JavaScript
/**
* @fileoverview DataFrame class for the node-pandas library.
* Provides a two-dimensional labeled data structure with columns of potentially
* different types, similar to a spreadsheet or SQL table. Extends JavaScript's
* native Array class to provide familiar array-like behavior while adding
* pandas-specific functionality.
*
* Validates: Requirements 1.2, 1.3, 1.4, 3.2, 3.3, 3.4, 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 12.1-12.8
*/
const {
dataType,
getTransformedDataList,
getIndicesColumns,
excludingColumns
} = require('../utils/utils');
const messages = require('../messages/messages');
const Series = require('../series/series');
const CsvBase = require('../bases/CsvBase');
const GroupBy = require('../features/GroupBy');
const { ValidationError, ColumnError, IndexError, OperationError } = require('../utils/errors');
const validation = require('../utils/validation');
const typeDetection = require('../utils/typeDetection');
/**
* DataFrameAtIndexer class - Fast scalar label-based accessor for DataFrame cells.
* Accessed via the DataFrame.at property, this class offers pandas-like fast
* scalar access by `(rowLabel, colName)`. Both arguments must be scalar; arrays
* or other non-scalar values are rejected. Mirrors pandas `df.at[row, col]`
* semantics.
*
* Internally, DataFrame rows are stored as objects keyed by column name, so the
* indexer reads/writes via `data[rowIdx][colName]`. The column index is still
* computed for validation and error messages.
*
* @class DataFrameAtIndexer
*
* @example
* const df = DataFrame([[1, 'Alice', 25], [2, 'Bob', 30]], ['id', 'name', 'age']);
* df.index = ['x', 'y'];
* df.at.get('x', 'name'); // 'Alice'
* df.at.set('x', 'name', 'Alicia'); // mutates in place, returns df
*/
class DataFrameAtIndexer {
/**
* Creates a new DataFrameAtIndexer instance.
*
* @param {DataFrame} df - The DataFrame instance to operate on
*
* @example
* // Typically accessed via df.at, not instantiated directly
* const atIndexer = new DataFrameAtIndexer(df);
*/
constructor(df) {
this._df = df;
}
/**
* Resolves a `(rowLabel, colName)` pair to internal indices, validating that
* both arguments are scalar and that the row label and column name exist on
* the DataFrame. Used internally by `get` and `set`.
*
* @param {string|number} rowLabel - The row label to resolve
* @param {string} colName - The column name to resolve
* @param {string} op - Operation tag for error context ('DataFrame.at.get' or 'DataFrame.at.set')
* @returns {{rowIdx: number, colIdx: number}} Resolved row and column indices
*
* @throws {ValidationError} If either argument is an array or otherwise non-scalar
* @throws {IndexError} If `rowLabel` is not present in `df.index`
* @throws {ColumnError} If `colName` is not present in `df.columns`
* @private
*/
_resolve(rowLabel, colName, op) {
if (Array.isArray(rowLabel) || (rowLabel !== null && typeof rowLabel === 'object')) {
throw new ValidationError('at accepts only scalar row labels, not arrays or objects', {
operation: op,
value: rowLabel,
expected: 'scalar'
});
}
if (Array.isArray(colName) || (colName !== null && typeof colName === 'object')) {
throw new ValidationError('at accepts only scalar column names, not arrays or objects', {
operation: op,
value: colName,
expected: 'scalar',
column: colName
});
}
const rowIdx = this._df.index.indexOf(rowLabel);
if (rowIdx === -1) {
throw new IndexError(`Row label '${rowLabel}' not found in index`, {
operation: op,
value: rowLabel,
expected: `one of ${JSON.stringify(this._df.index)}`
});
}
const colIdx = this._df.columns.indexOf(colName);
if (colIdx === -1) {
throw new ColumnError(`Column '${colName}' does not exist`, {
operation: op,
value: colName,
expected: `one of ${JSON.stringify(this._df.columns)}`,
column: colName
});
}
return { rowIdx, colIdx };
}
/**
* Gets the scalar value at the specified `(rowLabel, colName)`.
* Both arguments must be scalar.
*
* @param {string|number} rowLabel - The row label
* @param {string} colName - The column name
* @returns {*} The cell value at the given (rowLabel, colName)
*
* @throws {ValidationError} If either argument is non-scalar
* @throws {IndexError} If the row label is not in `df.index`
* @throws {ColumnError} If the column name is not in `df.columns`
*
* @example
* const df = DataFrame([[1, 'Alice'], [2, 'Bob']], ['id', 'name']);
* df.index = ['x', 'y'];
* df.at.get('x', 'name'); // 'Alice'
*/
get(rowLabel, colName) {
const { rowIdx } = this._resolve(rowLabel, colName, 'DataFrame.at.get');
return this._df.data[rowIdx][colName];
}
/**
* Sets the scalar value at the specified `(rowLabel, colName)` in place.
* Both arguments must be scalar. Returns the underlying DataFrame for chaining.
*
* @param {string|number} rowLabel - The row label
* @param {string} colName - The column name
* @param {*} value - The value to set at the cell
* @returns {DataFrame} The DataFrame instance (for chaining)
*
* @throws {ValidationError} If either argument is non-scalar
* @throws {IndexError} If the row label is not in `df.index`
* @throws {ColumnError} If the column name is not in `df.columns`
*
* @example
* const df = DataFrame([[1, 'Alice'], [2, 'Bob']], ['id', 'name']);
* df.index = ['x', 'y'];
* df.at.set('x', 'name', 'Alicia');
* df.at.get('x', 'name'); // 'Alicia'
*/
set(rowLabel, colName, value) {
const { rowIdx } = this._resolve(rowLabel, colName, 'DataFrame.at.set');
this._df.data[rowIdx][colName] = value;
return this._df;
}
}
/**
* DataFrameIatIndexer class - Fast scalar position-based accessor for DataFrame cells.
* Accessed via the DataFrame.iat property, this class offers pandas-like fast
* scalar access by `(rowPos, colPos)` integer positions. Both arguments must be
* scalar integers; arrays or non-integer values are rejected. Mirrors pandas
* `df.iat[rowPos, colPos]` semantics.
*
* Internally, DataFrame rows are stored as objects keyed by column name, so the
* indexer reads/writes via `data[rowPos][df.columns[colPos]]` after resolving
* the column name from the integer position.
*
* @class DataFrameIatIndexer
*
* @example
* const df = DataFrame([[1, 'Alice', 25], [2, 'Bob', 30]], ['id', 'name', 'age']);
* df.iat.get(0, 1); // 'Alice'
* df.iat.set(0, 1, 'Alicia'); // mutates in place, returns df
*/
class DataFrameIatIndexer {
/**
* Creates a new DataFrameIatIndexer instance.
*
* @param {DataFrame} df - The DataFrame instance to operate on
*
* @example
* // Typically accessed via df.iat, not instantiated directly
* const iatIndexer = new DataFrameIatIndexer(df);
*/
constructor(df) {
this._df = df;
}
/**
* Resolves a `(rowPos, colPos)` pair, validating that both arguments are
* scalar integers within bounds. Used internally by `get` and `set`.
*
* @param {number} rowPos - The integer row position to resolve
* @param {number} colPos - The integer column position to resolve
* @param {string} op - Operation tag for error context ('DataFrame.iat.get' or 'DataFrame.iat.set')
* @returns {{rowPos: number, colPos: number, colName: string}} Resolved positions and column name
*
* @throws {ValidationError} If either argument is an array or not an integer
* @throws {IndexError} If `rowPos` is out of `[0, df.rows - 1]`
* @throws {IndexError} If `colPos` is out of `[0, df.cols - 1]`
* @private
*/
_resolve(rowPos, colPos, op) {
if (Array.isArray(rowPos)) {
throw new ValidationError('iat accepts only a scalar integer row position, not arrays', {
operation: op,
value: rowPos,
expected: 'integer'
});
}
if (Array.isArray(colPos)) {
throw new ValidationError('iat accepts only a scalar integer column position, not arrays', {
operation: op,
value: colPos,
expected: 'integer'
});
}
if (!Number.isInteger(rowPos)) {
throw new ValidationError('iat row position must be an integer', {
operation: op,
value: rowPos,
expected: 'integer'
});
}
if (!Number.isInteger(colPos)) {
throw new ValidationError('iat column position must be an integer', {
operation: op,
value: colPos,
expected: 'integer'
});
}
if (rowPos < 0 || rowPos >= this._df.rows) {
throw new IndexError(`Row position ${rowPos} is out of bounds for DataFrame with ${this._df.rows} rows`, {
operation: op,
value: rowPos,
expected: `integer between 0 and ${this._df.rows - 1}`
});
}
if (colPos < 0 || colPos >= this._df.cols) {
throw new IndexError(`Column position ${colPos} is out of bounds for DataFrame with ${this._df.cols} columns`, {
operation: op,
value: colPos,
expected: `integer between 0 and ${this._df.cols - 1}`
});
}
return { rowPos, colPos, colName: this._df.columns[colPos] };
}
/**
* Gets the scalar value at the specified `(rowPos, colPos)`.
* Both arguments must be scalar integers.
*
* @param {number} rowPos - The integer row position
* @param {number} colPos - The integer column position
* @returns {*} The cell value at the given (rowPos, colPos)
*
* @throws {ValidationError} If either argument is an array or not an integer
* @throws {IndexError} If `rowPos` or `colPos` is out of range
*
* @example
* const df = DataFrame([[1, 'Alice'], [2, 'Bob']], ['id', 'name']);
* df.iat.get(0, 1); // 'Alice'
*/
get(rowPos, colPos) {
const { colName } = this._resolve(rowPos, colPos, 'DataFrame.iat.get');
return this._df.data[rowPos][colName];
}
/**
* Sets the scalar value at the specified `(rowPos, colPos)` in place.
* Both arguments must be scalar integers. Returns the underlying DataFrame
* for chaining.
*
* @param {number} rowPos - The integer row position
* @param {number} colPos - The integer column position
* @param {*} value - The value to set at the cell
* @returns {DataFrame} The DataFrame instance (for chaining)
*
* @throws {ValidationError} If either position argument is an array or not an integer
* @throws {IndexError} If `rowPos` or `colPos` is out of range
*
* @example
* const df = DataFrame([[1, 'Alice'], [2, 'Bob']], ['id', 'name']);
* df.iat.set(0, 1, 'Alicia');
* df.iat.get(0, 1); // 'Alicia'
*/
set(rowPos, colPos, value) {
const { colName } = this._resolve(rowPos, colPos, 'DataFrame.iat.set');
this._df.data[rowPos][colName] = value;
return this._df;
}
}
/**
* NodeDataFrame class - A two-dimensional labeled data structure.
*
* Extends JavaScript's Array class to provide array-like behavior while adding
* pandas-specific functionality for data manipulation, selection, and analysis.
* Each row is represented as an array, and columns are accessible by name.
*
* @class NodeDataFrame
* @extends Array
*
* @example
* // Create a DataFrame from a 2D array with column names
* const df = new DataFrame(
* [[1, 'Alice', 25], [2, 'Bob', 30], [3, 'Charlie', 35]],
* ['id', 'name', 'age']
* );
*
* // Access columns as Series
* const names = df.name; // Returns Series(['Alice', 'Bob', 'Charlie'])
*
* // Access rows
* const firstRow = df.getRow(0); // Returns {id: 1, name: 'Alice', age: 25}
*
* // Access cells
* const value = df.getCell(0, 'name'); // Returns 'Alice'
*
* // Display data
* df.show; // Displays formatted table
*/
class NodeDataFrame extends Array {
/**
* Creates a new DataFrame instance.
*
* @param {Array<Array>} dataList - The data as a 2D array where each inner array represents a row
* @param {Array<string>} [columns=null] - Column names. If not provided, will be auto-generated as 0, 1, 2, etc.
*
* @throws {ValidationError} If dataList is not a valid 2D array structure
* @throws {ValidationError} If columns length doesn't match the number of columns in data
* @throws {ValidationError} If column names contain duplicates
*
* @example
* // With explicit column names
* const df = new DataFrame(
* [[1, 'Alice'], [2, 'Bob']],
* ['id', 'name']
* );
*
* // Without column names (auto-generated)
* const df2 = new DataFrame([[1, 'Alice'], [2, 'Bob']]);
* // Columns will be ['0', '1']
*
* @example
* // Error handling
* try {
* const df = new DataFrame([[1, 2], [3]], ['a', 'b']); // Inconsistent row lengths
* } catch (error) {
* console.error(error.message); // "Row 1 has length 1, expected 2"
* }
*/
constructor(dataList, columns) {
// Validate input data structure
try {
validation.validateDataFrameStructure(dataList);
} catch (error) {
throw new ValidationError(`Invalid DataFrame structure: ${error.message}`, {
operation: 'DataFrame creation',
value: dataList
});
}
// Call the constructor of super class before using this keyword
super(...dataList);
let index;
// Transform data and extract columns/index if not provided
if (columns) {
try {
// Only validate column count if data is not empty
if (dataList.length > 0) {
validation.validateColumnNames(columns, dataList[0].length);
}
({ index, dataList } = getTransformedDataList(dataList, columns));
} catch (error) {
throw new ValidationError(`Invalid column names: ${error.message}`, {
operation: 'DataFrame creation',
value: columns
});
}
} else {
if (dataList.length === 0) {
// Handle empty DataFrame
index = [];
columns = [];
} else {
({ index, columns } = getIndicesColumns(dataList));
}
}
// Set properties
this.columns = columns;
this.index = index;
this.data = dataList;
this.rows = this.index.length;
this.cols = this.columns.length;
this.setDataForColumns();
this.out = true; // Output on console
}
/**
* Sets the internal data storage.
*
* @param {Array<Array>} data - The data to store
* @private
*/
set data(data) {
Object.defineProperty(this, '_data', {
value: data,
writable: true,
enumerable: false,
configurable: true
});
}
/**
* Gets the internal data storage.
*
* @returns {Array<Array>} The stored data
* @private
*/
get data() {
return this._data;
}
/**
* Displays the DataFrame in a formatted table using console.table.
*
* Provides a readable tabular representation of the DataFrame data,
* useful for debugging and data inspection during development.
*
* @returns {void}
*
* @example
* const df = new DataFrame([[1, 'Alice'], [2, 'Bob']], ['id', 'name']);
* df.show;
* // Outputs:
* // ┌─────────┬────┬───────┐
* // │ (index) │ id │ name │
* // ├─────────┼────┼───────┤
* // │ 0 │ 1 │ Alice │
* // │ 1 │ 2 │ Bob │
* // └─────────┴────┴───────┘
*/
get show() {
console.table(this.data);
}
/**
* Gets a row by index as an object with column names as keys.
*
* Returns a row object where each column name maps to its value in that row.
* This provides a convenient way to access all values in a row with their
* associated column names.
*
* @param {number} rowIndex - The zero-based row index
* @returns {Object} An object with column names as keys and row values as values
*
* @throws {IndexError} If rowIndex is out of bounds
*
* @example
* const df = new DataFrame(
* [[1, 'Alice', 25], [2, 'Bob', 30]],
* ['id', 'name', 'age']
* );
*
* const row = df.getRow(0);
* // Returns: {id: 1, name: 'Alice', age: 25}
*
* @example
* // Error handling
* try {
* df.getRow(10); // Out of bounds
* } catch (error) {
* console.error(error.message); // "Row index 10 out of range [0, 1]"
* }
*/
getRow(rowIndex) {
try {
validation.validateRowIndex(rowIndex, this.rows);
} catch (error) {
throw new IndexError(error.message, {
operation: 'row access',
value: rowIndex,
expected: `index between 0 and ${this.rows - 1}`
});
}
const row = {};
const rowData = this.data[rowIndex];
// Handle both array and object row formats
if (Array.isArray(rowData)) {
for (let i = 0; i < this.columns.length; i++) {
row[this.columns[i]] = rowData[i];
}
} else if (typeof rowData === 'object' && rowData !== null) {
// Data is already in object format
for (const col of this.columns) {
row[col] = rowData[col];
}
}
return row;
}
/**
* Gets a cell value by row index and column name.
*
* Provides direct access to a specific cell in the DataFrame by combining
* row index and column name. This is useful for accessing individual values
* without creating intermediate row or column objects.
*
* @param {number} rowIndex - The zero-based row index
* @param {string} columnName - The column name
* @returns {*} The value at the specified cell
*
* @throws {IndexError} If rowIndex is out of bounds
* @throws {ColumnError} If columnName doesn't exist
*
* @example
* const df = new DataFrame(
* [[1, 'Alice', 25], [2, 'Bob', 30]],
* ['id', 'name', 'age']
* );
*
* const value = df.getCell(0, 'name'); // Returns 'Alice'
* const age = df.getCell(1, 'age'); // Returns 30
*
* @example
* // Error handling
* try {
* df.getCell(0, 'nonexistent'); // Column doesn't exist
* } catch (error) {
* console.error(error.message); // "Column 'nonexistent' does not exist"
* }
*/
getCell(rowIndex, columnName) {
try {
validation.validateRowIndex(rowIndex, this.rows);
} catch (error) {
throw new IndexError(error.message, {
operation: 'cell access',
value: rowIndex,
expected: `index between 0 and ${this.rows - 1}`
});
}
const colIndex = this.columns.indexOf(columnName);
if (colIndex === -1) {
throw new ColumnError(`Column '${columnName}' does not exist`, {
operation: 'cell access',
column: columnName,
value: this.columns
});
}
const rowData = this.data[rowIndex];
// Handle both array and object row formats
if (Array.isArray(rowData)) {
return rowData[colIndex];
} else if (typeof rowData === 'object' && rowData !== null) {
// Data is already in object format
return rowData[columnName];
}
return undefined;
}
/**
* Fast scalar label-based accessor for individual DataFrame cells.
*
* Returns a {@link DataFrameAtIndexer} bound to this DataFrame. Mirrors
* pandas `df.at[row, col]`: both arguments must be scalar (no arrays/slices),
* and lookups go via the row label (`df.index`) and column name (`df.columns`).
*
* @returns {DataFrameAtIndexer} A label-based scalar cell accessor
*
* @example
* const df = DataFrame([[1, 'Alice'], [2, 'Bob']], ['id', 'name']);
* df.index = ['x', 'y'];
* df.at.get('x', 'name'); // 'Alice'
* df.at.set('x', 'name', 'Alicia'); // mutates in place, returns df
*/
get at() {
return new DataFrameAtIndexer(this);
}
/**
* Fast scalar position-based accessor for individual DataFrame cells.
*
* Returns a {@link DataFrameIatIndexer} bound to this DataFrame. Mirrors
* pandas `df.iat[rowPos, colPos]`: both arguments must be scalar integers
* (no arrays/slices), and lookups go via integer row position into
* `df.data` and integer column position into `df.columns`.
*
* @returns {DataFrameIatIndexer} A position-based scalar cell accessor
*
* @example
* const df = DataFrame([[1, 'Alice'], [2, 'Bob']], ['id', 'name']);
* df.iat.get(0, 1); // 'Alice'
* df.iat.set(0, 1, 'Alicia'); // mutates in place, returns df
*/
get iat() {
return new DataFrameIatIndexer(this);
}
/**
* Promotes a column to be the DataFrame's index.
*
* @param {string} columnName - The column name to promote.
* @param {Object} [options] - Options object.
* @param {boolean} [options.drop=true] - If true, the column is removed from columns; if false, it remains.
* @returns {DataFrame} A new DataFrame with the chosen column as the index.
*
* @throws {ValidationError} If columnName is not a string.
* @throws {ColumnError} If columnName is not in df.columns.
*
* @example
* const df = DataFrame([[1, 'Alice'], [2, 'Bob']], ['id', 'name']);
* const indexed = df.setIndex('id');
* // indexed.index === [1, 2], indexed.columns === ['name']
*/
setIndex(columnName, options = {}) {
if (typeof columnName !== 'string') {
throw new ValidationError('setIndex columnName must be a string', {
operation: 'setIndex',
value: columnName,
expected: 'string'
});
}
const colIdx = this.columns.indexOf(columnName);
if (colIdx === -1) {
throw new ColumnError(`Column '${columnName}' does not exist`, {
operation: 'setIndex',
column: columnName,
value: this.columns
});
}
const drop = options.drop !== false; // default true
if (this.rows === 0) {
return DataFrame([], drop ? this.columns.filter(c => c !== columnName) : [...this.columns]);
}
// Internal rows are stored as objects keyed by column name.
const newIndex = this.data.map(row => row[columnName]);
let newColumns;
let newData;
if (drop) {
newColumns = this.columns.filter((_, i) => i !== colIdx);
} else {
newColumns = [...this.columns];
}
newData = this.data.map(row => newColumns.map(col => row[col]));
const out = DataFrame(newData, newColumns);
out.index = newIndex;
return out;
}
/**
* Demotes the current index back to a regular column, or discards it.
*
* @param {Object} [options] - Options object.
* @param {boolean} [options.drop=false] - If true, discard the index entirely.
* @param {string} [options.name='index'] - Name of the new column when promoting.
* @returns {DataFrame} A new DataFrame.
*
* @throws {ValidationError} If name is not a string.
* @throws {ColumnError} If name collides with an existing column (when drop:false).
*
* @example
* const df = DataFrame([[10], [20]], ['age']);
* df.index = ['a', 'b'];
* df.resetIndex(); // columns: ['index', 'age']; data: [['a', 10], ['b', 20]]
*/
resetIndex(options = {}) {
const drop = options.drop === true; // default false
const name = options.name === undefined ? 'index' : options.name;
if (typeof name !== 'string') {
throw new ValidationError('resetIndex name must be a string', {
operation: 'resetIndex',
value: name,
expected: 'string'
});
}
if (!drop && this.columns.indexOf(name) !== -1) {
throw new ColumnError(`Column '${name}' already exists; choose a different name`, {
operation: 'resetIndex',
column: name,
expected: 'a name not in df.columns'
});
}
if (this.rows === 0) {
return DataFrame([], drop ? [...this.columns] : [name, ...this.columns]);
}
if (drop) {
const newData = this.data.map(row => this.columns.map(col => row[col]));
// Default 0..n-1 index is set automatically by getIndicesColumns; no override.
return DataFrame(newData, [...this.columns]);
}
const newColumns = [name, ...this.columns];
const newData = this.data.map((row, i) => [this.index[i], ...this.columns.map(col => row[col])]);
return DataFrame(newData, newColumns);
}
/**
* Apply a function along an axis.
*
* Two call signatures are supported for backward compatibility:
* - apply(fn, options): function-first signature (pandas-like). Applies fn
* per column (axis 0, default) or per row (axis 1). fn receives a Series.
* - apply(columnName, fn): legacy signature. Transforms a single column
* element-wise, returning a new DataFrame.
*
* @param {Function|string} fnOrCol - Function (axis-based) or column name (legacy).
* @param {Object|Function} [optionsOrFn] - Options object for axis form, or fn for legacy.
* @param {0|1} [optionsOrFn.axis=0] - 0 = per column, 1 = per row.
* @returns {Series|DataFrame} Series of scalars, or DataFrame if fn returns arrays/Series.
* @throws {ValidationError} if fn is not a function or axis is not 0/1.
* @throws {OperationError} if fn returns mixed shapes.
*/
apply(fnOrCol, optionsOrFn = {}) {
// Legacy signature dispatch: apply(columnName, fn)
if (typeof fnOrCol === 'string') {
return this._applyToColumn(fnOrCol, optionsOrFn);
}
const fn = fnOrCol;
const options = optionsOrFn || {};
if (typeof fn !== 'function') {
throw new ValidationError('apply requires a function', {
operation: 'apply', value: fn, expected: 'function'
});
}
const axis = options.axis === undefined ? 0 : options.axis;
if (axis !== 0 && axis !== 1) {
throw new ValidationError('apply axis must be 0 or 1', {
operation: 'apply', value: axis, expected: '0 or 1'
});
}
const results = [];
const resultIndex = [];
if (axis === 0) {
for (const colName of this.columns) {
const colData = this.data.map(row => row[colName]);
const colSeries = new Series(colData, { index: [...this.index], name: colName });
results.push(fn(colSeries));
resultIndex.push(colName);
}
} else {
for (let r = 0; r < this.rows; r++) {
const rowObj = this.data[r];
const rowData = this.columns.map(c => rowObj[c]);
const rowSeries = new Series(rowData, { index: [...this.columns], name: this.index[r] });
results.push(fn(rowSeries));
resultIndex.push(this.index[r]);
}
}
const isSeriesOrArray = v => Array.isArray(v) || (v && typeof v === 'object' && '_data' in v);
const shapes = results.map(r => isSeriesOrArray(r) ? 'array' : 'scalar');
const allSame = shapes.every(s => s === shapes[0]);
if (!allSame) {
throw new OperationError('apply received mixed return shapes', {
operation: 'apply', expected: 'uniform return shape'
});
}
if (shapes.length === 0 || shapes[0] === 'scalar') {
return new Series(results, { index: resultIndex });
}
const arrays = results.map(r => Array.isArray(r) ? r : r._data);
// Validate ragged arrays: all returned arrays must have the same length.
const firstLen = arrays[0].length;
if (!arrays.every(a => a.length === firstLen)) {
throw new OperationError('apply received array returns with non-uniform lengths', {
operation: 'apply',
expected: 'all returned arrays have the same length',
value: arrays.map(a => a.length)
});
}
// For axis=0, each returned array represents a column; transpose so rows=length of returned array.
// Result columns are this.columns (one column per input column).
// For axis=1, each returned array represents a row; use as-is.
// Result index is resultIndex (the row labels). Columns default to numeric '0'..'k-1'.
if (axis === 0) {
const nRows = firstLen;
const transposed = [];
for (let i = 0; i < nRows; i++) {
transposed.push(arrays.map(col => col[i]));
}
return DataFrame(transposed, [...this.columns]);
}
const out = DataFrame(arrays);
out.index = [...resultIndex];
return out;
}
/**
* Creates a cached Series for a column and stores it internally.
*
* This internal method is called when a column is first accessed to create
* and cache a Series object for that column. Subsequent accesses return the
* cached Series without recreating it.
*
* @param {string} colName - The column name
* @private
*
* @example
* // This is called internally when accessing df.columnName
* // Users should not call this directly
*/
setNewAttrib(colName) {
this[`___${colName}___`] = this.data.map((row) => row[colName]);
}
/**
* Sets up dynamic column accessors for all columns.
*
* Creates getter properties for each column name that return Series objects
* containing that column's data. This allows accessing columns using dot notation
* (e.g., df.columnName) or bracket notation (e.g., df['columnName']).
*
* The Series objects are cached after first access for performance.
*
* @private
*
* @example
* // After this is called, you can access columns like:
* const names = df.name; // Returns Series
* const ages = df['age']; // Returns Series
*/
setDataForColumns() {
// Reserved property names that should not be overridden
const reservedNames = new Set(['columns', 'index', 'data', 'rows', 'cols', 'out', 'show', 'getRow', 'getCell', 'setNewAttrib', 'setDataForColumns']);
this.columns.forEach((colName) => {
// Skip reserved property names to avoid shadowing class properties
if (reservedNames.has(colName)) {
return;
}
Object.defineProperty(NodeDataFrame.prototype, colName, {
get: function() {
// Create and cache the Series if not already created
if (this[`___${colName}___`] === undefined) {
this.setNewAttrib(colName);
}
// Return a Series object for this column
return new Series(this[`___${colName}___`]);
},
configurable: true
});
});
}
/**
* Selects specific columns from the DataFrame and returns a new DataFrame.
*
* Creates a new DataFrame containing only the specified columns, preserving
* all rows and data types. This is useful for extracting a subset of columns
* for analysis or further processing.
*
* @param {Array<string>} columnNames - Array of column names to select
* @returns {NodeDataFrame} A new DataFrame with only the selected columns
*
* @throws {ValidationError} If columnNames is not an array
* @throws {ColumnError} If any column name doesn't exist in the DataFrame
*
* @example
* const df = new DataFrame(
* [[1, 'Alice', 25], [2, 'Bob', 30], [3, 'Charlie', 35]],
* ['id', 'name', 'age']
* );
*
* // Select single column
* const nameOnly = df.select(['name']);
* // Returns DataFrame with 3 rows and 1 column: ['name']
*
* // Select multiple columns
* const idAndName = df.select(['id', 'name']);
* // Returns DataFrame with 3 rows and 2 columns: ['id', 'name']
*
* // Select in different order
* const reordered = df.select(['age', 'name', 'id']);
* // Returns DataFrame with columns in the specified order
*
* @example
* // Error handling
* try {
* df.select(['id', 'nonexistent']); // Column doesn't exist
* } catch (error) {
* console.error(error.message); // "Column 'nonexistent' does not exist"
* }
*/
select(columnNames) {
// Validate input
try {
validation.validateArray(columnNames, 'columnNames');
} catch (error) {
throw new ValidationError(`Invalid column names: ${error.message}`, {
operation: 'select',
value: columnNames
});
}
// Validate that all requested columns exist
try {
validation.validateColumnsExist(columnNames, this.columns);
} catch (error) {
throw new ColumnError(error.message, {
operation: 'select',
column: columnNames.find(col => !this.columns.includes(col)),
value: this.columns
});
}
// Create new data with only selected columns
const selectedData = this.data.map((row) => {
const newRow = {};
columnNames.forEach((colName) => {
newRow[colName] = row[colName];
});
return newRow;
});
// Create and return new DataFrame with selected columns
return new NodeDataFrame(
selectedData.map((row) => columnNames.map((col) => row[col])),
columnNames
);
}
/**
* Filters the DataFrame based on a condition function and returns a new DataFrame.
*
* Creates a new DataFrame containing only rows where the condition function
* evaluates to true. The condition function receives a row object with column
* names as keys. Multiple filters can be chained together to apply sequential
* filtering conditions.
*
* @param {Function} condition - A function that takes a row object and returns a boolean
* @returns {NodeDataFrame} A new DataFrame containing only rows that satisfy the condition
*
* @throws {ValidationError} If condition is not a function
* @throws {ColumnError} If the condition references non-existent columns
*
* @example
* const df = new DataFrame(
* [[1, 'Rishikesh Agrawani', 32], [2, 'Hemkesh Agrawani', 30], [3, 'Malinikesh Agrawani', 28]],
* ['id', 'name', 'age']
* );
*
* // Filter rows where age > 29
* const filtered = df.filter(row => row.age > 29);
* // Returns DataFrame with 2 rows (Rishikesh and Hemkesh)
*
* // Filter rows where name includes 'Agrawani'
* const allAgrawani = df.filter(row => row.name.includes('Agrawani'));
* // Returns DataFrame with all 3 rows
*
* // Chain multiple filters
* const result = df.filter(row => row.age > 28).filter(row => row.id < 3);
* // Returns DataFrame with 2 rows (Rishikesh and Hemkesh)
*
* @example
* // Filter that matches no rows
* const empty = df.filter(row => row.age > 100);
* // Returns empty DataFrame with same columns but no rows
*
* @example
* // Error handling
* try {
* df.filter('not a function'); // Invalid condition
* } catch (error) {
* console.error(error.message); // "condition must be a function"
* }
*/
filter(condition) {
// Validate input
try {
validation.validateFunction(condition, 'condition');
} catch (error) {
throw new ValidationError(`Invalid filter condition: ${error.message}`, {
operation: 'filter',
value: condition
});
}
// Filter rows based on condition
const filteredData = [];
for (let i = 0; i < this.rows; i++) {
const row = this.getRow(i);
try {
// Evaluate condition on the row
const shouldInclude = condition(row);
if (shouldInclude) {
// Convert row object back to array format for new DataFrame
const rowArray = this.columns.map(col => row[col]);
filteredData.push(rowArray);
}
} catch (error) {
// If condition throws an error, it likely references a non-existent column
throw new ColumnError(
`Filter condition error: ${error.message}`,
{
operation: 'filter',
column: 'unknown',
value: this.columns
}
);
}
}
// Create and return new DataFrame with filtered data
// For empty filtered data, we need to create a DataFrame with the same columns
// but no rows. We do this by creating a new DataFrame with empty data and then
// manually setting the columns property.
if (filteredData.length === 0) {
const emptyDf = new NodeDataFrame([]);
emptyDf.columns = this.columns;
emptyDf.cols = this.columns.length;
emptyDf.setDataForColumns();
return emptyDf;
}
return new NodeDataFrame(filteredData, this.columns);
}
/**
* Groups the DataFrame by one or more columns and returns a GroupBy object.
*
* Creates a GroupBy object that supports aggregation methods (mean, sum, count, min, max, std).
* Supports both single-column and multi-column grouping with hierarchical group organization.
*
* @param {string|Array<string>} columns - Column name(s) to group by
* @returns {GroupBy} A GroupBy object for performing aggregations
*
* @throws {ValidationError} If columns is not a string or array
* @throws {ColumnError} If any column doesn't exist in the DataFrame
*
* @example
* const df = DataFrame(
* [[1, 'Rishikesh Agrawani', 32, 'Engineering'],
* [2, 'Hemkesh Agrawani', 30, 'Sales'],
* [3, 'Malinikesh Agrawani', 28, 'Engineering']],
* ['id', 'name', 'age', 'department']
* );
*
* // Single-column grouping
* const grouped = df.groupBy('department');
* const meanAge = grouped.mean(); // Returns DataFrame with mean age by department
* const counts = grouped.count(); // Returns DataFrame with counts by department
*
* // Multi-column grouping
* const grouped2 = df.groupBy(['department', 'name']);
* const sums = grouped2.sum(); // Returns DataFrame with sums by department and name
*
* @example
* // Error handling
* try {
* df.groupBy('nonexistent'); // Column doesn't exist
* } catch (error) {
* console.error(error.message); // "Column 'nonexistent' does not exist"
* }
*/
groupBy(columns) {
return new GroupBy(this, columns);
}
/**
* Applies a transformation function to a specific column and returns a new DataFrame.
*
* Creates a new DataFrame where the specified column has been transformed by applying
* the provided function to each value in that column. All other columns remain unchanged.
* The function receives the value and its row index as parameters.
*
* @param {string} columnName - The name of the column to transform
* @param {Function} fn - Transformation function that receives (value, rowIndex) and returns transformed value
* @returns {NodeDataFrame} A new DataFrame with the transformed column
*
* @throws {ColumnError} If columnName doesn't exist in the DataFrame
* @throws {ValidationError} If fn is not a function
* @throws {Error} If the transformation function throws an error for any value
*
* @example
* const df = DataFrame(
* [[1, 'Rishikesh Agrawani', 25], [2, 'Hemkesh Agrawani', 30], [3, 'Malinikesh Agrawani', 35]],
* ['id', 'name', 'age']
* );
*
* // Transform age column by adding 5 to each value
* const transformed = df.apply('age', (value) => value + 5);
* // Returns DataFrame with ages [30, 35, 40]
*
* // Transform name column to uppercase
* const upperNames = df.apply('name', (value) => value.toUpperCase());
* // Returns DataFrame with names in uppercase
*
* // Use row index in transformation
* const withRowIndex = df.apply('id', (value, rowIndex) => value + rowIndex * 100);
* // Returns DataFrame with transformed id values
*
* @example
* // Error handling
* try {
* df.apply('nonexistent', (v) => v); // Column doesn't exist
* } catch (error) {
* console.error(error.message); // "Column 'nonexistent' does not exist"
* }
*/
_applyToColumn(columnName, fn) {
// Validate column exists
const colIndex = this.columns.indexOf(columnName);
if (colIndex === -1) {
throw new ColumnError(`Column '${columnName}' does not exist`, {
operation: 'apply',
column: columnName,
value: this.columns
});
}
// Validate function
try {
validation.validateFunction(fn, 'fn');
} catch (error) {
throw new ValidationError(`Invalid transformation function: ${error.message}`, {
operation: 'apply',
value: fn
});
}
// Transform the column
const transformedData = this.data.map((row, rowIndex) => {
const newRow = { ...row };
try {
const currentValue = row[columnName];
const transformedValue = fn(currentValue, rowIndex);
newRow[columnName] = transformedValue;
} catch (error) {
throw new Error(
`Transformation function failed at row ${rowIndex}, column '${columnName}': ${error.message}`
);
}
return newRow;
});
// Convert to array format
const arrayData = transformedData.map((row) => {
return this.columns.map(col => row[col]);
});
// Create and return new DataFrame
return new NodeDataFrame(arrayData, this.columns);
}
/**
* Applies an element-wise transformation function across all cells in the DataFrame.
*
* Creates a new DataFrame where every value has been transformed by applying the provided
* function to each cell. The function receives the value, row index, and column name as parameters.
* The structure and column names are preserved.
*
* @param {Function} fn - Transformation function that receives (value, rowIndex, columnName) and returns transformed value
* @returns {NodeDataFrame} A new DataFrame with all values transformed
*
* @throws {ValidationError} If fn is not a function
* @throws {Error} If the transformation function throws an error for any value
*
* @example
* const df = DataFrame(
* [[1, 'Rishikesh Agrawani', 25], [2, 'Hemkesh Agrawani', 30]],
* ['id', 'name', 'age']
* );
*
* // Convert all values to strings
* const allStrings = df.map((value) => String(value));
* // Returns DataFrame with all values as strings
*
* // Add row and column info to each value
* const withInfo = df.map((value, rowIndex, colName) => `${colName}[${rowIndex}]=${value}`);
* // Returns DataFrame with formatted strings
*
* // Multiply numeric values by 2, keep others unchanged
* const doubled = df.map((value) => typeof value === 'number' ? value * 2 : value);
* // Returns DataFrame with numeric values doubled
*
* @example
* // Error handling
* try {
* df.map('not a function'); // Invalid function
* } catch (error) {
* console.error(error.message); // "fn must be a function"
* }
*/
map(fn) {
// Validate function
try {
validation.validateFunction(fn, 'fn');
} catch (error) {
throw new ValidationError(`Invalid transformation function: ${error.message}`, {
operation: 'map',
value: fn
});
}
// Transform all cells
const transformedData = this.data.map((row, rowIndex) => {
const newRow = {};
try {
for (const columnName of this.columns) {
const value = row[columnName];
newRow[columnName] = fn(value, rowIndex, columnName);
}
} catch (error) {
throw new Error(
`Transformation function failed at row ${rowIndex}: ${error.message}`
);
}
return newRow;
});
// Convert to array format
const arrayData = transformedData.map((row) => {
return this.columns.map(col => row[col]);
});
// Create and return new DataFrame
return new NodeDataFrame(arrayData, this.columns);
}
/**
* Replaces values in the DataFrame and returns a new DataFrame.
*
* Replaces all occurrences of oldValue with newValue. If columnName is specified,
* only replaces values in that column. Otherwise, replaces across the entire DataFrame.
* Supports both exact value matching and function-based matching.
*
* @param {*|Function} oldValue - Value to replace or function that returns true for values to replace
* @param {*} newValue - Value to replace with
* @param {string} [columnName] - Optional column name to limit replacement to that column only
* @returns {NodeDataFrame} A new DataFrame with replacements made
*
* @throws {ColumnError} If columnName is provided but doesn't exist
* @throws {ValidationError} If oldValue is neither a value nor a function
*
* @example
* const df = DataFrame(
* [[1, 'Rishikesh Agrawani', 25], [2, 'Hemkesh Agrawani', null], [3, 'Malinikesh Agrawani', 35]],
* ['id', 'name', 'age']
* );
*
* // Replace null values with 0 in entire DataFrame
* const noNulls = df.replace(null, 0);
* // Returns DataFrame with null replaced by 0
*
* // Replace in specific column only
* const fixedAge = df.replace(null, 30, 'age');
* // Returns DataFrame with null in age column replaced by 30
*
* // Replace using a function
* const noSmallIds = df.replace((v) => v < 2, 999);
* // Returns DataFrame with values < 2 replaced by 999
*
* // Replace undefined values
* const noUndefined = df.replace(undefined, 'N/A');
* // Returns DataFrame with undefined replaced by 'N/A'
*
* @example
* // Error handling
* try {
* df.replace(1, 2, 'nonexistent'); // Column doesn't exist
* } catch (error) {
* console.error(error.message); // "Column 'nonexistent' does not exist"
* }
*/
replace(oldValue, newValue, columnName) {
// Validate columnName if provided
if (columnName !== undefined) {
const colIndex = this.columns.indexOf(columnName);
if (colIndex === -1) {
throw new ColumnError(`Column '${columnName}' does not exist`, {
operation: 'replace',
column: columnName,
value: this.columns
});
}
}
// Determine if oldValue is a function
const isFunction = typeof oldValue === 'function';
// Transform data
const transformedData = this.data.map((row) => {
const newRow = { ...row };
if (columnName !== undefined) {
// Replace in specific column only
const currentValue = row[columnName];
const shouldReplace = isFunction ? oldValue(currentValue) : currentValue === oldValue;
if (shouldReplace) {
newRow[columnName] = newValue;
}
} else {
// Replace across entire row
for (const col of this.columns) {
const shouldReplace = isFunction ? oldValue(newRow[col]) : newRow[col] === oldValue;
if (shouldReplace) {
newRow[col] = newValue;
}
}
}
return newRow;
});
// Convert to array format
const arrayData = transformedData.map((row) => {
return this.columns.map(col => row[col]);
});
// Create and return new DataFrame
return new NodeDataFrame(arrayData, this.columns);
}
/**
* Replaces values in the DataFrame and returns a new DataFrame.
*
* Replaces all occurrences of oldValue with newValue. If columnName is specified,
* only replaces values in that column. Otherwise, replaces across the entire DataFrame.
* Supports both exact value matching and function-based matching.
*
* @param {*|Function} oldValue - Value to replace or function that returns true for values to replace
* @param {*} newValue - Value to replace with
* @param {string} [columnName] - Optional column name to limit replacement to that column only
* @returns {NodeDataFrame} A new DataFrame with replacements made
*
* @throws {ColumnError} If columnName is provided but doesn't exist
* @throws {ValidationError} If oldValue is neither a value nor a function
*
* @example
* const df = DataFrame(
* [[1, 'Rishikesh Agrawani', 25], [2, 'Hemkesh Agrawani', null], [3, 'Malinikesh Agrawani', 35]],
* ['id', 'name', 'age']
* );
*
* // Replace null values with 0 in entire DataFrame
* const noNulls = df.replace(null, 0);
* // Returns DataFrame with null replaced by 0
*
* // Replace in specific column only
* const fixedAge = df.replace(null, 30, 'age');
* // Returns DataFrame with null in age column replaced by 30
*
* // Replace using a function
* const noSmallIds = df.replace((v) => v < 2, 999);
* // Returns DataFrame with values < 2 replaced by 999
*
* // Replace undefined values
* const noUndefined = df.replace(undefined, 'N/A');
* // Returns DataFrame with undefined replaced by 'N/A'
*
* @example
* // Error handling
* try {
* df.replace(1, 2, 'nonexistent'); // Column doesn't exist
* } catch (error) {
* console.error(error.message); // "Column 'nonexistent' does not exist"
* }
*/
replace(oldValue, newValue, columnName) {
// Validate columnName if provided
if (columnName !== undefined) {
const colIndex = this.columns.indexOf(columnName);
if (colIndex === -1) {
throw new ColumnError(`Column '${columnName}' does not exist`, {
operation: 'replace',
column: columnName,
value: this.columns
});
}
}
// Determine if oldValue is a function
const isFunction = typeof oldValue === 'function';
// Transform data
const transformedData = this.data.map((row) => {
const newRow = { ...row };
if (columnName !== undefined) {
// Replace in specific column only
const currentValue = row[columnName];
const shouldReplace = isFunction ? oldValue(currentValue) : currentValue === oldValue;
if (shouldReplace) {
newRow[columnName] = newValue;
}
} else {
// Replace across entire row
for (const col of this.columns) {
const shouldReplace = isFunction ? oldValue(newRow[col]) : newRow[col] === oldValue;
if (shouldReplace) {
newRow[col] = newValue;
}
}
}
return newRow;
});
// Convert to array format
const arrayData = transformedData.map((row) => {
return this.columns.map(col => row[col]);
});
// Create and return new DataFrame
return new NodeDataFrame(arrayData, this.columns);
}
/**
* Returns a summary statistics DataFrame for numeric and non-numeric columns.
*
* Generates a DataFrame containing statistical measures for all columns in the origina