@saltcorn/qlik-qvd 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(node -c index.js)"
5
+ ]
6
+ }
7
+ }
package/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # qlik-qvd
2
+
3
+ Provides the import_qvd_file function, which takes as argumnets:
4
+
5
+ - a filename (a file in the file store)
6
+ - optionally a table name (otherwise it will use the name found in the QVD file)
7
+
8
+ and when run:
9
+
10
+ 1. If the table does not exist, creates it with the fields in the QVD file
11
+ 2. Imports the rows in the QVD file into the table
package/index.js CHANGED
@@ -2,17 +2,89 @@ const File = require("@saltcorn/data/models/file");
2
2
  const Table = require("@saltcorn/data/models/table");
3
3
  const Field = require("@saltcorn/data/models/field");
4
4
  const { getState } = require("@saltcorn/data/db/state");
5
+ const db = require("@saltcorn/data/db");
6
+
7
+ const { Readable } = require("stream");
5
8
 
6
9
  const { QvdDataFrame, QvdFileReader } = require("qvd4js");
7
10
 
8
- const numberFormatToType = (nf) => {
9
- if (nf.Type === "REAL") return "Float";
10
- if (nf.Type === "INTEGER") return "Integer";
11
- if (nf.Type === "TIME") return "String";
12
- if (nf.Type === "UNKNOWN") return "String";
13
- throw new Error("Unknown NumberFormat: " + JSON.stringify(nf));
11
+ // Serialise a single value into a CSV cell suitable for a Postgres
12
+ // `COPY ... FROM STDIN CSV HEADER` ingest. An empty cell becomes NULL.
13
+ const csvCell = (v) => {
14
+ if (v === null || v === undefined) return "";
15
+ let s;
16
+ if (v instanceof Date) s = v.toISOString();
17
+ else s = String(v);
18
+ if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
19
+ return s;
20
+ };
21
+
22
+ // Resolve a single QVD symbol to the value qvd4js would actually place in the
23
+ // data frame: QvdSymbol.toPrimaryValue() prioritises the string representation,
24
+ // and QvdFileReader.load() then coerces numeric-looking strings to numbers.
25
+ const symbolValue = (symbol) => {
26
+ const value = symbol.toPrimaryValue();
27
+ if (typeof value === "string" && value !== "" && !isNaN(Number(value)))
28
+ return Number(value);
29
+ return value;
30
+ };
31
+
32
+ // Inspect every distinct symbol of a field to discover what it really holds.
33
+ // The QVD NumberFormat cannot be trusted on its own: a field tagged INTEGER may
34
+ // still contain pure strings (e.g. "KBS108244839"), so the symbol table is the
35
+ // authoritative source for the column type.
36
+ const scanSymbols = (symbols) => {
37
+ let hasString = false;
38
+ let hasNumber = false;
39
+ let hasFloat = false;
40
+ for (const symbol of symbols || []) {
41
+ const value = symbolValue(symbol);
42
+ if (value === null || value === undefined) continue;
43
+ if (typeof value === "number") {
44
+ hasNumber = true;
45
+ if (!Number.isInteger(value)) hasFloat = true;
46
+ } else {
47
+ hasString = true;
48
+ }
49
+ }
50
+ return { hasString, hasNumber, hasFloat };
51
+ };
52
+
53
+ const deduceFieldType = (field, symbols) => {
54
+ const nf = field.NumberFormat;
55
+ // Dates/times are identified through the number format only; in the symbol
56
+ // table they appear as numeric serials or formatted strings.
57
+ if (nf.Type === "TIME") return { type: "String" };
58
+ if (nf.Type === "DATE")
59
+ return {
60
+ type: "Date",
61
+ attributes: nf.Fmt === "DD.MM.YYYY" ? { day_only: true } : {},
62
+ };
63
+ if (nf.Type === "TIMESTAMP")
64
+ return {
65
+ type: "Date",
66
+ };
67
+
68
+ // For everything else, let the actual symbol contents decide between String,
69
+ // Float and Integer rather than relying on the (unreliable) NumberFormat.
70
+ const { hasString, hasNumber, hasFloat } = scanSymbols(symbols);
71
+ if (hasString) return { type: "String" };
72
+ if (hasFloat || nf.Type === "REAL" || nf.Type === "FIX")
73
+ return { type: "Float" };
74
+ if (hasNumber || nf.Type === "INTEGER") return { type: "Integer" };
75
+ // No usable symbols (e.g. an all-null column): fall back to String.
76
+ return { type: "String" };
14
77
  };
15
78
 
79
+ const QLIK_EPOCH_MS = Date.UTC(1899, 11, 30); // 1899-12-30 00:00:00 (month is 0-indexed)
80
+ const MS_PER_DAY = 86400000;
81
+
82
+ function fromQlik(serial, { roundToSeconds = true } = {}) {
83
+ let ms = QLIK_EPOCH_MS + serial * MS_PER_DAY;
84
+ if (roundToSeconds) ms = Math.round(ms / 1000) * 1000;
85
+ return new Date(ms);
86
+ }
87
+
16
88
  module.exports = {
17
89
  sc_plugin_api_version: 1,
18
90
  plugin_name: "qlik-qvd",
@@ -24,35 +96,82 @@ module.exports = {
24
96
  const reader = new QvdFileReader(file.location);
25
97
  const df = await reader.load();
26
98
  const qvdTableName = reader._header.QvdTableHeader.TableName;
27
- const fields =
99
+ let fields =
28
100
  reader._header["QvdTableHeader"]["Fields"]["QvdFieldHeader"];
101
+ // A QVD with a single field is parsed as an object, not an array.
102
+ // Normalise so it lines up with reader._symbolTable, which is always an
103
+ // array indexed by field position.
104
+ if (!Array.isArray(fields)) fields = [fields];
29
105
 
30
106
  let table = Table.findOne({ name: table_name || qvdTableName });
31
107
  let field_names = [];
32
108
  if (!table) {
33
109
  table = await Table.create(table_name || qvdTableName);
34
110
 
35
- for (const field of fields) {
111
+ for (let i = 0; i < fields.length; i++) {
112
+ const field = fields[i];
36
113
  const fld = {
37
114
  table,
38
115
  label: field.FieldName,
39
- type: numberFormatToType(field.NumberFormat),
40
- attributes: {},
116
+ ...deduceFieldType(field, reader._symbolTable[i]),
41
117
  };
42
118
 
43
119
  const f = await Field.create(fld);
44
120
  field_names.push(f.name);
45
121
  }
46
- await getState.refresh_tables()
122
+ await getState().refresh_tables();
47
123
  } else {
48
124
  field_names = fields.map((f) => Field.labelToName(f.FieldName));
49
125
  }
50
- for (const row of df.data) {
51
- const o = {};
52
- for (let index = 0; index < field_names.length; index++) {
53
- o[field_names[index]] = row[index];
126
+ const timeStampFields = new Set(
127
+ table.fields
128
+ .filter((f) => f.type?.name === "Date" && !f.attributes?.day_only)
129
+ .map((f) => f.name),
130
+ );
131
+
132
+ // Convert the raw QVD value for a given target field to the value we
133
+ // want stored, applying the Qlik-serial -> Date conversion for
134
+ // timestamp fields (mirrors the row-by-row path below).
135
+ const convertValue = (fname, val) => {
136
+ if (timeStampFields.has(fname) && typeof val === "number")
137
+ return fromQlik(val);
138
+ return val;
139
+ };
140
+
141
+ // Fast path: bulk-load via Postgres COPY when the driver supports it.
142
+ // db.copyFrom is only defined on the Postgres driver, not SQLite.
143
+ if (db.copyFrom) {
144
+ // Stream a CSV of the QVD data, using the plugin's field names as the
145
+ // header, instead of issuing one insertRow per row.
146
+ const csvStream = Readable.from(
147
+ (function* () {
148
+ yield field_names.map(csvCell).join(",") + "\n";
149
+ for (const row of df.data) {
150
+ const cells = new Array(field_names.length);
151
+ for (let index = 0; index < field_names.length; index++) {
152
+ const fname = field_names[index];
153
+ cells[index] = csvCell(convertValue(fname, row[index]));
154
+ }
155
+ yield cells.join(",") + "\n";
156
+ }
157
+ })(),
158
+ );
159
+
160
+ const client = await db.getClient();
161
+ try {
162
+ await db.copyFrom(csvStream, table.name, field_names, client);
163
+ } finally {
164
+ await client.release(true);
165
+ }
166
+ } else {
167
+ for (const row of df.data) {
168
+ const o = {};
169
+ for (let index = 0; index < field_names.length; index++) {
170
+ const fname = field_names[index];
171
+ o[fname] = convertValue(fname, row[index]);
172
+ }
173
+ await table.insertRow(o);
54
174
  }
55
- await table.insertRow(o);
56
175
  }
57
176
  },
58
177
  description: "Convert a list of JSON objects to a CSV string",
package/package.json CHANGED
@@ -1,12 +1,11 @@
1
1
  {
2
2
  "name": "@saltcorn/qlik-qvd",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Interacting with Qlik QVD files",
5
5
  "main": "index.js",
6
6
  "dependencies": {
7
7
  "@saltcorn/markup": "^0.8.0",
8
8
  "@saltcorn/data": "^0.8.0",
9
- "qvdrs": "0.7.0",
10
9
  "qvd4js":"1.0.5"
11
10
  },
12
11
  "author": "Tom Nielsen",