import { uniqueRows, Uint8ToString, StringToUint8 } from "./util"; import { SuperArray } from "./SuperArray"; import { getColumnType } from "./ColumnType"; import { orderBy, union } from "lodash"; import { v4 as uuid } from "uuid"; let btoa; let atob; if (typeof atob === "undefined") { btoa = require("btoa"); atob = require("atob"); } else { // @ts-ignore btoa = window.btoa; // @ts-ignore atob = window.atob; } type Constructor = new (...args: any[]) => T; /** * A class representing a table of data with various utility methods for manipulation and analysis. */ export class DBTable { protected _data: rowInterface[]; protected _meta = { columnNames: [] as string[], columnTypes: {} as any, }; protected _instanceID = uuid(); protected _formatter?: (row: any) => rowInterface; /** * Creates an instance of DBTable. * @param data Array of objects representing the table rows * @param meta Optional metadata object * @param formatter Optional function to format/transform each row */ constructor(jsonTable: rowInterface[] = [], metaData?: any, formatter?: (row: any) => rowInterface) { // Copy meta data this._meta = { ...metaData }; if (formatter) { this._data = jsonTable.map(row => formatter(row)); } else { this._data = jsonTable; } } /** * currentConstructor - Reference to the constructor function of the current class instance * * This property holds the constructor function of the current DBTable instance. * It is used to ensure that methods which return new instances of the class * will create objects of the correct type, especially when working with subclasses. * * When methods like whereColumnGreaterThan() or whereColumnLessThan() create * new instances, using this.currentConstructor ensures that: * 1. The correct constructor is used for subclass instances * 2. Prototype chain is preserved * 3. Static methods/properties are properly inherited * 4. Method chaining works correctly with the right type * * Example: * class MyCustomTable extends DBTable { * customMethod() { ... } * } * * When a MyCustomTable instance calls whereColumnGreaterThan(), * currentConstructor ensures the returned instance is also MyCustomTable * * @see Used in: whereColumnGreaterThan, whereColumnLessThan, etc. */ protected get currentConstructor(): Constructor { if (this["__proto__"].constructor) return this["__proto__"].constructor; return (this as any).prototype.constructor; } /** * Returns the column types mapping. * @returns {{[key:string]: any}} An object mapping column names to their inferred types. */ getColumnTypes() { if (!this._meta.columnTypes) { const columns: any = {}; for (const row of this._data) { Object.keys(row).forEach(column => { columns[column] = getColumnType(row[column]); }); } this._meta.columnTypes = columns; } return this._meta.columnTypes; } /** * Returns an array of the table’s column names. * @returns {string[]} The sorted list of column names. */ getColumnNames() { if (!this._meta.columnNames || this._meta.columnNames.length === 0) { this._meta.columnNames = Object.keys(this.getColumnTypes()).sort((a, b) => (a > b ? 1 : -1)); } return this._meta.columnNames; } updateMeta() { const oldMeta = this._meta; this._meta = {} as any; // This will create new meta entries for column names and types this.getColumnNames(); // merge the old meta into the new this._meta = { columnNames: undefined, columnTypes: { ...this._meta.columnTypes, ...oldMeta.columnTypes }, }; this.getColumnNames(); } /** * Number of rows in the table */ get count() { return this._data.length; } /** * Gets the underlying data array. */ get data(): rowInterface[] { //return this._data.slice(0); return [...this._data]; } /** * Returns a new DBTable containing only distinct rows based on all column values. * Duplicate rows are removed by comparing all fields in each row. * * @returns {DBTable} A new DBTable instance with all duplicate rows removed. * * @example * const uniqueRows = table.distinct(); */ distinct() { return new this.currentConstructor(uniqueRows(this._data), this._meta); } /** * * @param {string} columnName * @param amount defaults to 1 * @return {DBTable} */ incrementColumn(columnName: string, amount: any = 1) { this._data.forEach(row => { // make sure the row's field exists if (row[columnName] == undefined) row[columnName] = 0; // increment the fields value row[columnName] += amount; }); return this; } /** * Unions current data with data that is passed in * @returns a new DBTable with the 'unioned' data * @param data - can be a DBTable, a row from a table, or an array of rows */ union(data: DBTable | rowInterface[] | rowInterface) { let metaData = undefined as any; let finalData: any[]; if (data instanceof DBTable) { finalData = data._data; metaData = { ...this._meta, ...data._meta }; // merge the old meta into the new this._meta = { columnNames: undefined, columnTypes: { ...this._meta.columnTypes, ...data._meta.columnTypes }, }; this.getColumnNames(); } else if (typeof data === "object") { // TODO: add support for this.formatter finalData = [data]; } else { // TODO: add support for this.formatter finalData = (data as any) as rowInterface[]; } return new this.currentConstructor(union(this._data, finalData), metaData); } /** * * @param {DBTable} table * @param {string} column * @param {boolean} overwrite * @return {DBTable} */ mergeTableBy(table: DBTable, column: keyof rowInterface, overwrite: boolean = false) { let results: DBTable = new this.currentConstructor(this.data, { ...this._meta, ...table._meta }); table.data.reverse().forEach(row => { // Are there any rows that already have the same key let rowCollisionData = results.whereColumnEquals(column, row[column]); let rowCollision = rowCollisionData.count > 0; if (rowCollision && !overwrite) { return; } if (!rowCollision) { results._data.unshift(row); } else { let insertIndex = results._data.indexOf(rowCollisionData.data[0]); results._data[insertIndex] = { ...results._data[insertIndex], ...row }; } }); return results; } /** * * @param column * @param order */ orderBy(column: keyof rowInterface, order: "ASC" | "DESC" = "ASC") { // let factor = order.toLowerCase() === "asc" ? 1 : -1; let newData = orderBy(this.data, [column], [order.toLowerCase() as any]); return new this.currentConstructor(newData, this._meta); } /** * Returns a new `DBTable` with the rows randomly shuffled. * The original table remains unchanged. * @returns {DBTable} A shuffled copy of the table. */ shuffle() { let newData = this._data.sort(() => (Math.random() > 0.5 ? -1 : 1)); return new this.currentConstructor(newData, this._meta); } /** * Removes a column from the table. * The method clears the column name list, deletes the type entry and removes the property from every row. * @param columnName - Name of the column to delete. * @returns {DBTable} The current instance for chaining. */ removeColumn(columnName: keyof rowInterface | string) { this._meta.columnNames = []; if (this?._meta?.columnTypes) delete this._meta.columnTypes[columnName]; this._data.forEach(row => delete row[columnName as string]); return this; } /** * Returns a new `DBTable` containing a subset of the columns from the original table. * * This method allows you to select specific columns from the table, creating a new table with only those columns. * It is useful for reducing the amount of data processed or displayed, or for creating views tailored to specific needs. * * @param {string[]} columns - An array of column names to include in the new table. * @returns {DBTable} A new `DBTable` containing only the selected columns. Returns a new instance; does not modify the original table. * * @example * // Select only the 'name' and 'age' columns * const selectedTable = dbTable.select(['name', 'age']); * * @example * // Select a single column * const nameTable = dbTable.select(['name']); */ select(columns: (keyof rowInterface)[], distinct: boolean = true): DBTable> { // Todo: change distinct to require a value in version 0.6.x and default to false in 1.x.x // TODO: support meta changes!!! let result = this._data.map(row => { let newRow: any = {}; columns.forEach(column => (newRow[column] = row[column])); return newRow; }); if (distinct) result = uniqueRows(result as any); return new this.currentConstructor(result); } /** * Sets the specified column to a given value for all rows in the table. * This mutates the current instance and returns it for chaining. * @param {keyof rowInterface} column - The name of the column to update. * @param {*} value - The value to assign to every row's column. * @returns {this} The updated table instance. */ setColumn(column: keyof rowInterface, value: any) { this._data.forEach(row => (row[column as string] = value)); return this; } /** * This is an experimental feature * @experimental * @param columnName */ public listDistinctValues(columnName: keyof rowInterface, verbose: boolean = false) { const rowsToProcess = this.select([columnName], false); const resultsTable = new DBTable<{ value: string; count: number }>(); // rowsToProcess._data.forEach((row, index, array) => { let values = (row as any)[columnName].split(","); for (const value of values) { // Has this value already been registered? If so increment its count const alreadyRegistered = resultsTable.whereColumnEquals("value", value).incrementColumn("count").count; // Otherwise add it to the list if (!alreadyRegistered) { resultsTable._data.push({ value, count: 1 }); } } if (verbose) console.log(((100 * index) / array.length).toFixed(2) + "% done..."); }); return resultsTable.distinct(); } /** * Returns the last N rows of the table. * * This method is helpful when you want to examine or analyze the last few entries in * a dataset, such as the most recent 10 records. * * @param n The number of rows to retrieve from the bottom of the table. * @returns A new `DBTable` containing the last `n` rows. * * @example * const recentRows = dbTable.bottom(10); // Get the last 10 rows */ bottom(count: number) { let results = this._data.slice(count); return new this.currentConstructor(results, this._meta); } /** * Returns the first N rows of the table. * * This method is useful when you want to quickly inspect or process the initial records * in a dataset, such as the top 10 results. * * @param n The number of rows to retrieve from the top of the table. * @returns A new `DBTable` containing the first `n` rows. * * @example * const topRows = dbTable.top(5); // Get the first 5 rows */ top(count: number) { let results = this._data.slice(0, count); return new this.currentConstructor(results, this._meta); } /** * Splits the data table into two new tables. * * This method divides the data into two parts based on a specified count. * The first table contains the first `count` rows, and the second table contains the remaining rows. * This is useful for dividing a dataset into training and testing sets, or for processing data in batches. * * @param {number} count - The number of rows to include in the first table. * @returns {[DBTable, DBTable]} An array containing two new `DBTable` instances: * - The first table contains the first `count` rows. * - The second table contains the remaining rows. */ split(count: number) { return [this.top(count), this.bottom(count)]; } /** * Skips a specified number of rows from the beginning of the data table. * @param {number} count - The number of rows to skip * @returns {this} A new instance with the skipped rows */ skip(count: number) { let results = this._data.slice(count); return new this.currentConstructor(results, this._meta); } /** * Filters rows where the specified column's value contains the given substring. * * @param column - The name of the column to check. Must be a key of the row interface. * @param value - The substring to search for within the column values. * @returns A new DBTable containing only the rows where the column value includes the specified substring. * * @example * // Filter rows where the "name" column contains "john" * const result = table.whereColumnContains("name", "john"); */ whereColumnContains(column: keyof rowInterface, value: number | string, caseSensitive: boolean = true) { let results = this._data.filter(row => { let columnValue = row[column as string]; if (typeof columnValue !== "string") return false; if (caseSensitive) return (columnValue as any).includes(value); else { if (typeof value === "string") value = (value as string).toLowerCase(); return (columnValue.toLowerCase() as any).includes(value); } }); return new this.currentConstructor(results, this._meta); } /** * Filters the data table based on a condition. * @param {Function} callback - A function that returns true for rows to include * @returns {this} A new instance with the filtered rows */ filter(test: (row: rowInterface) => boolean) { return new this.currentConstructor(this._data.filter(test), this._meta); } /** * Filters rows where the specified column equals a given value. * @param column - Column name to compare. * @param value - Value that the column must equal. * @returns {DBTable} A new table containing only matching rows. */ whereColumnEquals(column: keyof rowInterface, value: any) { return new this.currentConstructor( this._data.filter(row => row[column as string] == value), this._meta ); } /** * * @param column * @param value * @return {DBTable} */ whereColumnNotEquals(column: keyof rowInterface, value: any) { return new this.currentConstructor( this._data.filter(row => row[column as string] != value), this._meta ); } /** * * @param {string} column * @param {number | string} value * @return {this} */ whereColumnGreaterThanEquals(column: keyof rowInterface, value: number | string) { return new this.currentConstructor( this._data.filter(row => row[column as string] >= value), this._meta ); } /** * * @param {string} column * @param {number | string} value * @return {this} */ whereColumnLessThanEquals(column: keyof rowInterface, value: number | string) { return new this.currentConstructor( this._data.filter(row => row[column as string] <= value), this._meta ); } /** * Filters rows where the specified column’s value is greater than a given threshold. * The comparison uses JavaScript's native `>` operator, so numeric and string * values are compared according to their natural ordering. For strings this * means lexicographic order based on Unicode code points. * * @param column - The key of the column to compare. * @param value - The threshold; rows with a column value strictly greater than * this value will be included in the result. * @returns A new {@link DBTable} instance containing only the matching rows. */ whereColumnGreaterThan(column: keyof rowInterface, value: number | string) { return new this.currentConstructor( this._data.filter(row => row[column as string] > value), this._meta ); } /** * Filters rows where the specified column’s value is less than a given threshold. * The comparison uses JavaScript's native `<` operator, so numeric and string * values are compared according to their natural ordering. For strings this * means lexicographic order based on Unicode code points. * * @param column - The key of the column to compare. * @param value - The threshold; rows with a column value strictly less than * this value will be included in the result. * @returns A new {@link DBTable} instance containing only the matching rows. */ whereColumnLessThan(column: keyof rowInterface, value: number | string) { return new this.currentConstructor( this._data.filter(row => row[column as string] < value), this._meta ); } /** * Creates a {@link DBTable} instance from a JSON string. * The JSON is expected to be either an array of rows or an object * containing `data` and optional `meta` properties. If the input * contains a `data` property, that value will be used as the table * data; otherwise the entire parsed value is treated as the data. * * @param json - The JSON string to parse. An empty string defaults to an empty array. * @param formatter - Optional function applied to each row after parsing. * @returns A new {@link DBTable} instance populated with the parsed rows and metadata. */ public static fromJson(json: string, formatter?: (row: any) => rowInterface) { const data = JSON.parse(json || "[]"); if (data.data) { return new DBTable(data.data, data.meta, formatter); } else { return new DBTable(data, undefined, formatter); } } /** * Serialises the table to a JSON string. * @param pretty Number of spaces for indentation (default 2). * @returns A JSON representation containing `data` and `meta`. */ toJSON(pretty: number = 2) { return JSON.stringify( { data: this._data, meta: this._meta, }, null, pretty ); } /** * Converts the table’s columns into `SuperArray` instances. * Each column becomes a `SuperArray` containing all values from that column. * @returns {{[K in keyof rowInterface]: SuperArray}} An object mapping column names to their corresponding `SuperArray`. */ toSuperArrays() { const columns = Object.keys(this._data[0]); const result: { [K in keyof rowInterface]: SuperArray } = {} as any; // loop through columns for (const columnName of columns) { const column = []; // loop through rows for (const row of this._data) { column.push(row[columnName]); } result[columnName] = new SuperArray(...column); } return result; } /** * * @param input */ static fromSuperArrays(input) { const columns = Object.keys(input); const { length } = input[columns[0]]; const data: any = []; // loop through rows for (let i = 0; i < length; i++) { const row: any = {}; // loop through columns for (const columnName of columns) { row[columnName] = input[columnName][i]; } data.push(row); } return new DBTable(data); } }