Repository navigation
Expand file tree
/
Copy pathColumnBatcher.java
More file actions
322 lines (308 loc) · 13 KB
/
Copy pathColumnBatcher.java
File metadata and controls
322 lines (308 loc) · 13 KB
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
/*
* Python-bindings bridge: binary columnar transport.
*
* Encodes up to `max` rows of a ResultSet into ONE byte[]: a JSON header
* (column names/types/sizes) followed by per-column buffers — fixed-width
* little-endian for numerics/bools/temporals (epoch millis), offset+UTF-8
* for strings, plus a null bitmap per column. A DECIMAL column ("dec" type) uses the string layout with each
* BigDecimal written in full, so no digit is lost to a double. Columns whose values don't
* fit those encodings are serialized as one JSON array ("json" type). Python decodes with numpy.frombuffer
* (ResultSet.to_columns / the fast to_dataframe path). Measured ~1.2x of
* Java-native iteration on 100k-row scans (vs 3.4x for the JSON row path).
*/
package com.arcadedb.python;
import com.arcadedb.query.sql.executor.Result;
import com.arcadedb.query.sql.executor.ResultSet;
import com.arcadedb.serializer.json.JSONArray;
import com.arcadedb.serializer.json.JSONObject;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public final class ColumnBatcher {
private ColumnBatcher() {
}
/** Wall-clock-consistent epoch millis: naive temporals are read as UTC so
* numpy's (naive) datetime64 shows the same wall-clock the record carries.
* A DATE is its UTC midnight in milliseconds, not a count of days: the column is a datetime64[ms], and it is the
* number RowBatcher gets from the engine's JSONObject for the same value (#10522, test_temporal_writers_agree.py).
* It is not a JSON writer, so there is nothing for it to disagree with in Result.toJSON(). */
private static long toEpochMillis(final Object v) {
if (v instanceof Date d)
return d.getTime();
if (v instanceof Calendar c)
return c.getTimeInMillis();
if (v instanceof LocalDateTime ldt)
return ldt.toInstant(ZoneOffset.UTC).toEpochMilli();
if (v instanceof LocalDate ld)
return ld.atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli();
if (v instanceof Instant i)
return i.toEpochMilli();
if (v instanceof ZonedDateTime z)
return z.toInstant().toEpochMilli();
if (v instanceof OffsetDateTime o)
return o.toInstant().toEpochMilli();
throw new IllegalArgumentException("not a temporal: " + v.getClass());
}
public static byte[] nextColumnBatch(final ResultSet rs, final int max, final String joinedColumns) {
// empty column spec: the column set is the union of every row's property names, in order of first appearance.
// A document is schemaless, so the first row says nothing about the others: taking its names alone dropped every
// property it lacked (humemai/arcadedb-embedded-python#113). Each batch reports its own set in its header.
String[] columns = parseColumnSpec(joinedColumns);
final List<Result> results = new ArrayList<>(Math.min(max, 1 << 16));
while (results.size() < max && rs.hasNext())
results.add(rs.next());
if (columns == null) {
final Set<String> names = new LinkedHashSet<>();
// Rows of one type almost always list the same names in the same order, so a row is compared with the one
// before it name by name (String.equals answers an identical instance at once) and only a row that differs
// is hashed into the set.
String[] previous = null;
for (final Result row : results) {
final Set<String> rowNames = row.getPropertyNames();
if (previous != null && rowNames.size() == previous.length) {
int i = 0;
for (final String name : rowNames)
if (!name.equals(previous[i++])) {
i = -1;
break;
}
if (i >= 0)
continue;
}
names.addAll(rowNames);
previous = rowNames.toArray(new String[0]);
}
columns = names.toArray(new String[0]);
}
final int n = columns.length;
final List<Object[]> rows = new ArrayList<>(results.size());
for (final Result row : results) {
final Object[] vals = new Object[n];
for (int c = 0; c < n; c++)
vals[c] = row.getProperty(columns[c]);
rows.add(vals);
}
final int count = rows.size();
// The header goes through JSONObject/JSONArray rather than string concatenation: a column name is an
// arbitrary query projection alias, so a name carrying a quote, a backslash or a control character used to
// emit invalid JSON and break the decode of the WHOLE batch on the Python side. RowBatcher already does it
// this way; the cost is n objects per batch, not per row.
final JSONArray headerColumns = new JSONArray();
final ByteArrayOutputStream body = new ByteArrayOutputStream(count * n * 8);
for (int c = 0; c < n; c++) {
// null bitmap
final byte[] nulls = new byte[(count + 7) / 8];
// detect the column type across ALL values; mixed/unsupported -> json
String type = null;
for (int r = 0; r < count; r++) {
final Object v = rows.get(r)[c];
if (v == null)
continue;
final String t;
if (v instanceof Long || v instanceof Integer || v instanceof Short || v instanceof Byte)
t = "i8";
else if (v instanceof Double || v instanceof Float)
t = "f8";
else if (v instanceof Boolean)
t = "b1";
else if (v instanceof Date || v instanceof Calendar || v instanceof LocalDateTime
|| v instanceof LocalDate || v instanceof Instant
|| v instanceof ZonedDateTime || v instanceof OffsetDateTime)
t = "dt";
else if (v instanceof String || v instanceof Character)
t = "str";
else if (v instanceof java.math.BigDecimal)
t = "dec";
else if (v instanceof float[])
t = "f4v";
else if (v instanceof double[])
t = "f8v";
else
t = "json";
if (type == null)
type = t;
else if (!type.equals(t)) {
// numeric widening is fine; anything else degrades to json
if ((type.equals("i8") && t.equals("f8")) || (type.equals("f8") && t.equals("i8")))
type = "f8";
else {
type = "json";
break;
}
}
}
if (type == null)
type = "str"; // all-null column
int vdim = -1;
if (type.equals("f4v") || type.equals("f8v")) {
// vector columns must be uniform-length; ragged degrades to json
for (int r = 0; r < count && vdim != -2; r++) {
final Object v = rows.get(r)[c];
if (v == null)
continue;
final int len = type.equals("f4v") ? ((float[]) v).length : ((double[]) v).length;
if (vdim == -1)
vdim = len;
else if (vdim != len)
vdim = -2;
}
if (vdim < 0)
type = "json";
}
byte[] colBuf;
if (type.equals("i8") || type.equals("dt")) {
final ByteBuffer bb = ByteBuffer.allocate(count * 8).order(ByteOrder.LITTLE_ENDIAN);
for (int r = 0; r < count; r++) {
final Object v = rows.get(r)[c];
if (v == null) {
nulls[r >> 3] |= (1 << (r & 7));
bb.putLong(0);
} else if (type.equals("dt"))
bb.putLong(toEpochMillis(v));
else
bb.putLong(((Number) v).longValue());
}
colBuf = bb.array();
} else if (type.equals("f8")) {
final ByteBuffer bb = ByteBuffer.allocate(count * 8).order(ByteOrder.LITTLE_ENDIAN);
for (int r = 0; r < count; r++) {
final Object v = rows.get(r)[c];
if (v == null) {
nulls[r >> 3] |= (1 << (r & 7));
bb.putDouble(Double.NaN);
} else
bb.putDouble(((Number) v).doubleValue());
}
colBuf = bb.array();
} else if (type.equals("b1")) {
final ByteBuffer bb = ByteBuffer.allocate(count);
for (int r = 0; r < count; r++) {
final Object v = rows.get(r)[c];
if (v == null) {
nulls[r >> 3] |= (1 << (r & 7));
bb.put((byte) 0);
} else
bb.put((byte) (((Boolean) v) ? 1 : 0));
}
colBuf = bb.array();
} else if (type.equals("f4v")) {
final ByteBuffer bb = ByteBuffer.allocate(count * vdim * 4).order(ByteOrder.LITTLE_ENDIAN);
for (int r = 0; r < count; r++) {
final Object v = rows.get(r)[c];
if (v == null) {
nulls[r >> 3] |= (1 << (r & 7));
for (int k = 0; k < vdim; k++)
bb.putFloat(Float.NaN);
} else
for (final float x : (float[]) v)
bb.putFloat(x);
}
colBuf = bb.array();
} else if (type.equals("f8v")) {
final ByteBuffer bb = ByteBuffer.allocate(count * vdim * 8).order(ByteOrder.LITTLE_ENDIAN);
for (int r = 0; r < count; r++) {
final Object v = rows.get(r)[c];
if (v == null) {
nulls[r >> 3] |= (1 << (r & 7));
for (int k = 0; k < vdim; k++)
bb.putDouble(Double.NaN);
} else
for (final double x : (double[]) v)
bb.putDouble(x);
}
colBuf = bb.array();
} else if (type.equals("json")) {
// one JSON array for the whole column (JSONObject/JSONArray handle
// primitive arrays natively since engine 26.7.2, #4967)
final com.arcadedb.serializer.json.JSONArray arr = new com.arcadedb.serializer.json.JSONArray();
for (int r = 0; r < count; r++) {
final Object v = rows.get(r)[c];
if (v == null)
nulls[r >> 3] |= (1 << (r & 7));
arr.put(v);
}
colBuf = arr.toString().getBytes(StandardCharsets.UTF_8);
} else { // strings and decimals: int32 offsets (count+1) then utf8 bytes
final ByteArrayOutputStream chars = new ByteArrayOutputStream(count * 16);
final ByteBuffer offs = ByteBuffer.allocate((count + 1) * 4).order(ByteOrder.LITTLE_ENDIAN);
int pos = 0;
offs.putInt(0);
for (int r = 0; r < count; r++) {
final Object v = rows.get(r)[c];
if (v == null)
nulls[r >> 3] |= (1 << (r & 7));
else {
// toPlainString: a BigDecimal's toString may use an exponent ("1E+3"), which is exact but needless here
final String text = v instanceof java.math.BigDecimal d ? d.toPlainString() : v.toString();
final byte[] b = text.getBytes(StandardCharsets.UTF_8);
chars.write(b, 0, b.length);
pos += b.length;
}
offs.putInt(pos);
}
final byte[] charBytes = chars.toByteArray();
final ByteBuffer bb = ByteBuffer.allocate(offs.capacity() + charBytes.length);
bb.put(offs.array());
bb.put(charBytes);
colBuf = bb.array();
}
final JSONObject columnHeader = new JSONObject();
columnHeader.put("name", columns[c]);
columnHeader.put("type", type);
columnHeader.put("nulls", nulls.length);
columnHeader.put("bytes", colBuf.length);
if (vdim > 0 && (type.equals("f4v") || type.equals("f8v")))
columnHeader.put("dim", vdim);
headerColumns.put(columnHeader);
body.write(nulls, 0, nulls.length);
body.write(colBuf, 0, colBuf.length);
}
final JSONObject header = new JSONObject();
header.put("count", count);
header.put("cols", headerColumns);
final byte[] headerBytes = header.toString().getBytes(StandardCharsets.UTF_8);
final byte[] bodyBytes = body.toByteArray();
final ByteBuffer out = ByteBuffer.allocate(4 + headerBytes.length + bodyBytes.length)
.order(ByteOrder.LITTLE_ENDIAN);
out.putInt(headerBytes.length);
out.put(headerBytes);
out.put(bodyBytes);
return out.array();
}
/**
* Decodes the caller-supplied column spec, which pins the column set across the batches of one result set.
* <p>
* The canonical form is a JSON array, because the names are arbitrary projection aliases: the legacy
* {@code ';'}-joined form silently split a name that contained a semicolon into two bogus columns. The legacy
* form is still accepted so an older Python side paired with a newer bridge jar keeps working.
*
* @return the pinned column names, or {@code null} when the spec is empty and the set must be derived from the
* first row
*/
private static String[] parseColumnSpec(final String spec) {
if (spec == null || spec.isEmpty())
return null;
if (spec.charAt(0) != '[')
return spec.split(";");
final JSONArray names = new JSONArray(spec);
final String[] columns = new String[names.length()];
for (int i = 0; i < columns.length; i++)
columns[i] = names.getString(i);
return columns;
}
}