qvd 0.1.0

High-performance library for reading, writing and converting Qlik QVD files with Parquet/Arrow support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
# QVD File Format — Complete Technical Documentation

## Overview

QVD (QlikView Data) — проприетарный бинарный формат файлов Qlik Sense/QlikView для хранения одной реляционной таблицы. Формат оптимизирован для максимально быстрого чтения/записи движком Qlik (до нескольких миллионов строк в секунду).

## Структура файла

QVD файл состоит из трёх последовательных частей:

```
┌─────────────────────────────┐
│  1. XML Header (метаданные) │  ← текстовый, UTF-8
├─────────────────────────────┤
│  2. Symbol Tables           │  ← бинарный, column-major
│     (уникальные значения    │
│      каждой колонки)        │
├─────────────────────────────┤
│  3. Index Table             │  ← бинарный, row-major, bit-stuffed
│     (строки = индексы       │
│      в symbol tables)       │
└─────────────────────────────┘
```

Все три части "плотно склеены" без разделителей. Между XML заголовком и бинарной частью — символы `\r\n` и нулевой байт `\0`.

---

## 1. XML Header (Метаданные)

### Структура XML

```xml
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<QvdTableHeader>
  <QvBuildNo>7314</QvBuildNo>
  <CreatorDoc></CreatorDoc>
  <CreateUtcTime>2019-04-03 06:24:33</CreateUtcTime>
  <SourceCreateUtcTime></SourceCreateUtcTime>
  <SourceFileUtcTime></SourceFileUtcTime>
  <SourceFileSize>-1</SourceFileSize>
  <StaleUtcTime></StaleUtcTime>
  <TableName>tab1</TableName>
  <Fields>
    <QvdFieldHeader>
      <FieldName>ID</FieldName>
      <BitOffset>0</BitOffset>
      <BitWidth>3</BitWidth>
      <Bias>-2</Bias>
      <NumberFormat>
        <Type>0</Type>
        <nDec>0</nDec>
        <UseThou>0</UseThou>
        <Fmt></Fmt>
        <Dec></Dec>
        <Thou></Thou>
      </NumberFormat>
      <NoOfSymbols>4</NoOfSymbols>
      <Offset>0</Offset>
      <Length>40</Length>
      <Comment></Comment>
      <Tags>
        <String>$numeric</String>
        <String>$integer</String>
      </Tags>
    </QvdFieldHeader>
  </Fields>
  <Compression></Compression>
  <RecordByteSize>1</RecordByteSize>
  <NoOfRecords>5</NoOfRecords>
  <Offset>77</Offset>
  <Length>5</Length>
  <Lineage>
    <LineageInfo>
      <Discriminator>...</Discriminator>
      <Statement>...</Statement>
    </LineageInfo>
  </Lineage>
  <Comment></Comment>
</QvdTableHeader>
```

### Основные поля метаданных

| Поле | Описание |
|------|----------|
| `QvBuildNo` | Номер билда Qlik, создавшего файл |
| `CreatorDoc` | Имя QVW/QVF файла-источника |
| `CreateUtcTime` | Время создания файла (UTC) |
| `TableName` | Имя таблицы в Qlik |
| `NoOfRecords` | Количество строк в таблице |
| `RecordByteSize` | Размер одной записи в index table (байты) |
| `Offset` | Смещение index table от начала бинарной части |
| `Length` | Длина index table в байтах |
| `Compression` | Сжатие (обычно пусто) |

### Поля метаданных колонки (QvdFieldHeader)

| Поле | Описание |
|------|----------|
| `FieldName` | Имя колонки |
| `BitOffset` | Битовое смещение в записи index table |
| `BitWidth` | Битовая ширина индекса в записи |
| `Bias` | Смещение индексов (0 или -2 для NULL) |
| `NoOfSymbols` | Количество уникальных значений |
| `Offset` | Смещение symbol table колонки от начала бинарной части |
| `Length` | Длина symbol table колонки в байтах |

### NumberFormat Types

| Значение | Тип |
|----------|-----|
| 0 | UNKNOWN |
| 1 | DATE |
| 2 | TIME |
| 3 | TIMESTAMP |
| 4 | INTERVAL |
| 5 | INTEGER |
| 6 | MONEY |
| 7 | REAL |
| 8 | ASCII |

