diff --git a/package.json b/package.json index 9aff0a98..3b02281e 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dist/**/*.js" ], "dependencies": { + "d3-array": "^3.2.0", "d3-dsv": "^2.0.0", "d3-require": "^1.3.0" }, diff --git a/src/index.mjs b/src/index.mjs index 04ae2226..57e73780 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -1,3 +1,3 @@ export {default as FileAttachments, AbstractFile} from "./fileAttachment.mjs"; export {default as Library} from "./library.mjs"; -export {makeQueryTemplate} from "./table.mjs"; +export {makeQueryTemplate, isDataArray, isDatabaseClient} from "./table.mjs"; diff --git a/src/table.mjs b/src/table.mjs index 04f7798f..0cd757ac 100644 --- a/src/table.mjs +++ b/src/table.mjs @@ -1,12 +1,153 @@ +import {ascending, descending, reverse} from "d3-array"; + +const nChecks = 20; // number of values to check in each array + +// We support two levels of DatabaseClient. The simplest DatabaseClient +// implements only the client.sql tagged template literal. More advanced +// DatabaseClients implement client.query and client.queryStream, which support +// streaming and abort, and the client.queryTag tagged template literal is used +// to translate the contents of a SQL cell or Table cell into the appropriate +// arguments for calling client.query or client.queryStream. For table cells, we +// additionally require client.describeColumns. The client.describeTables method +// is optional. +export function isDatabaseClient(value, mode) { + return ( + value && + (typeof value.sql === "function" || + (typeof value.queryTag === "function" && + (typeof value.query === "function" || + typeof value.queryStream === "function"))) && + (mode !== "table" || typeof value.describeColumns === "function") && + value !== __query // don’t match our internal helper + ); +} + +// Returns true if the value is a typed array (for a single-column table), or if +// it’s an array. In the latter case, the elements of the array must be +// consistently typed: either plain objects or primitives or dates. +export function isDataArray(value) { + return ( + (Array.isArray(value) && + (isQueryResultSetSchema(value.schema) || + isQueryResultSetColumns(value.columns) || + arrayContainsObjects(value) || + arrayContainsPrimitives(value) || + arrayContainsDates(value))) || + isTypedArray(value) + ); +} + +// Given an array, checks that the given value is an array that does not contain +// any primitive values (at least for the first few values that we check), and +// that the first object contains enumerable keys (see computeSchema for how we +// infer the columns). We assume that the contents of the table are homogenous, +// but we don’t currently enforce this. +// https://observablehq.com/@observablehq/database-client-specification#§1 +function arrayContainsObjects(value) { + const n = Math.min(nChecks, value.length); + for (let i = 0; i < n; ++i) { + const v = value[i]; + if (v === null || typeof v !== "object") return false; + } + return n > 0 && objectHasEnumerableKeys(value[0]); +} + +// Using a for-in loop here means that we can abort after finding at least one +// enumerable key (whereas Object.keys would require materializing the array of +// all keys, which would be considerably slower if the value has many keys!). +// This function assumes that value is an object; see arrayContainsObjects. +function objectHasEnumerableKeys(value) { + for (const _ in value) return true; + return false; +} + +function isQueryResultSetSchema(schemas) { + return (Array.isArray(schemas) && schemas.every((s) => s && typeof s.name === "string")); +} + +function isQueryResultSetColumns(columns) { + return (Array.isArray(columns) && columns.every((name) => typeof name === "string")); +} + +// Returns true if the value represents an array of primitives (i.e., a +// single-column table). This should only be passed values for which +// canDisplayTable returns true. +function arrayIsPrimitive(value) { + return ( + isTypedArray(value) || + arrayContainsPrimitives(value) || + arrayContainsDates(value) + ); +} + +// Given an array, checks that the first n elements are primitives (number, +// string, boolean, bigint) of a consistent type. +function arrayContainsPrimitives(value) { + const n = Math.min(nChecks, value.length); + if (!(n > 0)) return false; + let type; + let hasPrimitive = false; // ensure we encounter 1+ primitives + for (let i = 0; i < n; ++i) { + const v = value[i]; + if (v == null) continue; // ignore null and undefined + const t = typeof v; + if (type === undefined) { + switch (t) { + case "number": + case "boolean": + case "string": + case "bigint": + type = t; + break; + default: + return false; + } + } else if (t !== type) { + return false; + } + hasPrimitive = true; + } + return hasPrimitive; +} + +// Given an array, checks that the first n elements are dates. +function arrayContainsDates(value) { + const n = Math.min(nChecks, value.length); + if (!(n > 0)) return false; + let hasDate = false; // ensure we encounter 1+ dates + for (let i = 0; i < n; ++i) { + const v = value[i]; + if (v == null) continue; // ignore null and undefined + if (!(v instanceof Date)) return false; + hasDate = true; + } + return hasDate; +} + +function isTypedArray(value) { + return ( + value instanceof Int8Array || + value instanceof Int16Array || + value instanceof Int32Array || + value instanceof Uint8Array || + value instanceof Uint8ClampedArray || + value instanceof Uint16Array || + value instanceof Uint32Array || + value instanceof Float32Array || + value instanceof Float64Array + ); +} + +// __query is used by table cells; __query.sql is used by SQL cells. export const __query = Object.assign( - // This function is used by table cells. async (source, operations, invalidation) => { - const args = makeQueryTemplate(operations, await source); - if (!args) return null; // the empty state - return evaluateQuery(await source, args, invalidation); + source = await source; + if (isDatabaseClient(source)) return evaluateQuery(source, makeQueryTemplate(operations, source), invalidation); + if (isDataArray(source)) return __table(source, operations); + if (!source) throw new Error("missing data source"); + throw new Error("invalid data source"); }, { - // This function is used by SQL cells. sql(source, invalidation) { return async function () { return evaluateQuery(source, arguments, invalidation); @@ -16,7 +157,7 @@ export const __query = Object.assign( ); async function evaluateQuery(source, args, invalidation) { - if (!source) return; + if (!source) throw new Error("missing data source"); // If this DatabaseClient supports abort and streaming, use that. if (typeof source.queryTag === "function") { @@ -73,17 +214,15 @@ async function* accumulateQuery(queryRequest) { * of sub-strings and params are the parameter values to be inserted between each * sub-string. */ - export function makeQueryTemplate(operations, source) { +export function makeQueryTemplate(operations, source) { const escaper = - source && typeof source.escape === "function" ? source.escape : (i) => i; + typeof source.escape === "function" ? source.escape : (i) => i; const {select, from, filter, sort, slice} = operations; - if ( - from.table === null || - select.columns === null || - (select.columns && select.columns.length === 0) - ) - return; - const columns = select.columns.map((c) => `t.${escaper(c)}`); + if (!from.table) + throw new Error("missing from table"); + if (select.columns?.length === 0) + throw new Error("at least one column must be selected"); + const columns = select.columns ? select.columns.map((c) => `t.${escaper(c)}`) : "*"; const args = [ [`SELECT ${columns} FROM ${formatTable(from.table, escaper)} t`] ]; @@ -108,7 +247,7 @@ async function* accumulateQuery(queryRequest) { } function formatTable(table, escaper) { - if (typeof table === "object") { + if (typeof table === "object") { // i.e., not a bare string specifier let from = ""; if (table.database != null) from += escaper(table.database) + "."; if (table.schema != null) from += escaper(table.schema) + "."; @@ -231,3 +370,113 @@ function likeOperand(operand) { return {...operand, value: `%${operand.value}%`}; } +// This function applies table cell operations to an in-memory table (array of +// objects); it should be equivalent to the corresponding SQL query. +export function __table(source, operations) { + if (arrayIsPrimitive(source)) source = Array.from(source, (value) => ({value})); + const input = source; + let {schema, columns} = source; + for (const {type, operands} of operations.filter) { + const [{value: column}] = operands; + const values = operands.slice(1).map(({value}) => value); + switch (type) { + case "eq": { + const [value] = values; + if (value instanceof Date) { + const time = +value; // compare as primitive + source = source.filter((d) => +d[column] === time); + } else { + source = source.filter((d) => d[column] === value); + } + break; + } + case "ne": { + const [value] = values; + source = source.filter((d) => d[column] !== value); + break; + } + case "c": { + const [value] = values; + source = source.filter( + (d) => typeof d[column] === "string" && d[column].includes(value) + ); + break; + } + case "nc": { + const [value] = values; + source = source.filter( + (d) => typeof d[column] === "string" && !d[column].includes(value) + ); + break; + } + case "in": { + const set = new Set(values); // TODO support dates? + source = source.filter((d) => set.has(d[column])); + break; + } + case "nin": { + const set = new Set(values); // TODO support dates? + source = source.filter((d) => !set.has(d[column])); + break; + } + case "n": { + source = source.filter((d) => d[column] == null); + break; + } + case "nn": { + source = source.filter((d) => d[column] != null); + break; + } + case "lt": { + const [value] = values; + source = source.filter((d) => d[column] < value); + break; + } + case "lte": { + const [value] = values; + source = source.filter((d) => d[column] <= value); + break; + } + case "gt": { + const [value] = values; + source = source.filter((d) => d[column] > value); + break; + } + case "gte": { + const [value] = values; + source = source.filter((d) => d[column] >= value); + break; + } + default: + throw new Error(`unknown filter type: ${type}`); + } + } + for (const {column, direction} of reverse(operations.sort)) { + const compare = direction === "desc" ? descending : ascending; + if (source === input) source = source.slice(); // defensive copy + source.sort((a, b) => compare(a[column], b[column])); + } + let {from, to} = operations.slice; + from = from == null ? 0 : Math.max(0, from); + to = to == null ? Infinity : Math.max(0, to); + if (from > 0 || to < Infinity) { + source = source.slice(Math.max(0, from), Math.max(0, to)); + } + if (operations.select.columns) { + if (schema) { + const schemaByName = new Map(schema.map((s) => [s.name, s])); + schema = operations.select.columns.map((c) => schemaByName.get(c)); + } + if (columns) { + columns = operations.select.columns; + } + source = source.map((d) => + Object.fromEntries(operations.select.columns.map((c) => [c, d[c]])) + ); + } + if (source !== input) { + if (schema) source.schema = schema; + if (columns) source.columns = columns; + } + return source; +} diff --git a/test/table-test.mjs b/test/table-test.mjs index f6b226f3..c65e180b 100644 --- a/test/table-test.mjs +++ b/test/table-test.mjs @@ -1,4 +1,4 @@ -import {makeQueryTemplate} from "../src/table.mjs"; +import {makeQueryTemplate, __table} from "../src/table.mjs"; import assert from "assert"; export const EMPTY_TABLE_DATA = { @@ -35,178 +35,302 @@ const baseOperations = { } }; -it("makeQueryTemplate null table", () => { - const source = {}; - assert.strictEqual(makeQueryTemplate(EMPTY_TABLE_DATA.operations, source), undefined); -}); - -it("makeQueryTemplate no selected columns", () => { - const source = {name: "db", dialect: "postgres"}; - const operationsColumnsNull = {...baseOperations, select: {columns: null}}; - assert.strictEqual(makeQueryTemplate(operationsColumnsNull, source), undefined); - const operationsColumnsEmpty = {...baseOperations, select: {columns: []}}; - assert.strictEqual(makeQueryTemplate(operationsColumnsEmpty, source), undefined); -}); +describe("makeQueryTemplate", () => { + it("makeQueryTemplate null table", () => { + const source = {}; + assert.throws(() => makeQueryTemplate(EMPTY_TABLE_DATA.operations, source), /missing from table/); + }); -it("makeQueryTemplate invalid filter operation", () => { - const source = {name: "db", dialect: "postgres"}; - const invalidFilters = [ - { - type: "n", - operands: [ - {type: "column", value: "col1"}, - {type: "primitive", value: "val1"} - ] - }, - { - type: "eq", - operands: [{type: "column", value: "col1"}] - }, - { - type: "lt", - operands: [ - {type: "column", value: "col1"}, - {type: "primitive", value: "val1"}, - {type: "primitive", value: "val2"} - ] - } - ]; + it("makeQueryTemplate no selected columns", () => { + const source = {name: "db", dialect: "postgres"}; + const operationsColumnsEmpty = {...baseOperations, select: {columns: []}}; + assert.throws(() => makeQueryTemplate(operationsColumnsEmpty, source), /at least one column must be selected/); + }); - invalidFilters.map((filter) => { - const operations = { - ...baseOperations, - filter: [filter] - }; - assert.throws(() => makeQueryTemplate(operations, source), /Invalid filter operation/); + it("makeQueryTemplate select all", () => { + const source = {name: "db", dialect: "postgres"}; + const operationsColumnsNull = {...baseOperations, select: {columns: null}}; + assert.deepStrictEqual(makeQueryTemplate(operationsColumnsNull, source), [["SELECT * FROM table1 t"]]); }); -}); -it("makeQueryTemplate filter", () => { - const source = {name: "db", dialect: "postgres"}; - const operations = { - ...baseOperations, - filter: [ + it("makeQueryTemplate invalid filter operation", () => { + const source = {name: "db", dialect: "postgres"}; + const invalidFilters = [ { - type: "eq", + type: "n", operands: [ {type: "column", value: "col1"}, - {type: "primitive", value: "val1"} + {type: "resolved", value: "val1"} ] - } - ] - }; - - const [parts, ...params] = makeQueryTemplate(operations, source); - assert.deepStrictEqual(parts.join("?"), "SELECT t.col1,t.col2 FROM table1 t\nWHERE t.col1 = ?"); - assert.deepStrictEqual(params, ["val1"]); -}); - -it("makeQueryTemplate filter list", () => { - const source = {name: "db", dialect: "postgres"}; - const operations = { - ...baseOperations, - filter: [ + }, { - type: "in", - operands: [ - {type: "column", value: "col1"}, - {type: "primitive", value: "val1"}, - {type: "primitive", value: "val2"}, - {type: "primitive", value: "val3"} - ] + type: "eq", + operands: [{type: "column", value: "col1"}] }, { - type: "nin", + type: "lt", operands: [ {type: "column", value: "col1"}, - {type: "primitive", value: "val4"} + {type: "resolved", value: "val1"}, + {type: "resolved", value: "val2"} ] } - ] - }; + ]; - const [parts, ...params] = makeQueryTemplate(operations, source); - assert.deepStrictEqual(parts.join("?"), "SELECT t.col1,t.col2 FROM table1 t\nWHERE t.col1 IN (?,?,?)\nAND t.col1 NOT IN (?)"); - assert.deepStrictEqual(params, ["val1", "val2", "val3", "val4"]); -}); + invalidFilters.map((filter) => { + const operations = { + ...baseOperations, + filter: [filter] + }; + assert.throws(() => makeQueryTemplate(operations, source), /Invalid filter operation/); + }); + }); -it("makeQueryTemplate select", () => { - const source = {name: "db", dialect: "mysql"}; - const operations = { - ...baseOperations, - select: { - columns: ["col1", "col2", "col3"] - } - }; + it("makeQueryTemplate filter", () => { + const source = {name: "db", dialect: "postgres"}; + const operations = { + ...baseOperations, + filter: [ + { + type: "eq", + operands: [ + {type: "column", value: "col1"}, + {type: "resolved", value: "val1"} + ] + } + ] + }; - const [parts, ...params] = makeQueryTemplate(operations, source); - assert.deepStrictEqual(parts.join("?"), "SELECT t.col1,t.col2,t.col3 FROM table1 t"); - assert.deepStrictEqual(params, []); -}); + const [parts, ...params] = makeQueryTemplate(operations, source); + assert.deepStrictEqual(parts.join("?"), "SELECT t.col1,t.col2 FROM table1 t\nWHERE t.col1 = ?"); + assert.deepStrictEqual(params, ["val1"]); + }); -it("makeQueryTemplate sort", () => { - const source = {name: "db", dialect: "mysql"}; - const operations = { - ...baseOperations, - sort: [ - {column: "col1", direction: "asc"}, - {column: "col2", direction: "desc"} - ] - }; - - const [parts, ...params] = makeQueryTemplate(operations, source); - assert.deepStrictEqual(parts.join("?"), "SELECT t.col1,t.col2 FROM table1 t\nORDER BY t.col1 ASC, t.col2 DESC"); - assert.deepStrictEqual(params, []); -}); + it("makeQueryTemplate filter list", () => { + const source = {name: "db", dialect: "postgres"}; + const operations = { + ...baseOperations, + filter: [ + { + type: "in", + operands: [ + {type: "column", value: "col1"}, + {type: "resolved", value: "val1"}, + {type: "resolved", value: "val2"}, + {type: "resolved", value: "val3"} + ] + }, + { + type: "nin", + operands: [ + {type: "column", value: "col1"}, + {type: "resolved", value: "val4"} + ] + } + ] + }; -it("makeQueryTemplate slice", () => { - const source = {name: "db", dialect: "mysql"}; - const operations = {...baseOperations}; + const [parts, ...params] = makeQueryTemplate(operations, source); + assert.deepStrictEqual(parts.join("?"), "SELECT t.col1,t.col2 FROM table1 t\nWHERE t.col1 IN (?,?,?)\nAND t.col1 NOT IN (?)"); + assert.deepStrictEqual(params, ["val1", "val2", "val3", "val4"]); + }); - operations.slice = {from: 10, to: 20}; - let [parts, ...params] = makeQueryTemplate(operations, source); - assert.deepStrictEqual(parts.join("?"), "SELECT t.col1,t.col2 FROM table1 t\nLIMIT 10 OFFSET 10"); - assert.deepStrictEqual(params, []); + it("makeQueryTemplate select", () => { + const source = {name: "db", dialect: "mysql"}; + const operations = { + ...baseOperations, + select: { + columns: ["col1", "col2", "col3"] + } + }; - operations.slice = {from: null, to: 20}; - [parts, ...params] = makeQueryTemplate(operations, source); - assert.deepStrictEqual(parts.join("?"), "SELECT t.col1,t.col2 FROM table1 t\nLIMIT 20"); - assert.deepStrictEqual(params, []); + const [parts, ...params] = makeQueryTemplate(operations, source); + assert.deepStrictEqual(parts.join("?"), "SELECT t.col1,t.col2,t.col3 FROM table1 t"); + assert.deepStrictEqual(params, []); + }); - operations.slice = {from: 10, to: null}; - [parts, ...params] = makeQueryTemplate(operations, source); - assert.deepStrictEqual(parts.join("?"), `SELECT t.col1,t.col2 FROM table1 t\nLIMIT ${1e9} OFFSET 10`); - assert.deepStrictEqual(params, []); -}); + it("makeQueryTemplate sort", () => { + const source = {name: "db", dialect: "mysql"}; + const operations = { + ...baseOperations, + sort: [ + {column: "col1", direction: "asc"}, + {column: "col2", direction: "desc"} + ] + }; -it("makeQueryTemplate select, sort, slice, filter indexed", () => { - const source = {name: "db", dialect: "postgres"}; - const operations = { - ...baseOperations, - select: { - columns: ["col1", "col2", "col3"] - }, - sort: [{column: "col1", direction: "asc"}], - slice: {from: 10, to: 100}, - filter: [ - { - type: "gte", - operands: [ - {type: "column", value: "col1"}, - {type: "primitive", value: "val1"} - ] + const [parts, ...params] = makeQueryTemplate(operations, source); + assert.deepStrictEqual(parts.join("?"), "SELECT t.col1,t.col2 FROM table1 t\nORDER BY t.col1 ASC, t.col2 DESC"); + assert.deepStrictEqual(params, []); + }); + + it("makeQueryTemplate slice", () => { + const source = {name: "db", dialect: "mysql"}; + const operations = {...baseOperations}; + + operations.slice = {from: 10, to: 20}; + let [parts, ...params] = makeQueryTemplate(operations, source); + assert.deepStrictEqual(parts.join("?"), "SELECT t.col1,t.col2 FROM table1 t\nLIMIT 10 OFFSET 10"); + assert.deepStrictEqual(params, []); + + operations.slice = {from: null, to: 20}; + [parts, ...params] = makeQueryTemplate(operations, source); + assert.deepStrictEqual(parts.join("?"), "SELECT t.col1,t.col2 FROM table1 t\nLIMIT 20"); + assert.deepStrictEqual(params, []); + + operations.slice = {from: 10, to: null}; + [parts, ...params] = makeQueryTemplate(operations, source); + assert.deepStrictEqual(parts.join("?"), `SELECT t.col1,t.col2 FROM table1 t\nLIMIT ${1e9} OFFSET 10`); + assert.deepStrictEqual(params, []); + }); + + it("makeQueryTemplate select, sort, slice, filter indexed", () => { + const source = {name: "db", dialect: "postgres"}; + const operations = { + ...baseOperations, + select: { + columns: ["col1", "col2", "col3"] }, - { - type: "eq", - operands: [ - {type: "column", value: "col2"}, - {type: "primitive", value: "val2"} - ] - } - ] - }; + sort: [{column: "col1", direction: "asc"}], + slice: {from: 10, to: 100}, + filter: [ + { + type: "gte", + operands: [ + {type: "column", value: "col1"}, + {type: "resolved", value: "val1"} + ] + }, + { + type: "eq", + operands: [ + {type: "column", value: "col2"}, + {type: "resolved", value: "val2"} + ] + } + ] + }; + + const [parts, ...params] = makeQueryTemplate(operations, source); + assert.deepStrictEqual(parts.join("?"), "SELECT t.col1,t.col2,t.col3 FROM table1 t\nWHERE t.col1 >= ?\nAND t.col2 = ?\nORDER BY t.col1 ASC\nLIMIT 90 OFFSET 10"); + assert.deepStrictEqual(params, ["val1", "val2"]); + }); +}); - const [parts, ...params] = makeQueryTemplate(operations, source); - assert.deepStrictEqual(parts.join("?"), "SELECT t.col1,t.col2,t.col3 FROM table1 t\nWHERE t.col1 >= ?\nAND t.col2 = ?\nORDER BY t.col1 ASC\nLIMIT 90 OFFSET 10"); - assert.deepStrictEqual(params, ["val1", "val2"]); +describe("__table", () => { + let source; + + beforeEach(() => { + source = [{a: 1, b: 2, c: 3}, {a: 2, b: 4, c: 6}, {a: 3, b: 6, c: 9}]; + }); + + it("__table no operations", () => { + assert.deepStrictEqual(__table(source, EMPTY_TABLE_DATA.operations), source); + }); + + it("__table columns", () => { + const operationsNullColumns = {...EMPTY_TABLE_DATA.operations, select: {columns: null}}; + assert.deepStrictEqual(__table(source, operationsNullColumns), source); + const operationsEmptyColumns = {...EMPTY_TABLE_DATA.operations, select: {columns: []}}; + assert.deepStrictEqual(__table(source, operationsEmptyColumns), [{}, {}, {}]); + const operationsSelectedColumns = {...EMPTY_TABLE_DATA.operations, select: {columns: ["a"]}}; + assert.deepStrictEqual(__table(source, operationsSelectedColumns), [{a: 1}, {a: 2}, {a: 3}]); + }); + + it("__table unknown filter", () => { + const operations = { + ...EMPTY_TABLE_DATA.operations, + filter: [{type: "xyz", operands: [{type: "column", value: "a"}]}] + }; + assert.throws(() => __table(source, operations), /unknown filter type: xyz/); + }); + + it("__table filter lt + gt", () => { + const operationsEquals = { + ...EMPTY_TABLE_DATA.operations, + filter: [{type: "eq", operands: [{type: "column", value: "a"}, {type: "resolved", value: 1}]}] + }; + assert.deepStrictEqual(__table(source, operationsEquals), [{a: 1, b: 2, c: 3}]); + const operationsComparison = { + ...EMPTY_TABLE_DATA.operations, + filter: [ + {type: "lt", operands: [{type: "column", value: "a"}, {type: "resolved", value: 3}]}, + {type: "gt", operands: [{type: "column", value: "b"}, {type: "resolved", value: 2}]} + ] + }; + assert.deepStrictEqual(__table(source, operationsComparison), [{a: 2, b: 4, c: 6}]); + }); + + it("__table filter lte + gte", () => { + const operationsEquals = { + ...EMPTY_TABLE_DATA.operations, + filter: [{type: "eq", operands: [{type: "column", value: "a"}, {type: "resolved", value: 1}]}] + }; + assert.deepStrictEqual(__table(source, operationsEquals), [{a: 1, b: 2, c: 3}]); + const operationsComparison = { + ...EMPTY_TABLE_DATA.operations, + filter: [ + {type: "lte", operands: [{type: "column", value: "a"}, {type: "resolved", value: 2.5}]}, + {type: "gte", operands: [{type: "column", value: "b"}, {type: "resolved", value: 2.5}]} + ] + }; + assert.deepStrictEqual(__table(source, operationsComparison), [{a: 2, b: 4, c: 6}]); + }); + + it("__table filter eq date", () => { + const operationsEquals = { + ...EMPTY_TABLE_DATA.operations, + filter: [{type: "eq", operands: [{type: "column", value: "a"}, {type: "resolved", value: new Date("2021-01-02")}]}] + }; + const source = [{a: new Date("2021-01-01")}, {a: new Date("2021-01-02")}, {a: new Date("2021-01-03")}]; + assert.deepStrictEqual(__table(source, operationsEquals), [{a: new Date("2021-01-02")}]); + }); + + it("__table sort", () => { + const operations1 = {...EMPTY_TABLE_DATA.operations, sort: [{column: "a", direction: "desc"}]}; + assert.deepStrictEqual( + __table(source, operations1), + [{a: 3, b: 6, c: 9}, {a: 2, b: 4, c: 6}, {a: 1, b: 2, c: 3}] + ); + const sourceExtended = [...source, {a: 1, b: 3, c: 3}, {a: 1, b: 5, c: 3}]; + const operations2 = { + ...EMPTY_TABLE_DATA.operations, + sort: [{column: "a", direction: "desc"}, {column: "b", direction: "desc"}] + }; + assert.deepStrictEqual( + __table(sourceExtended, operations2), + [{a: 3, b: 6, c: 9}, {a: 2, b: 4, c: 6}, {a: 1, b: 5, c: 3}, {a: 1, b: 3, c: 3}, {a: 1, b: 2, c: 3}] + ); + }); + + it("__table sort does not mutate input", () => { + const operations = {...EMPTY_TABLE_DATA.operations, sort: [{column: "a", direction: "desc"}]}; + assert.deepStrictEqual( + __table(source, operations), + [{a: 3, b: 6, c: 9}, {a: 2, b: 4, c: 6}, {a: 1, b: 2, c: 3}] + ); + assert.deepStrictEqual( + source, + [{a: 1, b: 2, c: 3}, {a: 2, b: 4, c: 6}, {a: 3, b: 6, c: 9}] + ); + }); + + it("__table slice", () => { + const operationsToNull = {...EMPTY_TABLE_DATA.operations, slice: {from: 1, to: null}}; + assert.deepStrictEqual(__table(source, operationsToNull), [{a: 2, b: 4, c: 6}, {a: 3, b: 6, c: 9}]); + const operationsFromNull = {...EMPTY_TABLE_DATA.operations, slice: {from: null, to: 1}}; + assert.deepStrictEqual(__table(source, operationsFromNull), [{a: 1, b: 2, c: 3}]); + const operations = {...EMPTY_TABLE_DATA.operations, slice: {from: 1, to: 2}}; + assert.deepStrictEqual(__table(source, operations), [{a: 2, b: 4, c: 6}]); + }); + + it("__table retains schema and columns info", () => { + source.columns = ["a", "b", "c"]; + assert.deepStrictEqual(__table(source, EMPTY_TABLE_DATA.operations).columns, ["a", "b", "c"]); + source.schema = [{name: "a", type: "number"}, {name: "b", type: "number"}, {name: "c", type: "number"}]; + assert.deepStrictEqual( + __table(source, EMPTY_TABLE_DATA.operations).schema, + [{name: "a", type: "number"}, {name: "b", type: "number"}, {name: "c", type: "number"}] + ); + }); }); diff --git a/yarn.lock b/yarn.lock index 7f6e3382..2ea83113 100644 --- a/yarn.lock +++ b/yarn.lock @@ -487,6 +487,13 @@ cross-spawn@^7.0.2: shebang-command "^2.0.0" which "^2.0.1" +d3-array@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.0.tgz#15bf96cd9b7333e02eb8de8053d78962eafcff14" + integrity sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g== + dependencies: + internmap "1 - 2" + d3-dsv@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/d3-dsv/-/d3-dsv-2.0.0.tgz#b37b194b6df42da513a120d913ad1be22b5fe7c5" @@ -916,6 +923,11 @@ inherits@2, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +"internmap@1 - 2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" + integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== + is-binary-path@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"