Repository navigation
Expand file tree
/
Copy pathColumnBatcher.java
More file actions
371 lines (354 loc) · 15.8 KB
/
Copy pathColumnBatcher.java
File metadata and controls
371 lines (354 loc) · 15.8 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
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
/*
* 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).
*
* A "json" column is written with the tagged encoding of TypedRows, not the engine's JSON writer: a date, datetime,
* DECIMAL or non-String map key inside a list or a map reaches Python as the type ResultSet.to_list() gives it, and the
* values JSON cannot carry (NaN, RIDs, ...) are the engine objects of the side array that is returned with the buffer.
* With {@code micros} a datetime column is written as epoch MICROseconds ("dtu", what ResultSet.to_dataframe reads) and
* any column holding a DATE or a time-zone-aware temporal is a "json" column, so Python gets date and datetime objects as
* the row readers return them.
*/
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());
}
/** Epoch microseconds of a naive datetime: what {@link #toEpochMillis} is in milliseconds, without the cut. */
private static long toEpochMicros(final Object v) {
if (v instanceof LocalDateTime ldt)
return Math.addExact(Math.multiplyExact(ldt.toEpochSecond(ZoneOffset.UTC), 1_000_000L), ldt.getNano() / 1000);
if (v instanceof Date d)
return Math.multiplyExact(d.getTime(), 1000L);
if (v instanceof Calendar c)
return Math.multiplyExact(c.getTimeInMillis(), 1000L);
throw new IllegalArgumentException("not a naive datetime: " + v.getClass());
}
/** Python's datetime covers years 1 to 9999; a datetime outside it stays an engine object (TypedRows does the same). */
private static boolean yearFits(final LocalDateTime ldt) {
return ldt.getYear() >= 1 && ldt.getYear() <= 9999;
}
/** The columns of up to {@code max} rows, temporals in milliseconds: {@code {byte[] buffer, Object[] side}}. */
public static Object[] nextColumnBatch(final ResultSet rs, final int max, final String joinedColumns) {
return nextColumnBatch(rs, max, joinedColumns, false);
}
/**
* The columns of up to {@code max} rows as {@code {byte[] buffer, Object[] side}}: {@code side} holds the engine objects
* that the tagged JSON of a "json" column refers to by index. With {@code micros} see the class comment.
*/
public static Object[] nextColumnBatch(final ResultSet rs, final int max, final String joinedColumns,
final boolean micros) {
// 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 List<Object> side = new ArrayList<>();
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) {
if (!micros)
t = "dt";
else if (v instanceof LocalDateTime ldt ? yearFits(ldt) : v instanceof Date || v instanceof Calendar)
// a naive datetime is epoch microseconds; a DATE, a time-zone-aware temporal or a year Python cannot hold
// is read through the tagged JSON, as the row readers return it
t = "dtu";
else
t = "json";
}
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") || type.equals("dtu")) {
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 if (type.equals("dtu"))
bb.putLong(toEpochMicros(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, in the tagged encoding of TypedRows: what the engine's JSON writer turns
// into epoch milliseconds, a double or a text key (a date or Decimal in a list, an int map key) stays what the
// row readers return, and what JSON cannot carry goes to the side array
final StringBuilder arr = new StringBuilder();
arr.append('[');
for (int r = 0; r < count; r++) {
final Object v = rows.get(r)[c];
if (r > 0)
arr.append(',');
if (v == null)
nulls[r >> 3] |= (1 << (r & 7));
TypedRows.append(arr, v, side);
}
arr.append(']');
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 new Object[] { out.array(), side.toArray() };
}
/**
* 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;
}
}