> Поле `Type` в NumberFormat практически бесполезно для определения реального типа данных. В 90% случаев = 0 (UNKNOWN).

---

## 2. Symbol Table (Таблица символов)

### Общая структура

Для таблицы из N колонок будет N бинарных блоков symbol table, идущих подряд без разделителей. Каждый блок содержит уникальные значения одной колонки.

Смещение каждого блока: `Offset` в QvdFieldHeader (относительно начала бинарной части).
Длина: `Length` в QvdFieldHeader.

### Формат записи в symbol table

Каждое значение:
```
[1 байт: тип] [опциональное бинарное значение] [опциональная строка с нулевым терминатором]
```

### Типы данных (первый байт)

| Код | Тип | Бинарная часть | Строковая часть |
|-----|-----|----------------|-----------------|
| `0x01` | Integer | 4 байта (i32, little-endian) | нет |
| `0x02` | Double | 8 байт (f64, little-endian) | нет |
| `0x04` | String | нет | null-terminated строка (UTF-8) |
| `0x05` | Dual Int | 4 байта (i32, little-endian) | null-terminated строка |
| `0x06` | Dual Double | 8 байт (f64, little-endian) | null-terminated строка |

> **Код 0x03 не используется** (отсутствует в спецификации).

### Важные особенности

1. **Смешанные типы**: одна колонка может содержать значения разных типов (int, double, string).
2. **Невозможность индексации**: строковые значения имеют переменную длину → нельзя перейти к символу N без чтения всех предыдущих.
3. **Dual-типы**: содержат и числовое, и строковое представление. При чтении строковое представление имеет приоритет для отображения.

### Пример бинарного представления

Поле ID со значениями `123.12, 124, -2, 1`:
```
06 [8 байт: 123.12 как f64] "123.12\0"    — dual double
05 [4 байта: 124 как i32]   "124\0"        — dual int
05 [4 байта: -2 как i32]    "-2\0"         — dual int
05 [4 байта: 1 как i32]     "1\0"          — dual int
```
Итого: 40 байт.

---

## 3. Index Table (Таблица строк / Битовый индекс)

### Общая структура

- Расположение: смещение `Offset` от начала бинарной части (из корня XML)
- Размер: `NoOfRecords × RecordByteSize` байт
- Каждая запись имеет фиксированный размер `RecordByteSize` байт

### Формат записи

Каждая строка — это конкатенация битовых полей (индексов в symbol tables). Поля упакованы в минимальное количество бит:

- Порядок полей определяется `BitOffset` (от младших бит к старшим)
- Ширина каждого поля: `BitWidth` бит
- Общая длина выровнена до границы байта

### Порядок байтов (ВАЖНО!)

**Байты в записи хранятся в обратном порядке (little-endian byte order):**
- Первый байт записи → самые правые (младшие) биты
- Последний байт записи → самые левые (старшие) биты

Для чтения нужно:
1. Перевернуть байты записи
2. Читать биты от старших к младшим
3. Поле с `BitOffset=0` окажется в самых правых битах

### Вычисление индекса символа

```
raw_index = биты[BitOffset .. BitOffset + BitWidth]  (как unsigned integer)
actual_index = raw_index + Bias
```

### Обработка NULL

- Если `Bias = -2`: поле может содержать NULL
- NULL, когда `actual_index < 0` (т.е. raw_index = 0 при Bias = -2 → actual_index = -2)
- Также `actual_index = -1` считается NULL
- Все "настоящие" индексы увеличены на |Bias| (обычно на 2)

### Специальные случаи

1. **`BitWidth = 0`**: поле имеет только одно уникальное значение (или только NULL). В index table не занимает места.
2. **`NoOfSymbols = 0`**: поле всегда NULL, symbol table пуста.

### Пример

Таблица 5 строк, 2 колонки, `RecordByteSize = 1`:
```
Поле ID:   BitOffset=0, BitWidth=3, Bias=-2
Поле NAME: BitOffset=3, BitWidth=5, Bias=0
```

