Files
2026-07-13 12:25:07 +08:00

2546 lines
89 KiB
JavaScript

// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
import { test, expect } from "@perspective-dev/test";
import perspective from "../perspective_client";
const CHARS = ` !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_\`abcdefghijklmnopqrstuvwxyz{|}~`;
const ALPHA = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const NUMERIC = "0123456789";
const ALPHANUMERIC = ALPHA + NUMERIC;
const randint = (min, max) =>
Math.floor(Math.random() * (Math.floor(max) - Math.ceil(min) + 1)) +
Math.ceil(min);
const randchoice = (collection) =>
collection[randint(0, collection.length - 1)];
const random_string = (
max_length = 100,
is_null = false,
input_values = CHARS,
) => {
if (is_null && Math.random() > 0.5) return null;
const length = randint(1, max_length);
const output = [];
for (let i = 0; i < length; i++) {
output.push(randchoice(input_values));
}
return output.join("");
};
/**
* Tests the correctness of each string computation function in various
* environments and parameters - different types, nulls, undefined, etc.
*
* TODO: add tests for shared vocab after clear()
*/
((perspective) => {
test.describe("String functions", function () {
test("Comparisons", async function () {
const table = await perspective.table({
x: ["abc", "deeeeef", "fg", "hhs", "abcdefghijk"],
y: ["ABC", "DEF", "EfG", "HIjK", "lMNoP"],
});
const view = await table.view({
expressions: {
[`a`]: `'abcdefghijklmnopqrstuvwxyz' == 'abcdefghijklmnopqrstuvwxyz'`,
[`b`]: `"x" == lower("y")`,
[`c`]: `if("x" == 'abc', 100, 0)`,
[`d`]: `if("x" != 'abc', 'new string 1', 'new string 2')`,
[`e`]: `'d' > 'a'`,
[`f`]: `'efz' > 'efy'`, // lexicographic
},
});
let result = await view.to_columns();
expect(result["a"]).toEqual(Array(5).fill(true));
expect(result["b"]).toEqual([true, false, false, false, false]);
expect(result["c"]).toEqual([100, 0, 0, 0, 0]);
expect(result["d"]).toEqual([
"new string 2",
"new string 1",
"new string 1",
"new string 1",
"new string 1",
]);
expect(result["e"]).toEqual(Array(5).fill(true));
expect(result["f"]).toEqual(Array(5).fill(true));
view.delete();
table.delete();
});
test("Pivoted", async function () {
const table = await perspective.table({
a: ["abc", "deeeeef", "fg", "hhs", "abcdefghijk"],
b: ["ABC", "DEF", "EfG", "HIjK", "lMNoP"],
c: [2, 2, 4, 4, 5],
});
const view = await table.view({
aggregates: { column: "last" },
group_by: ["column"],
expressions: {
[`column`]: `concat("a", ', ', 'here is a long string, ', "b")`,
},
});
let result = await view.to_columns();
expect(result["column"]).toEqual([
"hhs, here is a long string, HIjK",
"abc, here is a long string, ABC",
"abcdefghijk, here is a long string, lMNoP",
"deeeeef, here is a long string, DEF",
"fg, here is a long string, EfG",
"hhs, here is a long string, HIjK",
]);
view.delete();
table.delete();
});
test("Filtered", async function () {
const table = await perspective.table({
a: ["abc", "deeeeef", "fg", "hhs", "abcdefghijk"],
b: ["ABC", "DEF", "EfG", "HIjK", "lMNoP"],
c: [2, 2, 4, 4, 5],
});
const view = await table.view({
filter: [["column", "==", "hhs, here is a long string, HIjK"]],
expressions: {
[`column`]: `concat("a", ', ', 'here is a long string, ', "b")`,
},
});
let result = await view.to_columns();
expect(result["column"]).toEqual([
"hhs, here is a long string, HIjK",
]);
view.delete();
table.delete();
});
test("Length", async function () {
const table = await perspective.table({
a: ["abc", "deeeeef", "fg", "hhs", "abcdefghijk"],
});
const view = await table.view({
expressions: { 'length("a")': 'length("a")' },
});
let result = await view.to_columns();
expect(result['length("a")']).toEqual(
result.a.map((x) => x.length),
);
view.delete();
table.delete();
});
test("Length with null", async function () {
const table = await perspective.table({
a: ["abc", "deeeeef", null, undefined, "abcdefghijk"],
});
const view = await table.view({
expressions: { 'length("a")': 'length("a")' },
});
let result = await view.to_columns();
expect(result['length("a")']).toEqual(
result.a.map((x) => (x ? x.length : null)),
);
view.delete();
table.delete();
});
test("Order", async function () {
const table = await perspective.table({
a: ["abc", "deeeeef", "fg", "hhs", "abcdefghijk"],
});
const validate = await table.validate_expressions({
[`col`]: `order("a", 'deeeeef', 'fg', 'abcdefghijk', 'hhs', 'abc')`,
});
expect(validate.expression_schema).toEqual({
col: "float",
});
const view = await table.view({
expressions: [
`order("a", 'deeeeef', 'fg', 'abcdefghijk', 'hhs', 'abc')`,
].reduce((x, y) => Object.assign(x, { [y]: y }), {}),
});
let result = await view.to_columns();
expect(
result[
`order("a", 'deeeeef', 'fg', 'abcdefghijk', 'hhs', 'abc')`
],
).toEqual([4, 0, 1, 3, 2]);
view.delete();
table.delete();
});
test("Order type validates", async function () {
const table = await perspective.table({
a: ["abc", "deeeeef", "fg", "hhs", "abcdefghijk"],
b: [1, 2, 3, 4, 5],
});
const validate = await table.validate_expressions({
[`col`]: `order("a", 'deeeeef', 'fg', 'abcdefghijk', 'hhs', 'abc')`,
[`col2`]: `order('a', 'b', today())`,
[`col3`]: `order("b")`,
[`col4`]: `order()`,
});
expect(validate.expression_schema).toEqual({
col: "float",
});
expect(validate.errors).toEqual({
col2: {
column: 0,
error_message:
"Type Error - inputs do not resolve to a valid expression.",
line: 0,
},
col3: {
column: 0,
error_message:
"Type Error - inputs do not resolve to a valid expression.",
line: 0,
},
col4: {
column: 7,
error_message:
"Zero parameter call to generic function: order not allowed",
line: 0,
},
});
await table.delete();
});
test("Order with partial specification", async function () {
const table = await perspective.table({
a: ["abc", "deeeeef", "fg", "hhs", "abcdefghijk"],
});
const view = await table.view({
expressions: {
"order(\"a\", 'deeeeef', 'fg')":
"order(\"a\", 'deeeeef', 'fg')",
},
});
let result = await view.to_columns();
expect(result[`order("a", 'deeeeef', 'fg')`]).toEqual([
2, 0, 1, 2, 2,
]);
view.delete();
table.delete();
});
test("Order with null", async function () {
const table = await perspective.table({
a: ["abc", "deeeeef", null, undefined, "abcdefghijk"],
});
const view = await table.view({
expressions: {
[`order("a", 'deeeeef', 'abcdefghijk', 'abc')`]: `order("a", 'deeeeef', 'abcdefghijk', 'abc')`,
},
});
let result = await view.to_columns();
expect(
result[`order("a", 'deeeeef', 'abcdefghijk', 'abc')`],
).toEqual([2, 0, null, null, 1]);
view.delete();
table.delete();
});
test("Upper", async function () {
const table = await perspective.table({
a: ["abc", "deeeeef", "fg", "hhs", "abcdefghijk"],
});
const view = await table.view({
expressions: { 'upper("a")': 'upper("a")' },
});
let result = await view.to_columns();
expect(result['upper("a")']).toEqual(
result.a.map((x) => x.toUpperCase()),
);
view.delete();
table.delete();
});
test("Uppercase with null", async function () {
const table = await perspective.table({
a: ["abc", "deeeeef", null, undefined, "abcdefghijk"],
});
const view = await table.view({
expressions: { 'upper("a")': 'upper("a")' },
});
let result = await view.to_columns();
expect(result['upper("a")']).toEqual(
result.a.map((x) => (x ? x.toUpperCase() : null)),
);
view.delete();
table.delete();
});
test.skip("Uppercase, non-utf8", async function () {
const table = await perspective.table({
a: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝓊⋁ẅ⤫𝛾𝓏", null],
b: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝑢ⱴⱳẍ𝘺𝘇ӑṣᶑᵴ", "EfG"],
});
const view = await table.view({
expressions: {
'upper("a")': 'upper("a")',
'upper("b")': 'upper("b")',
},
});
let result = await view.to_columns();
expect(result['upper("a")']).toEqual(
result.a.map((x) => (x ? x.toUpperCase() : null)),
);
expect(result['upper("b")']).toEqual(
result.b.map((x) => (x ? x.toUpperCase() : null)),
);
view.delete();
table.delete();
});
test("Lowercase", async function () {
const table = await perspective.table({
a: ["ABC", "DEF", "EfG", "HIjK", "lMNoP"],
});
const view = await table.view({
expressions: { 'lower("a")': 'lower("a")' },
});
let result = await view.to_columns();
expect(result['lower("a")']).toEqual(
result.a.map((x) => x.toLowerCase()),
);
view.delete();
table.delete();
});
test("Lowercase with null", async function () {
const table = await perspective.table({
a: ["ABC", "DEF", null, undefined, "lMNoP"],
});
const view = await table.view({
expressions: { 'lower("a")': 'lower("a")' },
});
let result = await view.to_columns();
expect(result['lower("a")']).toEqual(
result.a.map((x) => (x ? x.toLowerCase() : null)),
);
view.delete();
table.delete();
});
test("Lowercase, non-utf8", async function () {
const table = await perspective.table({
a: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝓊⋁ẅ⤫𝛾𝓏", null],
b: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝑢ⱴⱳẍ𝘺𝘇ӑṣᶑᵴ", "EfG"],
});
const view = await table.view({
expressions: {
'lower("a")': 'lower("a")',
'lower("b")': 'lower("b")',
},
});
let result = await view.to_columns();
expect(result['lower("a")']).toEqual(
result.a.map((x) => (x ? x.toLowerCase() : null)),
);
expect(result['lower("b")']).toEqual(
result.b.map((x) => (x ? x.toLowerCase() : null)),
);
view.delete();
table.delete();
});
test("Coalesce strings", async function () {
const table = await perspective.table({
a: ["ABC", null, null, "HIjK", null],
b: ["xyz", "DEF", null, "stu", null],
});
const view = await table.view({
expressions: {
coalesce_str: 'coalesce("a", "b", \'N/A\')',
},
});
const result = await view.to_columns();
const schema = await view.expression_schema();
expect(schema["coalesce_str"]).toEqual("string");
expect(result["coalesce_str"]).toEqual([
"ABC",
"DEF",
"N/A",
"HIjK",
"N/A",
]);
view.delete();
table.delete();
});
test("Concat", async function () {
const table = await perspective.table({
a: ["abc", "deeeeef", "fg", "hhs", "abcdefghijk"],
b: ["ABC", "DEF", "EfG", "HIjK", "lMNoP"],
});
const view = await table.view({
expressions: [
`concat("a", ', ', 'here is a long string, ', "b")`,
].reduce((x, y) => Object.assign(x, { [y]: y }), {}),
});
let result = await view.to_columns();
expect(
result[`concat("a", ', ', 'here is a long string, ', "b")`],
).toEqual(
result.a.map(
(x, idx) => x + ", here is a long string, " + result.b[idx],
),
);
view.delete();
table.delete();
});
test("Concats, nulls", async function () {
const table = await perspective.table({
a: ["ABC", "DEF", null, "HIjK", "lMNoP"],
b: ["ABC", undefined, "EfG", "HIjK", "lMNoP"],
});
const view = await table.view({
expressions: [
`concat("a", ', ', 'here is a long string, ', "b")`,
].reduce((x, y) => Object.assign(x, { [y]: y }), {}),
});
let result = await view.to_columns();
let expected = result.a.map(
(x, idx) => x + ", here is a long string, " + result.b[idx],
);
expected[1] = null;
expected[2] = null;
expect(
result[`concat("a", ', ', 'here is a long string, ', "b")`],
).toEqual(expected);
view.delete();
table.delete();
});
test("Concats, extra long", async function () {
const table = await perspective.table({
a: [
"ABC".repeat(10),
"DEF".repeat(10),
null,
"HIjK".repeat(10),
"lMNoP".repeat(10),
],
b: ["ABC", undefined, "EfG", "HIjK", "lMNoP"],
});
const view = await table.view({
expressions: [
`concat("a", ', ', 'here is a long string, ', "b")`,
].reduce((x, y) => Object.assign(x, { [y]: y }), {}),
});
let result = await view.to_columns();
let expected = result.a.map(
(x, idx) => x + ", here is a long string, " + result.b[idx],
);
expected[1] = null;
expected[2] = null;
expect(
result[`concat("a", ', ', 'here is a long string, ', "b")`],
).toEqual(expected);
view.delete();
table.delete();
});
test("Concats, non-utf8", async function () {
const table = await perspective.table({
a: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝓊⋁ẅ⤫𝛾𝓏", null],
b: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝑢ⱴⱳẍ𝘺𝘇ӑṣᶑᵴ", "EfG"],
});
const view = await table.view({
expressions: [
`concat("a", ', ', 'here is a long string, ', "b")`,
].reduce((x, y) => Object.assign(x, { [y]: y }), {}),
});
let result = await view.to_columns();
let expected = result.a.map(
(x, idx) => x + ", here is a long string, " + result.b[idx],
);
expected[2] = null;
expect(
result[`concat("a", ', ', 'here is a long string, ', "b")`],
).toEqual(expected);
view.delete();
table.delete();
});
test("Upper concats", async function () {
const table = await perspective.table({
a: [
"hello world",
"abakshdaskjhlgkjasdiukjqewlkjesaljhgdaskd",
null,
],
b: ["asjdhlkhfdshafiywhjklsjfaksdgjadkjlv", "abc", "EfG"],
});
const view = await table.view({
expressions: [
`upper(concat("a", ', ', 'here is a long string, ', "b"))`,
].reduce((x, y) => Object.assign(x, { [y]: y }), {}),
});
let result = await view.to_columns();
let expected = result[
`upper(concat("a", ', ', 'here is a long string, ', "b"))`
].map((x) => (x ? x.toUpperCase() : null));
expected[2] = null;
expect(
result[
`upper(concat("a", ', ', 'here is a long string, ', "b"))`
],
).toEqual(expected);
view.delete();
table.delete();
});
test("Lower concats", async function () {
const table = await perspective.table({
a: [
"HELLO WORLD SADJKHFUOIWNS:AJKSKJDJBCL",
"KJBSJHDBGASHJDB ASCBAKISJHDKJSAHNDKASJ SJKHDJKAS",
null,
],
b: [
"LDJSA:KJFGHJAKLSoijSJDM:ALKJDAS)oewqSAPDOD",
"ASdhnlsaadkjhASJKDSAHIUEHYWIUDSHDNBKJSAD",
"EfG",
],
});
const view = await table.view({
expressions: [
`lower(concat("a", ', ', 'HERE is a long string, ', "b"))`,
].reduce((x, y) => Object.assign(x, { [y]: y }), {}),
});
let result = await view.to_columns();
let expected = result[
`lower(concat("a", ', ', 'HERE is a long string, ', "b"))`
].map((x) => (x ? x.toLowerCase() : null));
expected[2] = null;
expect(
result[
`lower(concat("a", ', ', 'HERE is a long string, ', "b"))`
],
).toEqual(expected);
view.delete();
table.delete();
});
test("Order lower concats", async function () {
const table = await perspective.table({
a: ["HELLO WORLD", "VERY LONG STRING HERE", null],
b: ["ALSO HELLO WORLD", "ANOTHER LONG STRING IS HERE", "EfG"],
});
const view = await table.view({
expressions: [
`order(lower(concat("a", ', ', 'HERE is a long string, ', "b")), 'very long string here, here is a long string, another long string is here')`,
].reduce((x, y) => Object.assign(x, { [y]: y }), {}),
});
let result = await view.to_columns();
expect(
result[
`order(lower(concat("a", ', ', 'HERE is a long string, ', "b")), 'very long string here, here is a long string, another long string is here')`
],
).toEqual([1, 0, null]);
view.delete();
table.delete();
});
test.skip("Upper concats, non-utf8", async function () {
const table = await perspective.table({
a: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝓊⋁ẅ⤫𝛾𝓏", null],
b: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝑢ⱴⱳẍ𝘺𝘇ӑṣᶑᵴ", "EfG"],
});
const view = await table.view({
expressions: [
`upper(concat("a", ', ', 'here is a long string, ', "b"))`,
].reduce((x, y) => Object.assign(x, { [y]: y }), {}),
});
let result = await view.to_columns();
let expected = result[
`upper(concat("a", ', ', 'here is a long string, ', "b"))`
].map((x) => (x ? x.toUpperCase() : null));
expected[2] = null;
expect(
result[
`upper(concat("a", ', ', 'here is a long string, ', "b"))`
],
).toEqual(expected);
view.delete();
table.delete();
});
test("Lower concats, non-utf8", async function () {
const table = await perspective.table({
a: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝓊⋁ẅ⤫𝛾𝓏", null],
b: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝑢ⱴⱳẍ𝘺𝘇ӑṣᶑᵴ", "EfG"],
});
const view = await table.view({
expressions: [
`lower(concat("a", ', ', 'HERE is a long string, ', "b"))`,
].reduce((x, y) => Object.assign(x, { [y]: y }), {}),
});
let result = await view.to_columns();
let expected = result[
`lower(concat("a", ', ', 'HERE is a long string, ', "b"))`
].map((x) => (x ? x.toLowerCase() : null));
expect(
result[
`lower(concat("a", ', ', 'HERE is a long string, ', "b"))`
],
).toEqual(expected);
view.delete();
table.delete();
});
test.skip("Length concats, non-utf8", async function () {
const table = await perspective.table({
a: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝓊⋁ẅ⤫𝛾𝓏", null],
b: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝑢ⱴⱳẍ𝘺𝘇ӑṣᶑᵴ", "EfG"],
});
const view = await table.view({
expressions: [
`length(concat("a", ', ', 'here is a long string, ', "b"))`,
].reduce((x, y) => Object.assign(x, { [y]: y }), {}),
});
let result = await view.to_columns();
let expected = result.a.map(
(x, idx) =>
(x + ", here is a long string, " + result.b[idx]).length,
);
expected[2] = null;
expect(
result[
`length(concat("a", ', ', 'here is a long string, ', "b"))`
],
).toEqual(expected);
view.delete();
table.delete();
});
test.skip("Order concats, non-utf8", async function () {
const table = await perspective.table({
a: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝓊⋁ẅ⤫𝛾𝓏", null],
b: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝑢ⱴⱳẍ𝘺𝘇ӑṣᶑᵴ", "EfG"],
});
const view = await table.view({
expressions: [
`var x := concat("a", ', ', 'here is a long string, ', "b"); order(x, '𝓊⋁ẅ⤫𝛾𝓏, here is a long string, 𝑢ⱴⱳẍ𝘺𝘇ӑṣᶑᵴ', '𝕙ḗľᶅở щṏᵲɭⅾ, here is a long string, 𝕙ḗľᶅở щṏᵲɭⅾ')`,
].reduce((x, y) => Object.assign(x, { [y]: y }), {}),
});
let result = await view.to_columns();
expect(
result[
`var x := concat("a", ', ', 'here is a long string, ', "b"); order(x, '𝓊⋁ẅ⤫𝛾𝓏, here is a long string, 𝑢ⱴⱳẍ𝘺𝘇ӑṣᶑᵴ', '𝕙ḗľᶅở щṏᵲɭⅾ, here is a long string, 𝕙ḗľᶅở щṏᵲɭⅾ')`
],
).toEqual([1, 0, 2]);
view.delete();
table.delete();
});
});
test.describe("String comparison", function () {
test("==", async function () {
const table = await perspective.table({
a: ["ABC", "DEF", null, "HIjK", "lMNoP"],
b: ["ABC", undefined, null, "HIjK", "lMNoP"],
});
let view = await table.view({
expressions: { '"a" == "b"': '"a" == "b"' },
});
let result = await view.to_columns();
// null == null is true here
expect(result['"a" == "b"']).toEqual([
true,
false,
true,
true,
true,
]);
view.delete();
table.delete();
});
test("== on expression output", async function () {
const table = await perspective.table({
a: ["ABC", "DEF", "cba", "HIjK", "lMNoP"],
b: ["ABC", "ad", "asudfh", "HIjK", "lMNoP"],
});
let view = await table.view({
expressions: [
`concat("a", ', ', "b") == concat("a", ', ', "b")`,
].reduce((x, y) => Object.assign(x, { [y]: y }), {}),
});
let result = await view.to_columns();
expect(
result[`concat("a", ', ', "b") == concat("a", ', ', "b")`],
).toEqual([true, true, true, true, true]);
view.delete();
table.delete();
});
test("==, nulls", async function () {
const table = await perspective.table({
a: ["ABC", "DEF", undefined, null, null],
b: ["ABC", "not", "EfG", "HIjK", null],
});
let view = await table.view({
expressions: { '"a" == "b"': '"a" == "b"' },
});
let result = await view.to_columns();
expect(result['"a" == "b"']).toEqual([
true,
false,
false,
false,
true,
]);
view.delete();
table.delete();
});
test("==, extra long", async function () {
const table = await perspective.table({
a: [
"ABC".repeat(10),
"DEF".repeat(10),
null,
"HIjK".repeat(10),
"lMNoP",
],
b: [
"ABC".repeat(10),
"DEF".repeat(10),
undefined,
"HIjK",
"lMNoP",
],
});
let view = await table.view({
expressions: { '"a" == "b"': '"a" == "b"' },
});
let result = await view.to_columns();
expect(result['"a" == "b"']).toEqual([
true,
true,
true,
false,
true,
]);
view.delete();
table.delete();
});
test("==, short", async function () {
const table = await perspective.table({
a: ["A", "E", null, "h", "l"],
b: ["a", "E", undefined, "h", "l"],
});
let view = await table.view({
expressions: { '"a" == "b"': '"a" == "b"' },
});
let result = await view.to_columns();
expect(result['"a" == "b"']).toEqual([
false,
true,
true,
true,
true,
]);
view.delete();
table.delete();
});
test("==, mixed length", async function () {
const table = await perspective.table({
a: [
"ABC".repeat(100),
"DEF".repeat(10),
null,
"hijk".repeat(10),
"lm",
],
b: [
"arc".repeat(50),
"DEf".repeat(10),
undefined,
"HIjK",
"lMNoP",
],
});
let view = await table.view({
expressions: { '"a" == "b"': '"a" == "b"' },
});
let result = await view.to_columns();
expect(result['"a" == "b"']).toEqual([
false,
false,
true,
false,
false,
]);
view.delete();
table.delete();
});
test("==, UTF-8", async function () {
const table = await perspective.table({
a: [
">ﺐ{׆Meڱ㒕宾ⷭ̽쉱L𞔚Ո拏۴ګPظǭPۋV|팺㺞㷾墁鴦򒲹|ۿ򧊊䭪񪩛𬦢񺣠񦋳򵾳蛲񖑐iM񊪝񆷯",
"灙𬡍瀳։󷿙񅈕ǐ-kʂiJ!P񙺍󵝳̃੝w𬾐򕕉耨󉋦o򰵏詂3򒤹J<ꑭ񃕱Ӏ𛤦4u򉠚UPf􂢳P##Q񪂈",
"ĈᔞZ񇌖Qఋ?x?#$12ボլ㕢ﺧ𷛘󽙮[񲸧I񟭝򋨰魏ճכ󽺴ۏ󫨫䆐'㓔ǃ[ְ੬䎕寽𤩚ߨ袧򲕊򓰷|%",
"ęԛ򓍯󍩁𨞟㰢œ󇂣õ􌁇΍Ԥ⥯۷˝㿙צּ񬆩򤿭顂ݦۍ式+=ԋ帋񃴕譋ⴏ0l􅏎߳cί򇈊iȞڈU򆐹񍖮򷡦̥𩮏DZ",
"0ой3֝󻙋򑨮꾪߫0󏜬󆑝w󊭟񑓫򾷄𶳿o󏉃纊ʫ􅋶聍𾋊ô򓨼쀨ˆ퍨׽ȿKOŕ􅽾󙸹Ѩ󶭆j񽪌򸢐p򊘏׷򿣂dgD쩖",
],
b: [
">ﺐ{׆Meڱ㒕宾ⷭ̽쉱L𞔚Ո拏۴ګPظǭPۋV|팺㺞㷾墁鴦򒲹|ۿ򧊊䭪񪩛𬦢񺣠񦋳򵾳蛲񖑐iM񊪝񆷯",
"灙𬡍瀳։󷿙񅈕ǐ-kʂiJ!P񙺍󵝳̃੝w𬾐򕕉耨󉋦o򰵏詂3򒤹J<ꑭ񃕱Ӏ𛤦4u򉠚UPf􂢳P##Q񪂈",
"ĈᔞZ񇌖Qఋ?x?#$12ボլ㕢ﺧ𷛘󽙮[񲸧I񟭝򋨰魏ճכ󽺴ۏ󫨫䆐'㓔ǃ[ְ੬䎕寽𤩚ߨ袧򲕊򓰷|%",
"ęԛ򓍯󍩁𨞟㰢œ󇂣õ􌁇΍Ԥ⥯۷˝㿙צּ񬆩򤿭顂ݦۍ式+=ԋ帋񃴕譋ⴏ0l􅏎߳cί򇈊iȞڈU򆐹񍖮򷡦̥𩮏DZ",
"0ой3֝󻙋򑨮꾪߫0󏜬󆑝w󊭟񑓫򾷄𶳿o󏉃纊ʫ􅋶聍𾋊ô򓨼쀨ˆ퍨׽ȿKOŕ􅽾󙸹Ѩ󶭆j񽪌򸢐p򊘏׷򿣂dgD쩖2",
],
});
let view = await table.view({
expressions: { '"a" == "b"': '"a" == "b"' },
});
let result = await view.to_columns();
expect(result['"a" == "b"']).toEqual([
true,
true,
true,
true,
false,
]);
view.delete();
table.delete();
});
test("==, UTF-8 converted to Unicode", async function () {
const table = await perspective.table({
a: [
">{MeLPPV||iM",
"-kiJ!Pwo3J<4uUPfP##Q",
"ZQ?x?#$12[I'[|%",
"ܦf+=0lciU",
"030wo􎼨KOjpdD",
],
b: [
">{MeLPPV||iM",
"-kiJ!Pwo3J<4uUPfP##Q",
"ZQ?x?#$12[I'[|%",
"ܦf+=0lciU",
"030wo􎼨KOjpdD2",
],
});
let view = await table.view({
expressions: { '"a" == "b"': '"a" == "b"' },
});
let result = await view.to_columns();
expect(result['"a" == "b"']).toEqual([
true,
true,
true,
true,
false,
]);
view.delete();
table.delete();
});
});
test.describe("Regular Expressions", () => {
test("Match string with string", async () => {
const table = await perspective.table({
a: "string",
b: "string",
c: "string",
});
await table.update({
a: ["ABC", "DEF", "cbA", "HIjK", "lMNoP"],
b: ["ABC", "ad", "asudfh", "HIjK", "lMNoP"],
c: [
"1234567890",
"123x4567",
"abcdefg123",
"4567123",
"1?2?3?",
],
});
const expressions = [
`match("a", 'ABC')`,
"match('aBc', '[aAbBcC]{3}')",
`match("a", 'A')`,
`match("c", '[0-9]{3}')`,
`match("c", '4567')`,
];
const view = await table.view({
expressions,
});
const schema = await view.expression_schema();
for (const expr of expressions) {
expect(schema[expr]).toEqual("boolean");
}
const results = await view.to_columns();
expect(results[expressions[0]]).toEqual([
true,
false,
false,
false,
false,
]);
expect(results[expressions[1]]).toEqual(Array(5).fill(true));
expect(results[expressions[2]]).toEqual([
true,
false,
true,
false,
false,
]);
expect(results[expressions[3]]).toEqual([
true,
true,
true,
true,
false,
]);
expect(results[expressions[4]]).toEqual([
true,
true,
false,
true,
false,
]);
await view.delete();
await table.delete();
});
test("match_all string with string", async () => {
const table = await perspective.table({
a: "string",
b: "string",
c: "string",
});
await table.update({
a: ["ABC", "DEF", "cbA", "HIjK", "lMNoP"],
b: ["ABC", "ad", "asudfh", "HIjK", "lMNoP"],
c: ["1234567890", "123x4567", "abcdefg123", "4567123", "123"],
});
const expressions = [
`match_all("a", 'ABC')`,
"match_all('aBc', '[aAbBcC]{3}')",
`match_all("a", 'A')`,
`match_all("c", '[0-9]{3}')`,
`match_all("c", '4567')`,
`match_all("c", '4567123')`,
`match_all("c", '[0-9]+')`,
];
const view = await table.view({
expressions,
});
const schema = await view.expression_schema();
for (const expr of expressions) {
expect(schema[expr]).toEqual("boolean");
}
const results = await view.to_columns();
expect(results[expressions[0]]).toEqual([
true,
false,
false,
false,
false,
]);
expect(results[expressions[1]]).toEqual(Array(5).fill(true));
expect(results[expressions[2]]).toEqual(Array(5).fill(false));
expect(results[expressions[3]]).toEqual([
false,
false,
false,
false,
true,
]);
expect(results[expressions[4]]).toEqual(Array(5).fill(false));
expect(results[expressions[5]]).toEqual([
false,
false,
false,
true,
false,
]);
expect(results[expressions[6]]).toEqual([
true,
false,
false,
true,
true,
]);
await view.delete();
await table.delete();
});
test("Match string with bad regex should fail type-checking", async () => {
const table = await perspective.table({
a: ["ABC", "DEF", "cbA", "HIjK", "lMNoP"],
b: ["ABC", "ad", "asudfh", "HIjK", "lMNoP"],
});
const expressions = [
`match("a", '*.')`,
"match('abc', '(?=a)')",
`match("a", '?')`,
];
const validated = await table.validate_expressions(expressions);
expect(validated.expression_schema).toEqual({});
for (const expr of expressions) {
expect(validated.expression_schema[expr]).toBeUndefined();
expect(validated.errors[expr]).toEqual({
line: 0,
column: 0,
error_message:
"Type Error - inputs do not resolve to a valid expression.",
});
}
// Because a bad regex does not raise a parse error, it is still
// valid to create a view from them.
await expect(
table.view({
expressions,
}),
).rejects.toThrow();
await table.delete();
});
// I don't know what makes a parse error unique in the internal comment,
// but `validate()` and `expression` shoul dbe identical in behavior.
test.skip("OG - Match string with bad regex should fail type-checking", async () => {
const table = await perspective.table({
a: ["ABC", "DEF", "cbA", "HIjK", "lMNoP"],
b: ["ABC", "ad", "asudfh", "HIjK", "lMNoP"],
});
const expressions = [
`match("a", '*.')`,
"match('abc', '(?=a)')",
`match("a", '?')`,
];
const validated = await table.validate_expressions(expressions);
expect(validated.expression_schema).toEqual({});
for (const expr of expressions) {
expect(validated.expression_schema[expr]).toBeUndefined();
expect(validated.errors[expr]).toEqual({
line: 0,
column: 0,
error_message:
"Type Error - inputs do not resolve to a valid expression.",
});
}
// Because a bad regex does not raise a parse error, it is still
// valid to create a view from them.
const view = await table.view({
expressions,
});
const schema = await view.expression_schema();
const results = await view.to_columns();
for (const expr of expressions) {
expect(schema[expr]).toEqual("boolean");
expect(results[expr]).toEqual(Array(5).fill(null));
}
await view.delete();
await table.delete();
});
test("match_all string with bad regex should fail type-checking", async () => {
const table = await perspective.table({
a: ["ABC", "DEF", "cbA", "HIjK", "lMNoP"],
b: ["ABC", "ad", "asudfh", "HIjK", "lMNoP"],
});
const expressions = [
`match_all("a", '*.')`,
"match_all('abc', '(?=a)')",
`match_all("a", '?')`,
`match_all("a", '+.')`,
];
const validated = await table.validate_expressions(expressions);
expect(validated.expression_schema).toEqual({});
for (const expr of expressions) {
expect(validated.expression_schema[expr]).toBeUndefined();
expect(validated.errors[expr]).toEqual({
line: 0,
column: 0,
error_message:
"Type Error - inputs do not resolve to a valid expression.",
});
}
await expect(
table.view({
expressions,
}),
).rejects.toThrow();
await table.delete();
});
test("Match should only work on strings", async () => {
const table = await perspective.table({
a: ["ABC", "DEF", "cbA", "HIjK", "lMNoP"],
b: ["abc123", "abc567", "abc56", "1234567", "aaa000"],
});
const expressions = [
`match("a", "b")`,
`match("a", 123)`,
`match(today(), '[a-z]{3}[0-9]{3}')`,
`match(False, '[0-9]{7}')`,
];
const validated = await table.validate_expressions(expressions);
for (const expr of expressions) {
expect(validated.expression_schema[expr]).toBeUndefined();
}
expect(validated.errors[expressions[0]].error_message).toEqual(
"Failed parameter type check for function 'match', Expected 'TS' call set: 'TT'",
);
expect(validated.errors[expressions[1]].error_message).toEqual(
"Failed parameter type check for function 'match', Expected 'TS' call set: 'TT'",
);
expect(validated.errors[expressions[2]]).toEqual({
line: 0,
column: 0,
error_message:
"Type Error - inputs do not resolve to a valid expression.",
});
expect(validated.errors[expressions[3]]).toEqual({
line: 0,
column: 0,
error_message:
"Type Error - inputs do not resolve to a valid expression.",
});
await table.delete();
});
test("match_all should only work on strings", async () => {
const table = await perspective.table({
a: ["ABC", "DEF", "cbA", "HIjK", "lMNoP"],
b: ["abc123", "abc567", "abc56", "1234567", "aaa000"],
});
const expressions = [
`match_all("a", "b")`,
`match_all("a", 123)`,
`match_all(today(), '[a-z]{3}[0-9]{3}')`,
`match_all(False, '[0-9]{7}')`,
];
const validated = await table.validate_expressions(expressions);
for (const expr of expressions) {
expect(validated.expression_schema[expr]).toBeUndefined();
}
expect(validated.errors[expressions[0]].error_message).toEqual(
"Failed parameter type check for function 'match_all', Expected 'TS' call set: 'TT'",
);
expect(validated.errors[expressions[1]].error_message).toEqual(
"Failed parameter type check for function 'match_all', Expected 'TS' call set: 'TT'",
);
expect(validated.errors[expressions[2]]).toEqual({
line: 0,
column: 0,
error_message:
"Type Error - inputs do not resolve to a valid expression.",
});
expect(validated.errors[expressions[3]]).toEqual({
line: 0,
column: 0,
error_message:
"Type Error - inputs do not resolve to a valid expression.",
});
await table.delete();
});
test("Match string with regex", async () => {
const table = await perspective.table({
a: ["ABC", "DEF", "cbA", "HIjK", "lMNoP"],
b: ["abc123", "abc567", "abc56", "1234567", "aaa000"],
});
const expressions = [
`match("a", '.*')`,
`match("b", '[a-z]{3}[0-9]{3}')`,
`match("b", '[0-9]{7}')`,
];
const view = await table.view({
expressions,
});
const schema = await view.expression_schema();
for (const expr of expressions) {
expect(schema[expr]).toEqual("boolean");
}
const results = await view.to_columns();
expect(results[expressions[0]]).toEqual(Array(5).fill(true));
expect(results[expressions[1]]).toEqual([
true,
true,
false,
false,
true,
]);
expect(results[expressions[2]]).toEqual([
false,
false,
false,
true,
false,
]);
await view.delete();
await table.delete();
});
test("match_all string with regex", async () => {
const table = await perspective.table({
a: ["ABC", "DEF", "cbA", "HIjK", "lMNoP"],
b: ["abc123", "abc567", "abc56", "1234567", "aaa0001234"],
});
const expressions = [
`match_all("a", '.*')`,
`match_all("b", '[a-z]{3}[0-9]{3}')`,
`match_all("b", '[0-9]{7}')`,
];
const view = await table.view({
expressions,
});
const schema = await view.expression_schema();
for (const expr of expressions) {
expect(schema[expr]).toEqual("boolean");
}
const results = await view.to_columns();
expect(results[expressions[0]]).toEqual(Array(5).fill(true));
expect(results[expressions[1]]).toEqual([
true,
true,
false,
false,
false,
]);
expect(results[expressions[2]]).toEqual([
false,
false,
false,
true,
false,
]);
await view.delete();
await table.delete();
});
test("Match string with regex, randomized", async () => {
const data = { a: [] };
for (let i = 0; i < 500; i++) {
data.a.push(random_string(100, true));
}
const table = await perspective.table(data);
const expressions = [
`match("a", '.*')`, // should match everything
`match("a", '.{100}')`, // should match strings the size of max
];
const view = await table.view({
expressions,
});
const schema = await view.expression_schema();
for (const expr of expressions) {
expect(schema[expr]).toEqual("boolean");
}
const results = await view.to_columns();
for (let i = 0; i < 500; i++) {
const a = results[expressions[0]][i];
const b = results[expressions[1]][i];
const source = data.a[i];
if (source === null) {
expect(a).toEqual(null);
expect(b).toEqual(null);
} else {
expect(a).toEqual(true);
expect(b).toEqual(source.length === 100);
}
}
await view.delete();
await table.delete();
});
test("match_all string with regex, randomized", async () => {
const data = { a: [] };
for (let i = 0; i < 500; i++) {
data.a.push(random_string(100, true));
}
const table = await perspective.table(data);
const expressions = [
`match_all("a", '.*')`, // should match everything
`match_all("a", '.{100}')`, // should match strings the size of max
];
const view = await table.view({
expressions,
});
const schema = await view.expression_schema();
for (const expr of expressions) {
expect(schema[expr]).toEqual("boolean");
}
const results = await view.to_columns();
for (let i = 0; i < 500; i++) {
const a = results[expressions[0]][i];
const b = results[expressions[1]][i];
const source = data.a[i];
if (source === null) {
expect(a).toEqual(null);
expect(b).toEqual(null);
} else {
expect(a).toEqual(true);
expect(b).toEqual(source.length === 100);
}
}
await view.delete();
await table.delete();
});
test("Match string and null with regex", async () => {
const table = await perspective.table({
a: ["ABC", "abc", null, "AbC", "12345"],
});
const expressions = [
`match("a", '.*')`,
`match("a", '[aAbBcC]{3}')`,
`match("a", '[0-9]{5}')`,
];
const view = await table.view({
expressions,
});
const schema = await view.expression_schema();
for (const expr of expressions) {
expect(schema[expr]).toEqual("boolean");
}
const results = await view.to_columns();
expect(results[expressions[0]]).toEqual([
true,
true,
null,
true,
true,
]);
expect(results[expressions[1]]).toEqual([
true,
true,
null,
true,
false,
]);
expect(results[expressions[2]]).toEqual([
false,
false,
null,
false,
true,
]);
await view.delete();
await table.delete();
});
test("match_all string and null with regex", async () => {
const table = await perspective.table({
a: ["ABC", "abc", null, "AbC", "12345", "123456"],
});
const expressions = [
`match_all("a", '.*')`,
`match_all("a", '[aAbBcC]{3}')`,
`match_all("a", '[0-9]{5}')`,
];
const view = await table.view({
expressions,
});
const schema = await view.expression_schema();
for (const expr of expressions) {
expect(schema[expr]).toEqual("boolean");
}
const results = await view.to_columns();
expect(results[expressions[0]]).toEqual([
true,
true,
null,
true,
true,
true,
]);
expect(results[expressions[1]]).toEqual([
true,
true,
null,
true,
false,
false,
]);
expect(results[expressions[2]]).toEqual([
false,
false,
null,
false,
true,
false,
]);
await view.delete();
await table.delete();
});
test("Search with string", async () => {
const table = await perspective.table({
a: ["ABC", "DEF", "cbA", "HIjK", "lMNoP"],
b: ["abc123", "abc567", "DEF56", "1234567", "AAA000"],
});
const expressions = [
`search("a", '(ABC)')`,
`search("b", '(.*)')`,
`search("a", '([A-Za-z]{3})')`,
`search("b", '([A-Z]{3})')`,
`search("b", '([0-9]{7})')`,
`search("b", '([A-Za-z]{3})')`,
];
const view = await table.view({ expressions });
const schema = await view.expression_schema();
for (const expr of expressions) {
expect(schema[expr]).toEqual("string");
}
const results = await view.to_columns();
expect(results[expressions[0]]).toEqual([
"ABC",
null,
null,
null,
null,
]);
expect(results[expressions[1]]).toEqual([
"abc123",
"abc567",
"DEF56",
"1234567",
"AAA000",
]);
expect(results[expressions[2]]).toEqual([
"ABC",
"DEF",
"cbA",
"HIj",
"lMN",
]);
expect(results[expressions[3]]).toEqual([
null,
null,
"DEF",
null,
"AAA",
]);
expect(results[expressions[4]]).toEqual([
null,
null,
null,
"1234567",
null,
]);
expect(results[expressions[5]]).toEqual([
"abc",
"abc",
"DEF",
null,
"AAA",
]);
await view.delete();
await table.delete();
});
test("Search - extract email", async () => {
const endings = ["com", "net", "org", "co.uk", "ie", "me", "io"];
const valid_address_chars = ALPHANUMERIC + "._-";
const get_data = (num_rows) => {
const data = [];
for (let i = 0; i < num_rows; i++) {
const email = `${random_string(
30,
false,
valid_address_chars,
)}@${random_string(10, false, ALPHA)}.${randchoice(
endings,
)}`;
data.push(email);
}
return data;
};
const table = await perspective.table({ a: get_data(100) });
const expressions = [
`// address
search("a", '^([a-zA-Z0-9._-]+)@');`,
`// domain
search("a", '@([a-zA-Z.]+)$')`,
];
// Make the same regex 10x - make sure it's ok to cache the regex
for (let i = 0; i < 10; i++) {
const view = await table.view({ expressions });
const schema = await view.expression_schema();
expect(schema).toEqual({
address: "string",
domain: "string",
});
const result = await view.to_columns();
for (let i = 0; i < 100; i++) {
const source = result["a"][i];
const expected_address =
source.match(/^([a-zA-Z0-9._-]+)@/)[1];
const expected_domain = source.match(/@([a-zA-Z.]+)$/)[1];
expect(result["address"][i]).toEqual(expected_address);
expect(result["domain"][i]).toEqual(expected_domain);
}
await view.delete();
}
await table.delete();
});
test("Search - extract card number", async () => {
const digits = () => {
const output = [];
for (let i = 0; i < 4; i++) {
output.push(randchoice(NUMERIC));
}
return output.join("");
};
const get_data = (num_rows) => {
const data = [];
for (let i = 0; i < num_rows; i++) {
const separator = Math.random() > 0.5 ? " " : "-";
const num = `${digits()}${separator}${digits()}${separator}${digits()}${separator}${digits()}`;
data.push(num);
}
return data;
};
const table = await perspective.table({ a: get_data(1000) });
const parsed = `var parts[4];
parts[0] := search("a", '^([0-9]{4})[ -][0-9]{4}[ -][0-9]{4}[ -][0-9]{4}');
parts[1] := search("a", '^[0-9]{4}[ -]([0-9]{4})[ -][0-9]{4}[ -][0-9]{4}');
parts[2] := search("a", '^[0-9]{4}[ -][0-9]{4}[ -]([0-9]{4})[ -][0-9]{4}');
parts[3] := search("a", '^[0-9]{4}[ -][0-9]{4}[ -][0-9]{4}[ -]([0-9]{4})');
concat(parts[0], parts[1], parts[2], parts[3])`;
const view = await table.view({
expressions: { parsed },
});
const schema = await view.expression_schema();
expect(schema).toEqual({ parsed: "string" });
const result = await view.to_columns();
for (let i = 0; i < 100; i++) {
const source = result["a"][i];
const expected = source.replace(/[ -]/g, "");
expect(result.parsed[i]).toEqual(expected);
}
await view.delete();
await table.delete();
});
test("Search local var", async () => {
const digits = () => {
const output = [];
for (let i = 0; i < 4; i++) {
output.push(randchoice(NUMERIC));
}
return output.join("");
};
const get_data = (num_rows) => {
const data = [];
for (let i = 0; i < num_rows; i++) {
const separator = Math.random() > 0.5 ? " " : "-";
const num = `${digits()}${separator}${digits()}${separator}${digits()}${separator}${digits()}`;
data.push(num);
}
return data;
};
const table = await perspective.table({ a: get_data(1000) });
const expression = `var parts[4];
parts[0] := search("a", '^([0-9]{4})[ -][0-9]{4}[ -][0-9]{4}[ -][0-9]{4}');
parts[1] := search("a", '^[0-9]{4}[ -]([0-9]{4})[ -][0-9]{4}[ -][0-9]{4}');
parts[2] := search("a", '^[0-9]{4}[ -][0-9]{4}[ -]([0-9]{4})[ -][0-9]{4}');
parts[3] := search("a", '^[0-9]{4}[ -][0-9]{4}[ -][0-9]{4}[ -]([0-9]{4})');
var z := parts[2];
z`;
const view = await table.view({
expressions: { parsed: expression },
});
const schema = await view.expression_schema();
expect(schema).toEqual({ parsed: "string" });
const result = await view.to_columns();
for (let i = 0; i < 100; i++) {
const source = result["a"][i];
const expected = source.replace(/[ -]/g, "");
expect(result.parsed[i]).toEqual(expected.substring(8, 12));
}
await view.delete();
await table.delete();
});
test("non strings should not work", async () => {
const table = await perspective.table({
x: [1, 2, 3],
y: [100.5, 200.5, 300.5],
z: [new Date(), new Date(), null],
});
const expressions = [];
const cols = ["x", "y", "z"];
const pattern = "(.*)";
const fns = [
"match",
"match_all",
"search",
"indexof",
"substring",
];
for (const fn of fns) {
for (const col of cols) {
let expr;
switch (fn) {
case "indexof":
{
expr = `var x[2]; indexof("${col}", '${pattern}', x);`;
}
break;
case "substring":
{
expr = `substring("${col}", 0)`;
}
break;
default:
{
expr = `${fn}("${col}", '${pattern}')`;
}
break;
}
expressions.push(expr);
}
}
const validate = await table.validate_expressions(expressions);
expect(validate.expression_schema).toEqual({});
for (const expr of expressions) {
expect(validate.errors[expr]).toEqual({
column: 0,
error_message:
"Type Error - inputs do not resolve to a valid expression.",
line: 0,
});
}
await table.delete();
});
test("string literals should work", async () => {
const table = await perspective.table({
x: [1, 2, 3],
});
const expressions = [];
const str = "abcdefghijklmn";
const pattern = "(.*)";
const fns = [
"match",
"match_all",
"search",
"indexof",
"substring",
];
const expected_schema = {};
for (const fn of fns) {
let expr;
switch (fn) {
case "indexof":
{
expr = `var x[2]; indexof('${str}', '${pattern}', x);`;
expected_schema[expr] = "boolean";
}
break;
case "substring":
{
expr = `substring('${str}', 0)`;
expected_schema[expr] = "string";
}
break;
default:
{
expr = `${fn}('${str}', '${pattern}')`;
fn === "search"
? (expected_schema[expr] = "string")
: (expected_schema[expr] = "boolean");
}
break;
}
expressions.push(expr);
}
const validate = await table.validate_expressions(expressions);
expect(validate.expression_schema).toEqual(expected_schema);
await table.delete();
});
test("Search - newlines", async () => {
const table = await perspective.table({
a: [
"abc\ndef",
"\n\n\n\nabc\ndef",
"abc\n\n\n\n\n\nabc\ndef\n\n\n\n",
null,
"def",
],
b: [
"hello\tworld",
"\n\n\n\n\nhello\n\n\n\n\n\tworld",
"\tworld",
"world",
null,
],
});
const view = await table.view({
expressions: {
c1: "search(\"a\", '(\ndef)')",
c2: "search(\"b\", '(\tworld)')",
},
});
const schema = await view.expression_schema();
expect(schema).toEqual({
c1: "string",
c2: "string",
});
const result = await view.to_columns();
expect(result["c1"]).toEqual([
"\ndef",
"\ndef",
"\ndef",
null,
null,
]);
expect(result["c2"]).toEqual([
"\tworld",
"\tworld",
"\tworld",
null,
null,
]);
await view.delete();
await table.delete();
});
test("indexof", async () => {
const table = await perspective.table({
x: "string",
y: "string",
});
await table.update({
x: ["15 02 1997", "31 11 2021", "01 01 2020", "31 12 2029"],
y: ["$300", "$123.58", "$0.99", "$1.99"],
});
const expressions = {
"parsed date": `var year_vec[2];
indexof("x", '([0-9]{4})$', year_vec);
year_vec[1] - year_vec[0];
`,
"parsed date2": `var year_vec[2];
indexof("x", '([0-9]{4})$', year_vec);
`,
"parsed dollars":
"var dollar_vec[2];indexof(\"y\", '^[$]([0-9.]+)', dollar_vec); dollar_vec[0] + dollar_vec[1];",
};
const view = await table.view({ expressions });
const schema = await view.expression_schema();
expect(schema).toEqual({
"parsed date": "float",
"parsed date2": "boolean",
"parsed dollars": "float",
});
const result = await view.to_columns();
expect(result["parsed date"]).toEqual(Array(4).fill(3));
expect(result["parsed date2"]).toEqual(Array(4).fill(true));
expect(result["parsed dollars"]).toEqual([4, 7, 5, 5]);
await view.delete();
await table.delete();
});
test.skip("OG - indexof various vectors", async () => {
const table = await perspective.table({
x: "string",
y: "string",
});
await table.update({
x: ["15 02 1997", "31 11 2021", "01 01 2020", "31 12 2029"],
y: ["$300", "$123.58", "$0.99", "$1.99"],
});
const expressions = {
"parsed date": `var year_vec[1]; // vector too small
indexof("x", '([0-9]{4})$', year_vec);
`,
"parsed date2": `var year_vec[2];
indexof("x", '([a-z])', year_vec); // should not match
`,
"parsed dollars":
"var dollar_vec[2] := {100, 200};indexof(\"y\", '^[$]([0-9.]+)', dollar_vec); dollar_vec[0] + dollar_vec[1];",
"parsed dollars2": `var dollar_vec[2] := {100, 200};
indexof("y", '^([a-z])', dollar_vec); // should not match
dollar_vec[0] + dollar_vec[1]; // should not overwrite vector
`,
};
const view = await table.view({ expressions });
const schema = await view.expression_schema();
expect(schema).toEqual({
"parsed date": "boolean",
"parsed date2": "boolean",
"parsed dollars": "float",
"parsed dollars2": "float",
});
const result = await view.to_columns();
expect(result["parsed date"]).toEqual(Array(4).fill(null));
expect(result["parsed date2"]).toEqual(Array(4).fill(false));
expect(result["parsed dollars"]).toEqual([4, 7, 5, 5]);
expect(result["parsed dollars2"]).toEqual(Array(4).fill(300));
await view.delete();
await table.delete();
});
test("indexof various vectors", async () => {
const table = await perspective.table({
x: "string",
y: "string",
});
await table.update({
x: ["15 02 1997", "31 11 2021", "01 01 2020", "31 12 2029"],
y: ["$300", "$123.58", "$0.99", "$1.99"],
});
const expressions = {
"parsed date": `var year_vec[1]; // vector too small
indexof("x", '([0-9]{4})$', year_vec);
`,
"parsed date2": `var year_vec[2];
indexof("x", '([a-z])', year_vec); // should not match
`,
"parsed dollars":
"var dollar_vec[2] := {100, 200};indexof(\"y\", '^[$]([0-9.]+)', dollar_vec); dollar_vec[0] + dollar_vec[1];",
"parsed dollars2": `var dollar_vec[2] := {100, 200};
indexof("y", '^([a-z])', dollar_vec); // should not match
dollar_vec[0] + dollar_vec[1]; // should not overwrite vector
`,
};
try {
await table.view({ expressions });
} catch (e) {
const expected = `Abort(): Type Error - inputs do not resolve to a valid expression.`;
expect(e.message.slice(0, expected.length)).toEqual(expected);
}
delete expressions["parsed date"];
const view = await table.view({ expressions });
const schema = await view.expression_schema();
expect(schema).toEqual({
"parsed date2": "boolean",
"parsed dollars": "float",
"parsed dollars2": "float",
});
const result = await view.to_columns();
expect(result["parsed date2"]).toEqual(Array(4).fill(false));
expect(result["parsed dollars"]).toEqual([4, 7, 5, 5]);
expect(result["parsed dollars2"]).toEqual(Array(4).fill(300));
await view.delete();
await table.delete();
});
test("indexof and substr", async () => {
const table = await perspective.table({
x: "string",
y: "string",
});
await table.update({
x: ["15 02 1997", "31 11 2021", "01 01 2020", "31 12 2029"],
y: ["$300", "$123.58", "$0.99", "$1.99"],
});
const expressions = [
`// year
var year_vec[2];
indexof("x", '([0-9]{4})$', year_vec);
var start := year_vec[0];
var len := year_vec[1] - start + 1;
substring("x", start, len);`,
`// year int
var year_vec[2];
indexof("x", '([0-9]{4})$', year_vec);
var start := year_vec[0];
var len := year_vec[1] - start + 1;
integer(substring("x", start, len));`,
`// dollars
var out[2];
indexof("y", '^[$]([0-9.]+)', out);
substring("y", out[0], out[1] - out[0] + 1)`,
`// dollars float
var out[2];
indexof("y", '^[$]([0-9.]+)', out);
float(substring("y", out[0], out[1] - out[0] + 1))`,
];
const view = await table.view({ expressions });
const schema = await view.expression_schema();
expect(schema).toEqual({
year: "string",
"year int": "integer",
dollars: "string",
"dollars float": "float",
});
const result = await view.to_columns();
expect(result.year).toEqual(["1997", "2021", "2020", "2029"]);
expect(result["year int"]).toEqual([1997, 2021, 2020, 2029]);
expect(result.dollars).toEqual(["300", "123.58", "0.99", "1.99"]);
expect(result["dollars float"]).toEqual([300, 123.58, 0.99, 1.99]);
await view.delete();
await table.delete();
});
test("substr with invalid indices", async () => {
const table = await perspective.table({
x: ["abc, def, efg", "abcdef", null, "", "12345678"],
y: ["abc", "abc", "abc", "abc", "abc"],
});
const view = await table.view({
expressions: [
"substring('abcdef', 0)",
"substring('abcdef', 3)",
'substring("x", 3)',
'substring("x", -1)',
'substring("x", 1, 10000)',
'substring("x", 10000)',
'substring("x", 5, 4)',
'substring("y", 0, 3)',
'substring("y", 0)',
'substring("y", 1, 2)',
'substring("y", 2, 2)',
'substring("y", 1, 1)',
].reduce((x, y) => Object.assign(x, { [y]: y }), {}),
});
const results = await view.to_columns();
expect(results["substring('abcdef', 0)"]).toEqual(
Array(5).fill("abcdef"),
);
expect(results["substring('abcdef', 3)"]).toEqual(
Array(5).fill("def"),
);
expect(results['substring("x", 3)']).toEqual([
", def, efg",
"def",
null,
null,
"45678",
]);
expect(results['substring("y", 0, 3)']).toEqual(
Array(5).fill("abc"),
);
expect(results['substring("y", 1, 2)']).toEqual(
Array(5).fill("bc"),
);
expect(results['substring("y", 0)']).toEqual(results["y"]);
expect(results['substring("y", 2, 2)']).toEqual(
Array(5).fill(null),
);
expect(results['substring("y", 1, 1)']).toEqual(Array(5).fill("b"));
expect(results['substring("x", -1)']).toEqual(Array(5).fill(null));
expect(results['substring("x", 1, 10000)']).toEqual(
Array(5).fill(null),
);
expect(results['substring("x", 10000)']).toEqual(
Array(5).fill(null),
);
expect(results['substring("x", 5, 4)']).toEqual([
"def,",
null,
null,
null,
null,
]);
// substring() with invalid indices will pass type checking but
// will return null.
const validate = await table.validate_expressions([
'substring("x", -1)',
'substring("x", 1, 10000)',
'substring("x", 10000)',
'substring("x", 5, 4)',
]);
expect(validate.expression_schema).toEqual({
'substring("x", -1)': "string",
'substring("x", 1, 10000)': "string",
'substring("x", 10000)': "string",
'substring("x", 5, 4)': "string",
});
expect(validate.errors).toEqual({});
await view.delete();
await table.delete();
});
test("substr with partial update", async () => {
const table = await perspective.table(
{
x: ["abc, def, efg", "abcdef", null, "", "12345678"],
y: [1, 2, 3, 4, 5],
},
{ index: "y" },
);
const view = await table.view({
expressions: {
'substring("x", 3)': 'substring("x", 3)',
'substring("x", 1, 2)': 'substring("x", 1, 2)',
},
});
let results = await view.to_columns();
expect(results['substring("x", 3)']).toEqual([
", def, efg",
"def",
null,
null,
"45678",
]);
expect(results['substring("x", 1, 2)']).toEqual([
"bc",
"bc",
null,
null,
"23",
]);
await table.update({
x: ["new string", null, "abc"],
y: [3, 1, 5],
});
results = await view.to_columns();
expect(results['substring("x", 3)']).toEqual([
null,
"def",
" string",
null,
null,
]);
expect(results['substring("x", 1, 2)']).toEqual([
null,
"bc",
"ew",
null,
"bc",
]);
await table.update({
x: ["1234", "1234", "1234", "1234", "1234"],
y: [5, 4, 3, 2, 1],
});
results = await view.to_columns();
expect(results['substring("x", 3)']).toEqual([
"4",
"4",
"4",
"4",
"4",
]);
expect(results['substring("x", 1, 2)']).toEqual([
"23",
"23",
"23",
"23",
"23",
]);
await view.delete();
await table.delete();
});
test("substr with clear", async () => {
const table = await perspective.table({
x: ["abc, def, efg", "abcdef", null, "", "12345678"],
});
const view = await table.view({
expressions: {
'substring("x", 3)': 'substring("x", 3)',
'substring("x", 1, 2)': 'substring("x", 1, 2)',
},
});
let results = await view.to_columns();
expect(results['substring("x", 3)']).toEqual([
", def, efg",
"def",
null,
null,
"45678",
]);
expect(results['substring("x", 1, 2)']).toEqual([
"bc",
"bc",
null,
null,
"23",
]);
await view.delete();
await table.clear();
expect(await table.size()).toEqual(0);
await table.update({
x: ["new string", null, "abc"],
});
const view2 = await table.view({
expressions: {
'substring("x", 3)': 'substring("x", 3)',
'substring("x", 1, 2)': 'substring("x", 1, 2)',
},
});
results = await view2.to_columns();
expect(results['substring("x", 3)']).toEqual([
" string",
null,
null,
]);
await view2.delete();
await table.delete();
});
test("substr more invalid indices", async () => {
const table = await perspective.table(
{
x: ["abc, def, efg", "abcdef", null, "", "12345678"],
y: [1, 2, 3, 4, 5],
},
{ index: "y" },
);
const view = await table.view({
expressions: [
'substring("x", 100)',
'substring("x", 1, 300)',
'substring("x", -100)',
].reduce((x, y) => Object.assign(x, { [y]: y }), {}),
});
let results = await view.to_columns();
expect(results['substring("x", 100)']).toEqual(Array(5).fill(null));
expect(results['substring("x", 1, 300)']).toEqual(
Array(5).fill(null),
);
expect(results['substring("x", -100)']).toEqual(
Array(5).fill(null),
);
await table.remove([1, 2, 3, 4, 5]);
const str = Array(100).fill("abcde").join("");
await table.update({
x: [str],
y: [1],
});
results = await view.to_columns();
expect(results['substring("x", 100)']).toEqual([
str.substring(100),
]);
expect(results['substring("x", 1, 300)']).toEqual([
str.substr(1, 300),
]);
expect(results['substring("x", -100)']).toEqual(
Array(1).fill(null),
);
await view.delete();
await table.delete();
});
test("substring - extract email", async () => {
const endings = ["com", "net", "org", "co.uk", "ie", "me", "io"];
const valid_address_chars = ALPHANUMERIC + "._-";
const get_data = (num_rows) => {
const data = [];
for (let i = 0; i < num_rows; i++) {
const email = `${random_string(
30,
false,
valid_address_chars,
)}@${random_string(10, false, ALPHA)}.${randchoice(
endings,
)}`;
data.push(email);
}
return data;
};
const table = await perspective.table({ a: get_data(100) });
const expressions = [
`// ending
var domain := search(\"a\", '@([a-zA-Z.]+)$');
var len := length(domain);
if (len > 0 and is_not_null(domain)) {
search(domain, '[.](.*)$');
} else {
'not found';
}`,
];
// Make the same regex 10x - make sure it's ok to cache the regex
for (let i = 0; i < 10; i++) {
const view = await table.view({ expressions });
const schema = await view.expression_schema();
expect(schema).toEqual({
ending: "string",
});
const result = await view.to_columns();
for (let i = 0; i < 100; i++) {
const source = result["a"][i];
const domain = source.match(/@([a-zA-Z.]+)$/)[1];
const ending = domain.match(/[.](.*)$/)[1];
expect(result["ending"][i]).toEqual(ending);
}
await view.delete();
}
await table.delete();
});
test("replace", async () => {
const digits = () => {
const output = [];
for (let i = 0; i < 4; i++) {
output.push(randchoice(NUMERIC));
}
return output.join("");
};
const data = [];
const index = [];
for (let i = 0; i < 1000; i++) {
const separator = Math.random() > 0.5 ? " " : "-";
const num = `${digits()}${separator}${digits()}${separator}${digits()}${separator}${digits()}`;
data.push(num);
index.push(i);
}
const table = await perspective.table({ a: "string", b: "string" });
await table.update({ a: data, b: index });
const expressions = {
[`w`]: `replace('abc-def-hijk', '-', '')`,
[`x`]: `replace("a", '[0-9]{4}$', "b")`,
[`y`]: `replace("a", '[a-z]{4}$', "b")`,
[`z`]: `var x := 'long string, very cool!'; replace("a", '^[0-9]{4}', x)`,
};
const validate = await table.validate_expressions(expressions);
expect(validate.expression_schema).toEqual({
w: "string",
x: "string",
y: "string",
z: "string",
});
const view = await table.view({ expressions });
const result = await view.to_columns();
for (let i = 0; i < 100; i++) {
const source = result["a"][i];
const idx = result["b"][i];
expect(result["w"][i]).toEqual("abcdef-hijk");
expect(result["x"][i]).toEqual(
source.replace(/[0-9]{4}$/, idx),
);
expect(result["y"][i]).toEqual(source);
expect(result["z"][i]).toEqual(
source.replace(/^[0-9]{4}/, "long string, very cool!"),
);
}
await view.delete();
await table.delete();
});
test("replace invalid", async () => {
const table = await perspective.table({ a: "string", b: "string" });
const expressions = {
[`v`]: `replace('abc-def-hijk', '-', 123)`,
[`w`]: `replace('', '-', today())`,
[`x`]: `replace("a", '[0-9]{4}$', today())`,
[`y`]: `replace("a", '[a-z]{4}$', null)`,
[`z`]: `var x := 123; replace("a", '^[0-9]{4}', x)`,
};
const validate = await table.validate_expressions(expressions);
expect(validate.expression_schema).toEqual({});
await table.delete();
});
test("replace all", async () => {
const digits = () => {
const output = [];
for (let i = 0; i < 4; i++) {
output.push(randchoice(NUMERIC));
}
return output.join("");
};
const data = [];
const index = [];
for (let i = 0; i < 1000; i++) {
const separator = Math.random() > 0.5 ? " " : "-";
const num = `${digits()}${separator}${digits()}${separator}${digits()}${separator}${digits()}`;
data.push(num);
index.push(i);
}
const table = await perspective.table({ a: "string", b: "string" });
await table.update({ a: data, b: index });
const expressions = {
[`w`]: `replace_all('abc-def-hijk', '-', '')`,
[`x`]: `replace_all("a", '[0-9]{4}', "b")`,
[`y`]: `replace_all("a", '[a-z]{4}', "b")`,
[`z`]: `var x := 'long string, very cool!'; replace_all("a", '[0-9]{4}', x)`,
};
const validate = await table.validate_expressions(expressions);
expect(validate.expression_schema).toEqual({
w: "string",
x: "string",
y: "string",
z: "string",
});
const view = await table.view({ expressions });
const result = await view.to_columns();
for (let i = 0; i < 100; i++) {
const source = result["a"][i];
const idx = result["b"][i];
expect(result["w"][i]).toEqual("abcdefhijk");
expect(result["x"][i]).toEqual(
source.replace(/[0-9]{4}/g, () => idx),
);
expect(result["y"][i]).toEqual(source);
expect(result["z"][i]).toEqual(
source.replace(
/[0-9]{4}/g,
() => "long string, very cool!",
),
);
}
await view.delete();
await table.delete();
});
test("replace all invalid", async () => {
const table = await perspective.table({ a: "string", b: "string" });
const expressions = {
[`v`]: `replace_all('abc-def-hijk', '-', 123)`,
[`w`]: `replace_all('', '-', today())`,
[`x`]: `replace_all("a", '[0-9]{4}$', today())`,
[`y`]: `replace_all("a", '[a-z]{4}$', null)`,
[`z`]: `var x := 123; replace_all("a", '^[0-9]{4}', x)`,
};
const validate = await table.validate_expressions(expressions);
expect(validate.expression_schema).toEqual({});
await table.delete();
});
});
})(perspective);