Skip to main content

perspective_js/
typed_array.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13use std::io::Cursor;
14
15use arrow_array::cast::AsArray;
16use arrow_array::types::*;
17use arrow_array::{
18    Array as _, ArrayRef, ArrowPrimitiveType, DictionaryArray, PrimitiveArray, StringArray,
19};
20use arrow_ipc::reader::StreamReader;
21use arrow_schema::{DataType, TimeUnit};
22use js_sys::{Array, Function, JsString, Uint8Array};
23use perspective_client::ViewWindow;
24use ts_rs::TS;
25use wasm_bindgen::JsCast;
26use wasm_bindgen::prelude::*;
27
28#[wasm_bindgen]
29unsafe extern "C" {
30    #[wasm_bindgen(typescript_type = "TypedArrayWindow")]
31    #[derive(Clone)]
32    pub type JsTypedArrayWindow;
33}
34
35/// Options for `with_typed_arrays`, extending `ViewWindow` with
36/// typed-array-specific options.
37#[derive(Default, serde::Deserialize, TS)]
38pub struct TypedArrayWindow {
39    #[serde(flatten)]
40    pub view_window: ViewWindow,
41
42    /// When `true`, Float64/Date32/Timestamp columns are output as
43    /// `Float32Array` instead of `Float64Array`.
44    #[serde(default)]
45    pub float32: bool,
46}
47
48impl From<TypedArrayWindow> for ViewWindow {
49    fn from(w: TypedArrayWindow) -> Self {
50        w.view_window
51    }
52}
53
54fn zero_invalid_slots<T: ArrowPrimitiveType>(arr: &PrimitiveArray<T>) {
55    let Some(nulls) = arr.nulls() else { return };
56    let ptr = arr.values().as_ptr() as *mut T::Native;
57    let chunks = nulls.inner().bit_chunks();
58    let mut base = 0;
59    for chunk in chunks.iter() {
60        if chunk != u64::MAX {
61            for bit in 0..64 {
62                if chunk & (1 << bit) == 0 {
63                    unsafe { ptr.add(base + bit).write(T::default_value()) };
64                }
65            }
66        }
67
68        base += 64;
69    }
70
71    let rem = chunks.remainder_bits();
72    for bit in 0..chunks.remainder_len() {
73        if rem & (1 << bit) == 0 {
74            unsafe { ptr.add(base + bit).write(T::default_value()) };
75        }
76    }
77}
78
79/// Emit a sub-32-bit integer column as an `Int32Array`. Every Arrow
80/// integer width the engine emits below 32 bits (`i8`/`u8`/`i16`/`u16`)
81/// widens losslessly, so consumers see ONE integer representation
82/// regardless of the column's storage width — and, like `Int32`, one
83/// the `float32` flag never narrows. Needs a copy; `Box<[i32]>` gives
84/// the stable data pointer the zero-copy `view` requires, exactly as
85/// the `f32`/`f64` conversion buffers do.
86fn set_widened_i32<T>(
87    col: &ArrayRef,
88    col_idx: usize,
89    js_values: &Array,
90    js_dicts: &Array,
91    storage: &mut Vec<Box<[i32]>>,
92) where
93    T: ArrowPrimitiveType,
94    T::Native: Into<i32>,
95{
96    let typed = col.as_primitive::<T>();
97    zero_invalid_slots(typed);
98    let vals: Box<[i32]> = typed.values().iter().map(|&v| v.into()).collect();
99
100    let arr = unsafe { js_sys::Int32Array::view(&vals) };
101    storage.push(vals);
102    js_values.set(col_idx as u32, arr.into());
103    js_dicts.set(col_idx as u32, JsValue::NULL);
104}
105
106/// Decode an Arrow IPC batch and call `callback` once with all columns.
107///
108/// Callback signature:
109/// ```js
110/// callback(names: string[], values: TypedArray[], validities: (Uint8Array|null)[], dictionaries: (string[]|null)[]) => void | Promise<void>
111/// ```
112///
113/// If the callback returns a `Promise`, it is awaited before the Arrow
114/// batch (and therefore the zero-copy typed-array views into it) is
115/// dropped. A synchronous callback returning `undefined` is supported
116/// with no promise-handling overhead.
117pub(crate) async fn decode_and_call(
118    arrow: &[u8],
119    float32: bool,
120    callback: &Function,
121) -> Result<(), JsValue> {
122    let cursor = Cursor::new(arrow);
123    let reader = StreamReader::try_new(cursor, None)
124        .map_err(|e| JsValue::from_str(&format!("Arrow decode error: {e}")))?;
125
126    let batch = reader
127        .into_iter()
128        .next()
129        .ok_or_else(|| JsValue::from_str("Arrow IPC contained no record batches"))?
130        .map_err(|e| JsValue::from_str(&format!("Arrow batch error: {e}")))?;
131
132    let schema = batch.schema();
133    let num_cols = batch.num_columns();
134
135    let js_names = Array::new_with_length(num_cols as u32);
136    let js_values = Array::new_with_length(num_cols as u32);
137    let js_validities = Array::new_with_length(num_cols as u32);
138    let js_dicts = Array::new_with_length(num_cols as u32);
139
140    // Storage for type-conversion buffers (narrow-int/bool widening,
141    // Int64/UInt64/Date32/Timestamp and `float32` narrowing). These
142    // MUST outlive the callback because `js_sys::*Array::view()`
143    // creates zero-copy views into their heap memory. Using `Box<[T]>`
144    // (rather than `Vec<T>`) yields a stable data pointer that won't
145    // move when the outer Vec grows, so a view created before the push
146    // stays valid.
147    let mut i32_storage: Vec<Box<[i32]>> = Vec::new();
148    let mut f32_storage: Vec<Box<[f32]>> = Vec::new();
149    let mut f64_storage: Vec<Box<[f64]>> = Vec::new();
150
151    // The bytes under a NULL slot in the source Arrow are UNDEFINED —
152    // the perspective engine zero-fills them but e.g. DuckDB's Arrow
153    // output leaves NaN (which, unlike the garbage a consumer merely
154    // *displays* wrong, poisons any consumer that aggregates, e.g. the
155    // treemap's bottom-up value sums). `zero_invalid_slots` normalizes
156    // every column in place — forcing invalid slots to 0 so all backends
157    // present the same value contract — without giving up the zero-copy
158    // view.
159    for col_idx in 0..num_cols {
160        let field = schema.field(col_idx);
161        let col = batch.column(col_idx);
162        let validity = col.nulls().map(|nulls| nulls.validity());
163
164        js_names.set(col_idx as u32, JsString::from(field.name().as_str()).into());
165
166        match col.data_type() {
167            DataType::Int8 => {
168                set_widened_i32::<Int8Type>(col, col_idx, &js_values, &js_dicts, &mut i32_storage);
169            },
170            DataType::UInt8 => {
171                set_widened_i32::<UInt8Type>(col, col_idx, &js_values, &js_dicts, &mut i32_storage);
172            },
173            DataType::Int16 => {
174                set_widened_i32::<Int16Type>(col, col_idx, &js_values, &js_dicts, &mut i32_storage);
175            },
176            DataType::UInt16 => {
177                set_widened_i32::<UInt16Type>(
178                    col,
179                    col_idx,
180                    &js_values,
181                    &js_dicts,
182                    &mut i32_storage,
183                );
184            },
185            DataType::UInt32 => {
186                let typed = col.as_primitive::<UInt32Type>();
187                zero_invalid_slots(typed);
188                let arr = unsafe { js_sys::Uint32Array::view(typed.values().as_ref()) };
189                js_values.set(col_idx as u32, arr.into());
190                js_dicts.set(col_idx as u32, JsValue::NULL);
191            },
192            DataType::Int32 => {
193                let typed = col.as_primitive::<Int32Type>();
194                zero_invalid_slots(typed);
195                let arr = unsafe { js_sys::Int32Array::view(typed.values().as_ref()) };
196                js_values.set(col_idx as u32, arr.into());
197                js_dicts.set(col_idx as u32, JsValue::NULL);
198            },
199            DataType::Float32 => {
200                let typed = col.as_primitive::<Float32Type>();
201                zero_invalid_slots(typed);
202                let arr = unsafe { js_sys::Float32Array::view(typed.values().as_ref()) };
203                js_values.set(col_idx as u32, arr.into());
204                js_dicts.set(col_idx as u32, JsValue::NULL);
205            },
206            DataType::Float64 => {
207                let typed = col.as_primitive::<Float64Type>();
208                zero_invalid_slots(typed);
209                if float32 {
210                    let vals: Box<[f32]> = typed.values().iter().map(|&v| v as f32).collect();
211
212                    let arr = unsafe { js_sys::Float32Array::view(&vals) };
213                    f32_storage.push(vals);
214                    js_values.set(col_idx as u32, arr.into());
215                } else {
216                    let arr = unsafe { js_sys::Float64Array::view(typed.values().as_ref()) };
217
218                    js_values.set(col_idx as u32, arr.into());
219                }
220
221                js_dicts.set(col_idx as u32, JsValue::NULL);
222            },
223            DataType::Date32 => {
224                // Datetime values are always emitted as Float64 — narrowing
225                // epoch-ms to f32 collapses ~256 ms of resolution at modern
226                // timestamps, so the `float32` flag is intentionally ignored
227                // for date/timestamp columns.
228                let typed = col.as_primitive::<Date32Type>();
229                zero_invalid_slots(typed);
230                let vals: Box<[f64]> = typed
231                    .values()
232                    .iter()
233                    .map(|&v| v as f64 * 86_400_000.0)
234                    .collect();
235
236                let arr = unsafe { js_sys::Float64Array::view(&vals) };
237                f64_storage.push(vals);
238                js_values.set(col_idx as u32, arr.into());
239                js_dicts.set(col_idx as u32, JsValue::NULL);
240            },
241            DataType::Timestamp(TimeUnit::Millisecond, _) => {
242                let typed = col.as_primitive::<TimestampMillisecondType>();
243                zero_invalid_slots(typed);
244                let vals: Box<[f64]> = typed.values().iter().map(|&v| v as f64).collect();
245
246                let arr = unsafe { js_sys::Float64Array::view(&vals) };
247                f64_storage.push(vals);
248                js_values.set(col_idx as u32, arr.into());
249                js_dicts.set(col_idx as u32, JsValue::NULL);
250            },
251            DataType::Int64 => {
252                let typed = col.as_primitive::<Int64Type>();
253                zero_invalid_slots(typed);
254                if float32 {
255                    let vals: Box<[f32]> = typed.values().iter().map(|&v| v as f32).collect();
256
257                    let arr = unsafe { js_sys::Float32Array::view(&vals) };
258                    f32_storage.push(vals);
259                    js_values.set(col_idx as u32, arr.into());
260                } else {
261                    let vals: Box<[f64]> = typed.values().iter().map(|&v| v as f64).collect();
262
263                    let arr = unsafe { js_sys::Float64Array::view(&vals) };
264                    f64_storage.push(vals);
265                    js_values.set(col_idx as u32, arr.into());
266                }
267
268                js_dicts.set(col_idx as u32, JsValue::NULL);
269            },
270            // Neither `u64` nor `i64` fits `Int32Array`, so both widen
271            // to float (narrowed by `float32` like the other float
272            // columns) and lose exactness past 2^53. `UInt64` is not an
273            // exotic case: `get_simple_accumulator_type` promotes EVERY
274            // unsigned width to `DTYPE_UINT64`, so the `sum` of any
275            // unsigned column in a `group_by` view lands here.
276            DataType::UInt64 => {
277                let typed = col.as_primitive::<UInt64Type>();
278                zero_invalid_slots(typed);
279                if float32 {
280                    let vals: Box<[f32]> = typed.values().iter().map(|&v| v as f32).collect();
281
282                    let arr = unsafe { js_sys::Float32Array::view(&vals) };
283                    f32_storage.push(vals);
284                    js_values.set(col_idx as u32, arr.into());
285                } else {
286                    let vals: Box<[f64]> = typed.values().iter().map(|&v| v as f64).collect();
287
288                    let arr = unsafe { js_sys::Float64Array::view(&vals) };
289                    f64_storage.push(vals);
290                    js_values.set(col_idx as u32, arr.into());
291                }
292
293                js_dicts.set(col_idx as u32, JsValue::NULL);
294            },
295            DataType::Boolean => {
296                // Bit-packed, so `zero_invalid_slots` (a `PrimitiveArray`
297                // memory rewrite) cannot apply — the `is_valid` test
298                // below enforces the same "invalid slots read 0"
299                // contract while materializing.
300                let typed = col.as_boolean();
301                let vals: Box<[i32]> = (0..typed.len())
302                    .map(|i| i32::from(typed.is_valid(i) && typed.value(i)))
303                    .collect();
304
305                let arr = unsafe { js_sys::Int32Array::view(&vals) };
306                i32_storage.push(vals);
307                js_values.set(col_idx as u32, arr.into());
308                js_dicts.set(col_idx as u32, JsValue::NULL);
309            },
310            DataType::Dictionary(..) => {
311                let dict = col
312                    .as_any()
313                    .downcast_ref::<DictionaryArray<Int32Type>>()
314                    .ok_or_else(|| {
315                        JsValue::from_str(&format!(
316                            "Unsupported dictionary key type for typed array: {}",
317                            col.data_type()
318                        ))
319                    })?;
320
321                let keys = dict.keys();
322                zero_invalid_slots(keys);
323                let arr = unsafe { js_sys::Int32Array::view(keys.values().as_ref()) };
324                js_values.set(col_idx as u32, arr.into());
325
326                let values = dict
327                    .values()
328                    .as_any()
329                    .downcast_ref::<StringArray>()
330                    .ok_or_else(|| {
331                        JsValue::from_str(&format!(
332                            "Unsupported dictionary value type for typed array: {}",
333                            dict.values().data_type()
334                        ))
335                    })?;
336
337                let js_dict = Array::new_with_length(values.len() as u32);
338                for i in 0..values.len() {
339                    js_dict.set(i as u32, JsValue::from_str(values.value(i)));
340                }
341                js_dicts.set(col_idx as u32, js_dict.into());
342            },
343            dt => {
344                return Err(JsValue::from_str(&format!(
345                    "Unsupported column type for typed array: {dt}"
346                )));
347            },
348        }
349
350        // SAFETY: Validity bitmap is owned by `batch` which outlives the
351        // callback — safe to view zero-copy.
352        let js_validity = validity.map(|v| unsafe { Uint8Array::view(v) });
353        js_validities.set(
354            col_idx as u32,
355            js_validity
356                .as_ref()
357                .map(JsValue::from)
358                .unwrap_or(JsValue::NULL),
359        );
360    }
361
362    let ret = callback.call4(
363        &JsValue::UNDEFINED,
364        &js_names.into(),
365        &js_values.into(),
366        &js_validities.into(),
367        &js_dicts.into(),
368    )?;
369
370    // If the callback returned a Promise, await it before releasing the
371    // batch — zero-copy TypedArray views into `batch` and the
372    // `i32`/`f32`/`f64` conversion buffers must remain valid for the
373    // full lifetime of the awaited work.
374    if ret.is_instance_of::<js_sys::Promise>() {
375        let promise: js_sys::Promise = ret.unchecked_into();
376        wasm_bindgen_futures::JsFuture::from(promise).await?;
377    }
378
379    // Keep storage alive until after the callback (and its awaited
380    // promise, if any) returns.
381    drop(i32_storage);
382    drop(f32_storage);
383    drop(f64_storage);
384
385    Ok(())
386}