Запись `0x02` = бинарно `00000010`:
```
Биты 0-2 (ID):   010 = 2, + Bias(-2) = 0 → символ[0] = "123.12"
Биты 3-7 (NAME): 00000 = 0            = 0 → символ[0] = "Pete"
```

Запись `0x20` = бинарно `00100000`:
```
Биты 0-2 (ID):   000 = 0, + Bias(-2) = -2 → NULL
Биты 3-7 (NAME): 00100 = 4            = 4 → символ[4] = "None"
```

---

## 4. Даты и Timestamps

- **Дата**: целое число = количество дней от начала эпохи Qlik (30 декабря 1899 года)
- **Timestamp**: дробное число, целая часть = дата, дробная = время дня
  - `.0` = 00:00:00
  - `.999999` = 23:59:59
- При dual-типах (5, 6) строковое представление содержит читаемую дату

---

## 5. Функция EXISTS() в Qlik

### Как работает

`EXISTS(field, value)` проверяет, было ли значение `value` уже загружено в поле `field` в процессе выполнения скрипта загрузки.

### Внутренний механизм

1. Qlik строит **Symbol Table** для каждого поля — хранит только уникальные значения
2. Symbol Table использует **hash-based** структуру данных
3. `EXISTS()` выполняет **O(1) lookup** по хешу в Symbol Table
4. Symbol Table обновляется динамически при загрузке каждой новой строки

### Ключевые свойства

- Работает значительно быстрее JOIN для фильтрации строк
- Проверяет только одно поле за раз
- Для составных ключей нужно конкатенировать поля
- Позволяет "оптимизированную загрузку" QVD (самый быстрый режим)

### Реализация в нашей библиотеке

Для реализации аналога `EXISTS()`:
- При чтении QVD строим `HashSet<QvdValue>` для каждой колонки из symbol table
- При загрузке данных из другого QVD с `WHERE EXISTS` — проверяем через HashSet
- O(1) lookup по хешу, как в Qlik

---

## 6. Существующие реализации

