Skip to content

Commit d282b71

Browse files
authored
Merge pull request #3172 from perspective-dev/gh-issues-roulette
Add `coalesce`
2 parents 60903e8 + ace5c72 commit d282b71

14 files changed

Lines changed: 515 additions & 44 deletions

File tree

docs/md/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
- [Overview](./explanation/python.md)
5555
- [Installation](./how_to/python/installation.md)
5656
- [Loading data into a `Table`](./how_to/python/table.md)
57+
- [`pandas`, `polars` and `pyarrow` integration](./how_to/python/table_data.md)
5758
- [Callbacks and events](./how_to/python/callbacks.md)
5859
- [Multithreading](./how_to/python/multithreading.md)
5960
- [Hosting a WebSocket server](./how_to/python/websocket.md)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# DataFrame and Arrow Compatibility
2+
3+
`perspective-python` accepts a `Table` constructor argument from any of the
4+
common Python columnar data libraries. In all three cases, `perspective.table`
5+
(and `Table.update()`) consume the input directly — there is no need to
6+
serialize to Apache Arrow IPC bytes yourself. However, note is
7+
still the most efficient way to bulk load data into `Table`.
8+
9+
## PyArrow
10+
11+
```python
12+
import pyarrow as pa
13+
import perspective
14+
15+
arrow_table = pa.table({
16+
"int": pa.array([1, 2, 3], type=pa.int64()),
17+
"float": pa.array([1.5, 2.5, 3.5], type=pa.float64()),
18+
"string": pa.array(["a", "b", "c"], type=pa.string()),
19+
})
20+
21+
table = perspective.table(arrow_table)
22+
```
23+
24+
The same applies to `Table.update()`:
25+
26+
```python
27+
table.update(arrow_table)
28+
```
29+
30+
If you have Arrow data already in IPC format (e.g. read from disk, received
31+
over the wire, or produced by another tool), pass the raw `bytes` directly —
32+
both stream and file formats are auto-detected:
33+
34+
```python
35+
with open("data.arrow", "rb") as f:
36+
table = perspective.table(f.read())
37+
```
38+
39+
## Polars
40+
41+
```python
42+
import polars as pl
43+
import perspective
44+
45+
df = pl.DataFrame({
46+
"a": [1, 2, 3, 4, 5],
47+
"b": ["x", "y", "z", "x", "y"],
48+
})
49+
50+
table = perspective.table(df)
51+
```
52+
53+
Internally, the `DataFrame` is converted to a `pyarrow.Table` before
54+
ingestion, so Polars columns inherit the Arrow type mapping above.
55+
56+
See also Perspective [Virtual Server support for `polars.DataFrame`](./virtual_server/polars.md)
57+
58+
## Pandas
59+
60+
`pandas.DataFrame` is supported via `pyarrow.Table.from_pandas`, which
61+
dictates behavior including type support — see the
62+
[pyarrow pandas docs](https://arrow.apache.org/docs/python/pandas.html) for
63+
details on which pandas dtypes round-trip cleanly.
64+
65+
```python
66+
from datetime import date, datetime
67+
import numpy as np
68+
import pandas as pd
69+
import perspective
70+
71+
data = pd.DataFrame({
72+
"int": np.arange(100),
73+
"float": [i * 1.5 for i in range(100)],
74+
"bool": [True for i in range(100)],
75+
"date": [date.today() for i in range(100)],
76+
"datetime": [datetime.now() for i in range(100)],
77+
"string": [str(i) for i in range(100)],
78+
})
79+
80+
table = perspective.table(data, index="float")
81+
```

rust/perspective-client/src/rust/config/expressions.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ pub struct CompletionItemSuggestion {
259259
}
260260

261261
#[doc(hidden)]
262-
pub static COMPLETIONS: [CompletionItemSuggestion; 77] = [
262+
pub static COMPLETIONS: [CompletionItemSuggestion; 79] = [
263263
CompletionItemSuggestion {
264264
label: "var",
265265
insert_text: "var ${1:x := 1}",
@@ -537,6 +537,16 @@ pub static COMPLETIONS: [CompletionItemSuggestion; 77] = [
537537
insert_text: "is_not_null(${1:x})",
538538
documentation: "Whether x is not a null value",
539539
},
540+
CompletionItemSuggestion {
541+
label: "coalesce",
542+
insert_text: "coalesce(${1:x}, ${2:y})",
543+
documentation: "Returns the first non-null argument.",
544+
},
545+
CompletionItemSuggestion {
546+
label: "contains",
547+
insert_text: "contains(${1:x}, ${2:'substr'})",
548+
documentation: "Whether the string column or value contains the literal substring.",
549+
},
540550
CompletionItemSuggestion {
541551
label: "not",
542552
insert_text: "not(${1:x})",

rust/perspective-js/test/js/expressions/numeric.spec.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1262,6 +1262,112 @@ function validate_binary_operations(output, expressions, operator) {
12621262
await table.delete();
12631263
});
12641264

1265+
test("coalesce returns first non-null arg", async function () {
1266+
const table = await perspective.table({
1267+
a: "integer",
1268+
b: "integer",
1269+
});
1270+
1271+
const view = await table.view({
1272+
expressions: {
1273+
coalesce_ab: 'coalesce("a", "b")',
1274+
coalesce_ab_default: 'coalesce("a", "b", 99)',
1275+
},
1276+
});
1277+
1278+
await table.update({
1279+
a: [1, null, null, 4],
1280+
b: [10, 20, null, 40],
1281+
});
1282+
1283+
const result = await view.to_columns();
1284+
expect(result["coalesce_ab"]).toEqual([1, 20, null, 4]);
1285+
expect(result["coalesce_ab_default"]).toEqual([1, 20, 99, 4]);
1286+
await view.delete();
1287+
await table.delete();
1288+
});
1289+
1290+
test("coalesce promotes mixed numeric inputs to float", async function () {
1291+
const table = await perspective.table({
1292+
i: "integer",
1293+
f: "float",
1294+
});
1295+
1296+
const view = await table.view({
1297+
expressions: {
1298+
coalesce_if: 'coalesce("i", "f")',
1299+
coalesce_if_default: 'coalesce("i", "f", 0.5)',
1300+
},
1301+
});
1302+
1303+
await table.update({
1304+
i: [1, null, null, 4],
1305+
f: [null, 2.5, null, 4.5],
1306+
});
1307+
1308+
const result = await view.to_columns();
1309+
const schema = await view.expression_schema();
1310+
expect(schema["coalesce_if"]).toEqual("float");
1311+
expect(schema["coalesce_if_default"]).toEqual("float");
1312+
expect(result["coalesce_if"]).toEqual([1, 2.5, null, 4]);
1313+
expect(result["coalesce_if_default"]).toEqual([1, 2.5, 0.5, 4]);
1314+
1315+
await view.delete();
1316+
await table.delete();
1317+
});
1318+
1319+
test("coalesce with all-null inputs returns null", async function () {
1320+
const table = await perspective.table({
1321+
a: "integer",
1322+
b: "integer",
1323+
});
1324+
1325+
const view = await table.view({
1326+
expressions: { coalesce_nulls: 'coalesce("a", "b")' },
1327+
});
1328+
1329+
await table.update({
1330+
a: [null, null, null],
1331+
b: [null, null, null],
1332+
});
1333+
1334+
const result = await view.to_columns();
1335+
expect(result["coalesce_nulls"]).toEqual([null, null, null]);
1336+
await view.delete();
1337+
await table.delete();
1338+
});
1339+
1340+
test("coalesce fails validation for incompatible types", async function () {
1341+
const table = await perspective.table({
1342+
a: "integer",
1343+
b: "string",
1344+
});
1345+
1346+
const validated = await table.validate_expressions([
1347+
'coalesce("a", "b")',
1348+
"coalesce(\"a\", 'fallback')",
1349+
]);
1350+
1351+
expect(validated.expression_schema).toEqual({});
1352+
expect(validated.errors['coalesce("a", "b")']).toEqual({
1353+
column: 0,
1354+
line: 0,
1355+
error_message:
1356+
"Type Error - inputs do not resolve to a valid expression.",
1357+
});
1358+
1359+
expect(validated.errors["coalesce(\"a\", 'fallback')"]).toEqual(
1360+
{
1361+
column: 0,
1362+
line: 0,
1363+
error_message:
1364+
"Type Error - inputs do not resolve to a valid expression.",
1365+
},
1366+
);
1367+
1368+
await table.delete();
1369+
});
1370+
12651371
test("null", async function () {
12661372
const table = await perspective.table({
12671373
a: "integer",

rust/perspective-js/test/js/expressions/string.spec.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,30 @@ const random_string = (
371371
table.delete();
372372
});
373373

374+
test("Coalesce strings", async function () {
375+
const table = await perspective.table({
376+
a: ["ABC", null, null, "HIjK", null],
377+
b: ["xyz", "DEF", null, "stu", null],
378+
});
379+
const view = await table.view({
380+
expressions: {
381+
coalesce_str: 'coalesce("a", "b", \'N/A\')',
382+
},
383+
});
384+
const result = await view.to_columns();
385+
const schema = await view.expression_schema();
386+
expect(schema["coalesce_str"]).toEqual("string");
387+
expect(result["coalesce_str"]).toEqual([
388+
"ABC",
389+
"DEF",
390+
"N/A",
391+
"HIjK",
392+
"N/A",
393+
]);
394+
view.delete();
395+
table.delete();
396+
});
397+
374398
test("Concat", async function () {
375399
const table = await perspective.table({
376400
a: ["abc", "deeeeef", "fg", "hhs", "abcdefghijk"],

0 commit comments

Comments
 (0)