### Rust (Python-биндинг)
- **[qvd-utils](https://github.com/SBentley/qvd-utils)** — Rust + PyO3, только чтение. Зависимости: `quick-xml`, `serde`, `bitvec`, `pyo3`

### Python
- **[PyQvd](https://github.com/MuellerConstantin/PyQvd)** — чтение и запись
- **[qvdfile](https://github.com/korolmi/qvdfile)** — исследовательская версия (автор статей на Хабре)

### C
- **[qvdreader](https://github.com/devinsmith/qvdreader)** — утилита чтения, использует libxml2

### JavaScript
- **[qvd-reader](https://github.com/mafuentes22/qvd-reader)** — чтение в массив объектов

### Java
- **[QVDConverter](https://github.com/ralfbecher/QlikView_QVDReader_Examples)** — чтение и конвертация

---

## 7. Режимы загрузки QVD в Qlik

### Optimized Load (до 100x быстрее)

Qlik поддерживает два режима чтения QVD:

- **Optimized** — данные загружаются как есть, без распаковки строк. До 100x быстрее.
- **Standard** — данные распаковываются и обрабатываются построчно.

**Optimized load работает ТОЛЬКО когда:**
- Загружаются все поля (или только переименовываются)
- Нет WHERE-условий (кроме простого `WHERE EXISTS`)
- Нет вычислимых полей (ApplyMap, конкатенации и т.д.)
- Нет трансформаций в том же LOAD-выражении

**Что ломает optimized load:**
- `WHERE` с любым условием кроме `EXISTS`
- `ApplyMap()` в LOAD
- Вычислимые поля (`field1 & '-' & field2`)
- Любые агрегации в том же LOAD

**Workaround в Qlik:** двухшаговая загрузка — сначала optimized LOAD в resident, потом LOAD из resident с трансформациями.

**Workaround с rustqvd:** выполнить трансформации ВНЕ Qlik (JOIN, WHERE, ApplyMap), сохранить результат в QVD, и Qlik сделает optimized load готового файла.

### Производительность на реальных данных

| Режим | 22 млн строк |
|-------|-------------|
| Optimized | ~3 сек |
| Standard | ~9 сек |
| С ApplyMap | ~15 сек |

---

## 8. Архитектура библиотеки `rustqvd`

### Текущие возможности (v0.1.0)

- Чтение QVD файлов (парсинг XML + бинарной части)
- Запись QVD файлов (byte-identical roundtrip)
- Генерация QVD с нуля через `QvdTableBuilder`
- Быстрый `exists()` через HashSet lookup (O(1))
- Оптимизированная `filter_rows_by_exists_fast()` — фильтрация на уровне символов
- Python-биндинги через PyO3/maturin

### Зависимости

- **Нулевые зависимости** для core-библиотеки
- `pyo3` — опционально, только для Python-биндингов (feature `python`)
- XML парсер написан с нуля внутри библиотеки

### Модули

```
src/
├── lib.rs          — публичный API, re-exports
├── error.rs        — типы ошибок (QvdError, QvdResult)
├── header.rs       — парсинг/генерация XML заголовка (свой XML парсер)
├── value.rs        — типы данных QVD (QvdSymbol, QvdValue)
├── symbol.rs       — чтение/запись symbol tables (бинарный формат)
├── index.rs        — чтение/запись index table (bit-stuffing)
├── reader.rs       — высокоуровневый reader (QvdTable, read_qvd_file)
├── writer.rs       — высокоуровневый writer + QvdTableBuilder
├── exists.rs       — ExistsIndex с HashSet + filter_rows_by_exists_fast
└── python.rs       — PyO3 биндинги (QvdTable, ExistsIndex, filter_exists)
```

### Производительность

Протестировано на 20 реальных QVD файлах (от 11 КБ до 2.8 ГБ):

| Файл | Размер | Строки | Колонки | Чтение | Запись |
|------|--------|--------|---------|--------|-------|
| sample_tiny.qvd | 11 KB | 12 | 5 | 0.0s | 0.0s |
| sample_small.qvd | 418 KB | 2,746 | 8 | 0.0s | 0.0s |
| sample_medium.qvd | 41 MB | 465,810 | 12 | 0.5s | 0.0s |
| sample_large.qvd | 587 MB | 5,458,618 | 15 | 6.1s | 0.4s |
| sample_xlarge.qvd | 1.7 GB | 87,617,047 | 6 | 36.8s | 1.6s |
| sample_huge.qvd | 2.8 GB | 11,907,648 | 42 | 24.3s | 2.4s |

**Все 20 файлов — byte-identical roundtrip (MD5 match).**

### Бенчмарк: rustqvd vs PyQvd (Pure Python, v2.3.1)

| Файл | Размер | Строки | Колонки | PyQvd | rustqvd | Ускорение |
|------|--------|--------|---------|-------|---------|-----------|
| sample_tiny.qvd | 11 KB | 12 | 5 | 0.016s | 0.000s | **33x** |
| sample_small.qvd | 418 KB | 2,746 | 8 | 0.047s | 0.002s | **22x** |
| sample_3mb.qvd | 3.3 MB | 81,343 | 7 | 0.449s | 0.014s | **32x** |
| sample_10mb_a.qvd | 10 MB | 248,311 | 9 | 1.9s | 0.1s | **33x** |
| sample_10mb_b.qvd | 10 MB | 1,423,886 | 4 | 4.983s | 0.171s | **29x** |
| sample_medium.qvd | 41 MB | 465,810 | 12 | 8.5s | 0.5s | **16x** |
| sample_500mb.qvd | 480 MB | 11,994,296 | 10 | 79.4s | 2.3s | **35x** |
| sample_large.qvd | 560 MB | 5,458,618 | 15 | 126.6s | 6.3s | **20x** |
| sample_xlarge.qvd | 1.7 GB | 87,617,047 | 6 | >10 мин (не завершился) | 29.6s | **>20x** |
| sample_huge.qvd | 2.8 GB | 11,907,648 | 42 | не тестировалось | 24.3s | — |

**rustqvd в 16-35 раз быстрее PyQvd.** На файлах >500 МБ PyQvd работает минутами, а на 1.7 ГБ (87M строк) не завершился за 10 минут. rustqvd читает тот же файл за 30 секунд.

### Python API

```python
import qvd

# Чтение
table = qvd.read_qvd("file.qvd")
table.columns          # имена колонок
table.num_rows         # количество строк
table.head(5)          # первые 5 строк как list[dict]
table.symbols("col")   # уникальные значения колонки
table.to_dict()        # весь файл как dict {col: [values]}
table.save("out.qvd")  # сохранение (byte-identical roundtrip)

# EXISTS — O(1) lookup
idx = qvd.ExistsIndex(table, "ClientID")
idx.exists("12345")      # True/False
"12345" in idx            # поддержка оператора in

# Фильтрация строк по EXISTS
rows = qvd.filter_exists(other_table, "ClientID", idx)
```

---

## 9. Конкурентный анализ

### Библиотеки для работы с QVD

| Библиотека | Язык | Backend | Чтение | Запись | Скорость | На crates.io |
|---|---|---|---|---|---|---|
| **rustqvd (наш)** | Rust + Python | Rust | да | да (byte-identical) | ~3M rows/sec | нет (будет первым) |
| qvd-utils | Python | Rust (PyO3) | да | нет | ~2M rows/sec | нет |
| PyQvd | Python | Pure Python | да | да | ~100K rows/sec | — |
| qvdfile | Python | Pure Python | да | да | очень медленно | — |
| qvd4js | JavaScript | JS | да | нет | медленно | — |
| qvdreader | C | C | да | нет | быстро | — |

**Ключевой факт: на crates.io НЕТ ни одного QVD crate.** Мы будем первыми.

### Коммерческие инструменты

| Инструмент | Статус | Описание |
|---|---|---|
| EasyQlik QViewer | **Закрыт** (заменён CSViewer) | Был основным QVD-просмотрщиком |
| EasyQlik CSViewer | Бесплатный | Просмотр QVD, Parquet и других форматов |
| Q-Eye | Бесплатный | QVD/QVX viewer и editor (Microsoft Store) |
| QData | Open source | Desktop viewer/editor на PyQvd |
| Alteryx QVD Tools | Community prototype | Чтение/запись, но с багами (даты=NULL, ошибки symbol table) |

### Тренд QVD → Parquet

С августа 2023 Qlik Sense нативно поддерживает Parquet для STORE/LOAD. Однако:
- **Проблема dual values**: QVD хранит loosely-typed duals (число+строка), Parquet — strongly-typed. Конвертация теряет данные.
- **Проблема смешанных типов**: одно поле QVD может содержать int, double и string. Parquet не может.
- **Размер**: QVD 1.7 ГБ → Parquet без сжатия 6 ГБ (dictionary encoding + bit stuffing в QVD очень эффективны).
- **Вывод**: QVD остаётся актуальным, особенно внутри Qlik-экосистемы.

---

## 10. Рыночные возможности и боли пользователей

### Что ищут пользователи (и не находят)

1. **SQL-запросы к QVD вне Qlik** — обсуждается на Qlik Community с 2017 года, решения нет
2. **QVD + DuckDB** — нет расширения DuckDB для QVD
3. **QVD + ClickHouse** — нет интеграции
4. **Streaming QVD reader** — все библиотеки грузят файл целиком в память
5. **Быстрая Python-библиотека с записью** — PyQvd пишет, но медленно; qvd-utils быстрый, но read-only

### Vendor lock-in

- QVD — проприетарный формат, данные заперты внутри Qlik
- Экспорт QVD из Qlik Cloud **не поддерживается** напрямую
- Организации хотят использовать QVD-данные в AI/ML пайплайнах
- Единственный путь — конвертация через Python (медленно) или CSV-export (неэффективно)

### Как rustqvd решает эти проблемы

| Боль пользователя | Решение rustqvd |
|---|---|
| "Нужен SQL к QVD" | DataFusion TableProvider / DuckDB VTab |
| "Нужна конвертация в Parquet" | Arrow RecordBatch → Parquet (без промежуточных файлов) |
| "QVD reload слишком медленный" | Pre-filter/pre-join вне Qlik, optimized load готового QVD |
| "Нет streaming reader" | Streaming reader с чанками (планируется) |
| "Python qvd-библиотека устарела" | PyO3 биндинги, read+write, 30x быстрее PyQvd |

---

## 11. План развития

### Фаза 1 — Core (ГОТОВО)

- [x] Чтение QVD (XML header + symbol tables + bit-stuffed index)
- [x] Запись QVD (byte-identical roundtrip)
- [x] EXISTS() через HashSet (O(1) lookup)
- [x] Python-биндинги (PyO3/maturin)
- [x] Тестирование на 20 реальных файлах (до 2.8 ГБ, 87M строк)
- [ ] Публикация на crates.io
- [ ] Публикация на PyPI

### Фаза 2 — Arrow & Streaming

- [ ] Streaming reader (чтение чанками, не грузить весь файл в память)
- [ ] Arrow RecordBatch output (мост к DuckDB, DataFusion, Polars)
- [ ] DuckDB VTab (виртуальная таблица QVD в DuckDB)
- [ ] CLI утилита (`qvd inspect`, `qvd head`, `qvd convert`, `qvd sql`)

### Фаза 3 — SQL Engine

- [ ] DataFusion TableProvider (SQL-запросы прямо к QVD файлам)
- [ ] Projection pushdown (читать только нужные колонки)
- [ ] Filter pushdown (фильтрация на уровне символов)
- [ ] JOIN нескольких QVD через DataFusion
- [ ] QVD → Parquet конвертация со сжатием

### Фаза 4 — ETL Platform

- [ ] Инкрементальное слияние QVD файлов
- [ ] Валидация данных (типы, дубликаты, свежесть)
- [ ] WASM-модуль для браузера (QVD Viewer Online)
- [ ] Polars IO plugin

---

## 12. Интеграция с экосистемой Rust Data Engineering

### Ключевой принцип: Arrow как универсальный мост

```
QVD файл ──► rustqvd ──► Arrow RecordBatch ──┬──► DuckDB (SQL)
                                               ├──► DataFusion (SQL)
                                               ├──► Polars (DataFrame)
                                               ├──► Parquet (файл)
                                               ├──► ClickHouse (import)
                                               └──► Delta Lake (таблица)
```

### DataFusion TableProvider

```rust
// QVD файлы как SQL-таблицы
let ctx = SessionContext::new();
ctx.register_table("sales", Arc::new(QvdTableProvider::open("sales.qvd")?))?;

let df = ctx.sql("
    SELECT region, SUM(amount) as total
    FROM sales
    WHERE date >= '2024-01-01'
    GROUP BY region
").await?;
```

### DuckDB Virtual Table

```rust
// QVD как виртуальная таблица DuckDB
let conn = Connection::open_in_memory()?;
conn.register_table_function::<QvdVTab>("read_qvd")?;

let result = conn.query_arrow("
    SELECT * FROM read_qvd('facts.qvd') f
    JOIN read_qvd('dim.qvd') d ON f.id = d.id
")?;
```

### Streaming Reader (планируется)

```rust
// Не грузит весь файл в память
let reader = QvdStreamReader::open("facts_50gb.qvd")?;
for batch in reader.batches(65536) {
    // batch: arrow::RecordBatch (~65K строк)
    // обрабатываем чанками, RAM = O(chunk_size)
}
```

### Ландшафт Rust Data Engineering

| Crate | Назначение | Связь с rustqvd |
|---|---|---|
| `arrow` | In-memory columnar формат | Выход rustqvd → RecordBatch |
| `datafusion` | SQL query engine | TableProvider для QVD |
| `polars` | DataFrame библиотека | IO plugin для QVD |
| `duckdb` | Embedded OLAP БД | VTab для QVD |
| `delta-rs` | Delta Lake таблицы | QVD → Delta конвертация |
| `parquet` | Parquet файлы | QVD → Parquet конвертация |
| `lance` | AI-optimized формат | QVD → Lance конвертация |

---

## 13. Практические сценарии применения

### Сценарий 1: ETL вне Qlik

```
Проблема: 400 Qlik скриптов, 3.5 ТБ QVD, тяжёлые JOIN на 3-15 млрд строк.
          Qlik съедает 390 ГБ RAM, reload занимает часы.

Решение:
  Source QVD → [rustqvd + DuckDB: JOIN, GROUP BY, Window] → Result QVD
                50 ГБ RAM, минуты

  Result QVD → [Qlik: optimized load] → Dashboard
                0 ГБ RAM, секунды
```

### Сценарий 2: Инкрементальная обработка

```
День N:
  prev_result.qvd (готов)  +  facts_day_N.qvd (30 МБ)
       ↓                          ↓
  [rustqvd + DuckDB: UNION ALL + GROUP BY]
       ↓
  result_day_N.qvd

Время: секунды. RAM: < 1 ГБ. Qlik не нужен.
```

### Сценарий 3: Pre-filter для Qlik

```
Проблема: WHERE EXISTS() ломает optimized load в Qlik.

Решение:
  big_facts.qvd ──► [rustqvd: filter_rows_by_exists_fast()]
                         ↓
                    filtered_facts.qvd (меньший файл)
                         ↓
                    [Qlik: optimized load — 100x быстрее]
```

### Сценарий 4: QVD как источник для BI без Qlik

```
QVD файлы (3.5 ТБ) ──► [DataFusion + rustqvd]
                              ↓
                         SQL API / REST API
                              ↓
                    Metabase / Grafana / Jupyter
```

### Сценарий 5: Валидация и мониторинг

```bash
# CLI: проверка свежести данных
qvd inspect /data/qvd/**/*.qvd --check-freshness 24h

# CLI: поиск дубликатов
qvd sql "SELECT id, COUNT(*) FROM 'facts.qvd' GROUP BY id HAVING COUNT(*) > 1"

# CLI: конвертация
qvd convert facts.qvd --to parquet --compression zstd
```

---

## 14. Известные edge cases формата QVD

### Обнаруженные особенности

1. **Dual values для дат**: TIME и TIMESTAMP оба хранятся как dual double. Различаются только по `<NumberFormat><Type>` в XML (TIME vs TIMESTAMP).

2. **Смешанные типы в одном поле**: QVD позволяет хранить int, double и string в одном поле. При конвертации в strongly-typed форматы (Parquet, Arrow) нужно выбирать общий тип.

3. **Symbol table inconsistency**: при записи QVD критически важно точно воспроизвести формат symbol table. Прототип Alteryx QVD Tools имеет баг с "Symbol table inconsistency" — наша библиотека это решает через byte-identical roundtrip.

4. **Нулевые байты после XML**: между XML и бинарной частью могут быть `\r\n\0` или просто `\0`. Наш парсер обрабатывает оба варианта.

5. **NumberFormat Type**: в 90% файлов = "UNKNOWN" или "0". Тип данных определяется ТОЛЬКО по flag byte перед каждым символом в symbol table, а НЕ из XML метаданных.

6. **BitOffset не обязательно последовательны**: поля могут быть расположены в index table в произвольном порядке (определяется BitOffset), не совпадающем с порядком в XML.

7. **Пустые symbol tables**: если `NoOfSymbols = 0` и `BitWidth = 0`, поле всегда NULL.

8. **Кодировка строк**: UTF-8 (указано в XML заголовке). Поддерживаются Unicode символы включая emoji.

---

## Источники

### Формат QVD
- [PyQvd Documentation — QVD File Format](https://pyqvd.readthedocs.io/stable/guide/qvd-file-format.html)
- [Qlik Cloud Help — QVD files](https://help.qlik.com/en-US/cloud-services/Subsystems/Hub/Content/Sense_Hub/Scripting/QVD-files-scripting.htm)
- [Qlik Help — Working with QVD files](https://help.qlik.com/en-US/cloud-services/Subsystems/Hub/Content/Sense_Hub/Scripting/work-with-QVD-files.htm)
- [QVD Reverse Engineering Blog (Alteryx)](https://kongsoncheung.blogspot.com/2023/07/qlikview-data-file-reverse-engineering.html)
- Статьи на Хабре: "QVD файлы — что внутри" (части 1-3)

### Существующие реализации
- [SBentley/qvd-utils (GitHub)](https://github.com/SBentley/qvd-utils) — Rust+PyO3, read-only
- [MuellerConstantin/PyQvd (GitHub)](https://github.com/MuellerConstantin/PyQvd) — Pure Python, read+write
- [korolmi/qvdfile (GitHub)](https://github.com/korolmi/qvdfile) — исследовательская версия
- [devinsmith/qvdreader (GitHub)](https://github.com/devinsmith/qvdreader) — C
- [mafuentes22/qvd-reader (GitHub)](https://github.com/mafuentes22/qvd-reader) — JavaScript
- [MuellerConstantin/qvd4js (GitHub)](https://github.com/MuellerConstantin/qvd4js) — JavaScript/Node.js
- [MuellerConstantin/qdata (GitHub)](https://github.com/MuellerConstantin/qdata) — Desktop viewer
- [kongson-cheung/Alteryx-QVD-Tools (GitHub)](https://github.com/kongson-cheung/Alteryx-QVD-Tools) — Alteryx prototype
- [ralfbecher/QVDConverter (GitHub)](https://github.com/ralfbecher/QlikView_QVDReader_Examples) — Java

### Qlik производительность
- [Qlik Help — EXISTS function](https://help.qlik.com/en-US/sense/November2025/Subsystems/Hub/Content/Sense_Hub/Scripting/InterRecordFunctions/Exists.htm)
- [BitMetric — Qlik Optimized Load](https://www.bitmetric.nl/blog/qlik-optimized-load/)
- [Quick Intelligence — Optimised QVD Loads](https://www.quickintelligence.co.uk/qlikview-optimised-qvd-loads/)
- [BigBear.ai — Optimizing Qlik Load Time](https://bigbear.ai/blog/optimizing-qlik-load-time/)
- [Qlik Community — EXISTS and Optimized Load](https://community.qlik.com/t5/QlikView-App-Dev/using-exists-function-to-load-qvd-as-optimized-load/td-p/123585)
- [Qlik Community — ApplyMap vs Join Performance](https://community.qlik.com/t5/Visualization-and-Usability/Lookup-vs-Join-Performance-ApplyMap-vs-Inner-Join/td-p/1811152)

### Rust Data Engineering
- [DataFusion — TableProvider trait](https://docs.rs/datafusion/latest/datafusion/catalog/trait.TableProvider.html)
- [DataFusion — Custom Table Providers guide](https://datafusion.apache.org/library-user-guide/custom-table-providers.html)
- [datafusion-table-providers (GitHub)](https://github.com/datafusion-contrib/datafusion-table-providers)
- [DuckDB Rust crate — VTab](https://docs.rs/duckdb/latest/duckdb/)
- [arrow-rs — RecordBatch](https://docs.rs/arrow/latest/arrow/record_batch/struct.RecordBatch.html)
- [Polars — IO Plugins](https://docs.pola.rs/user-guide/plugins/io_plugins/)
- [Polars vs DataFusion 2026](https://dasroot.net/posts/2026/01/rust-data-processing-polars-vs-datafusion/)

### Рыночный контекст
- [Pandas Issue #18259 — read_qvd request](https://github.com/pandas-dev/pandas/issues/18259)
- [Qlik Community — SQL on QVD](https://community.qlik.com/t5/QlikView/How-to-perform-SQL-query-on-QVD/td-p/1568409)
- [Quo Vadis QVD — vendor lock-in](https://www.linkedin.com/pulse/quo-vadis-qvd-need-qlik-sense-data-files-2020-boris-michel)
- [Rise of Parquet as QVD replacement](https://medium.com/@durgesh.patel13/rise-of-parquet-files-as-a-replacement-for-qvd-in-data-analytics-0d9ed6668154)
- [Offload Qlik data to Lakehouse](https://medium.com/@irregularbi/offload-your-qlik-data-into-a-lakehouse-finally-1c6a27b9733c)
- [Qlik Community — Parquet vs QVD](https://community.qlik.com/t5/Connectivity-Data-Prep/parquet-vs-qvd-QS-August-2023/td-p/2111224)
- [EasyQlik QViewer (retired)](https://easyqlik.com/qviewer/)
- [EasyQlik CSViewer](https://easyqlik.com/csviewer/)
- [Q-Eye QVD Viewer](https://www.etl-tools.com/products/q-eye.html)