ReviewOS

also looking at this

craft-native/craft

feat(android): dbExecute and dbQuery in Zig — ratchet 84 → 82

#160
Merged glennmichael123 wants to merge feat/android-sqlite into main
7 files +541 -3
packages/zig/src/bridge_android_db.zigadded+386-0
Changes to packages/zig/src/bridge_android_db.zig
@@ -0,0 +1,386 @@
1//! `dbExecute` and `dbQuery` on Android.
2//!
3//! ## The connection stays Kotlin's
4//!
5//! The shim caches one `SQLiteDatabase` in a field and reuses it, which is
6//! what makes `BEGIN` in one call and `COMMIT` in the next work at all. Zig
7//! could open its own — `Context.openOrCreateDatabase` hands out a new
8//! connection each call — and then a page that mixed a served call with a
9//! declined one would have two connections to one file, taking each other's
10//! locks for no reason a reader could see.
11//!
12//! So the database arrives as an argument, the way `SharedPreferences` does
13//! for the secure store. The Kotlin opens it, the Kotlin owns it, and Zig
14//! borrows it for the length of one call.
15//!
16//! ## Which parameter shapes this serves
17//!
18//! The shim turns every parameter into a string with `JSONArray.getString`,
19//! which coerces: `1` becomes "1", `true` becomes "true", and an explicit null
20//! becomes the four characters "null" — the same `JSONObject.NULL.toString()`
21//! quirk the calendar module documents.
22//!
23//! Those four are reproduced. A float is not, because `Double.toString` picks
24//! the shortest form that round-trips and matching it exactly is not something
25//! to reproduce from memory; nor is a nested object or array, whose string
26//! form is `org.json`'s own printer. Those make `bindArgs` return null, the
27//! native returns false, and the shim binds them with the coercions it has.
28//!
29//! ## A NULL column disappears from its row
30//!
31//! `row.put(col, cursor.getString(index))` with a null value **removes** the
32//! mapping rather than storing a JSON null, so a row with a NULL column comes
33//! back with that key missing. It is not a rounding of the truth to say a page
34//! cannot tell a NULL column from an absent one — that is what the shim sends.
35
36const std = ;
37const jni = ;
38const bridge_error = ;
39
40const Jni = jni.Jni;
41const jobject = jni.jobject;
42
43pub const A = struct {
44 pub const db_execute = "dbExecute";
45 pub const db_query = "dbQuery";
46};
47
48pub const exec_resolve_global = "_craftDbExecResolve";
49pub const exec_reject_global = "_craftDbExecReject";
50pub const query_resolve_global = "_craftDbQueryResolve";
51pub const query_reject_global = "_craftDbQueryReject";
52
53/// What `dbExecute` resolves with, exactly as the shim writes it.
54///
55/// The 1 is a literal in the Kotlin, not a row count — `execSQL` returns void,
56/// so nothing here knows how many rows were touched. A `DELETE` matching
57/// nothing reports the same 1 as an `INSERT`. Ported rather than corrected,
58/// because correcting it means a different number reaching pages that already
59/// read this one. See #159.
60pub const exec_result = "{\"rowsAffected\":1}";
61
62/// `Array(params.length()) { params.getString(it) }`, or null to decline.
63///
64/// Most results borrow from `value`, which has to outlive the JNI calls that
65/// bind them; an integer is printed and so is allocated. Nothing is freed
66/// individually, because every caller is inside the per-call arena — passing a
67/// tracking allocator here reports the printed integers as leaks.
68pub fn bindArgs(allocator: std.mem.Allocator, value: std.json.Value) !?[][]const u8 {
69 const items = switch (value) {
70 .array => |a| a.items,
71 // `JSONArray(paramsJson)` throws on anything that is not an array, and
72 // the shim's catch rejects. Declining sends it there.
73 else => return null,
74 };
75
76 const args = try allocator.alloc([]const u8, items.len);
77 errdefer allocator.free(args);
78
79 for (items, 0..) |item, i| {
80 args[i] = switch (item) {
81 .string => |text| text,
82 .bool => |b| if (b) "true" else "false",
83 // `JSONObject.NULL.toString()`. A page sending `[null]` binds the
84 // four characters, not SQL NULL — which is worth knowing, and is
85 // the shim's behaviour either way.
86 .null => "null",
87 .integer => |n| try std.fmt.allocPrint(allocator, "{d}", .{n}),
88 // Double.toString, org.json's printer, and a number too large for
89 // an i64. Each has an exact answer that is not worth guessing.
90 .float, .object, .array, .number_string => {
91 allocator.free(args);
92 return null;
93 },
94 };
95 }
96 return args;
97}
98
99/// One row of a query result, as the shim's `JSONObject` would print it.
100///
101/// A null value removes its key rather than storing a JSON null, so `values`
102/// carrying a null emits an object without that column.
103pub fn appendRow(
104 allocator: std.mem.Allocator,
105 out: *std.ArrayListUnmanaged(u8),
106 columns: []const []const u8,
107 values: []const ?[]const u8,
108) !void {
109 std.debug.assert(columns.len == values.len);
110
111 try out.append(allocator, '{');
112 var written: usize = 0;
113 for (columns, values) |column, value| {
114 const text = value orelse continue;
115 if (written != 0) try out.append(allocator, ',');
116 try out.append(allocator, '"');
117 try bridge_error.appendJsonEscaped(allocator, out, column);
118 try out.appendSlice(allocator, "\":\"");
119 try bridge_error.appendJsonEscaped(allocator, out, text);
120 try out.append(allocator, '"');
121 written += 1;
122 }
123 try out.append(allocator, '}');
124}
125
126/// `database.execSQL(sql, args)`.
127pub fn execute(j: Jni, allocator: std.mem.Allocator, db: jobject, sql: []const u8, args: []const []const u8) !void {
128 try j.pushLocalFrame(16);
129 defer _ = j.popLocalFrame(null);
130
131 const bind = try stringArray(j, allocator, args);
132 const db_cls = try j.objectClass(db);
133
134 // `execSQL(String, Object[])` — a `String[]` is an `Object[]`, which is
135 // what the Kotlin passes too.
136 try j.callVoidMethodA(
137 db,
138 try j.methodId(db_cls, "execSQL", "(Ljava/lang/String;[Ljava/lang/Object;)V"),
139 &.{ .{ .l = try javaString(j, allocator, sql) }, .{ .l = bind } },
140 );
141}
142
143/// `database.rawQuery(sql, args)`, every row appended to `out` as JSON.
144pub fn query(
145 j: Jni,
146 allocator: std.mem.Allocator,
147 db: jobject,
148 sql: []const u8,
149 args: []const []const u8,
150 out: *std.ArrayListUnmanaged(u8),
151) !void {
152 try j.pushLocalFrame(24);
153 defer _ = j.popLocalFrame(null);
154
155 const bind = try stringArray(j, allocator, args);
156 const db_cls = try j.objectClass(db);
157 const cursor = try j.callObjectMethodA(
158 db,
159 try j.methodId(
160 db_cls,
161 "rawQuery",
162 "(Ljava/lang/String;[Ljava/lang/String;)Landroid/database/Cursor;",
163 ),
164 &.{ .{ .l = try javaString(j, allocator, sql) }, .{ .l = bind } },
165 );
166
167 try out.append(allocator, '[');
168 defer out.append(allocator, ']') catch {};
169
170 if (cursor == null) return;
171
172 const cursor_cls = try j.objectClass(cursor);
173 const move_to_next = try j.methodId(cursor_cls, "moveToNext", "()Z");
174 const get_string = try j.methodId(cursor_cls, "getString", "(I)Ljava/lang/String;");
175 const close = try j.methodId(cursor_cls, "close", "()V");
176
177 // `use` closes however the block leaves, error included.
178 defer j.callVoidMethodA(cursor, close, &.{}) catch {};
179
180 const names = try j.callObjectMethodA(
181 cursor,
182 try j.methodId(cursor_cls, "getColumnNames", "()[Ljava/lang/String;"),
183 &.{},
184 );
185 const column_count: usize = @intCast(try j.arrayLength(names));
186
187 const columns = try allocator.alloc([]const u8, column_count);
188 defer allocator.free(columns);
189 for (columns, 0..) |*column, i| {
190 const element = try j.objectArrayElement(names, i);
191 column.* = if (element == null) "" else try j.stringToUtf8(allocator, element);
192 }
193
194 const values = try allocator.alloc(?[]const u8, column_count);
195 defer allocator.free(values);
196
197 var row_count: usize = 0;
198 while (try j.callBooleanMethodA(cursor, move_to_next, &.{})) {
199 // Sixteen local slots is all the JVM guarantees, and a wide table
200 // makes one reference per column.
201 try j.pushLocalFrame(8);
202 defer _ = j.popLocalFrame(null);
203
204 for (values, 0..) |*slot, i| {
205 const text = try j.callObjectMethodA(cursor, get_string, &.{.{ .i = @intCast(i) }});
206 slot.* = if (text == null) null else try j.stringToUtf8(allocator, text);
207 }
208
209 if (row_count != 0) try out.append(allocator, ',');
210 try appendRow(allocator, out, columns, values);
211 row_count += 1;
212 }
213}
214
215fn javaString(j: Jni, allocator: std.mem.Allocator, text: []const u8) !jni.jstring {
216 const terminated = try allocator.allocSentinel(u8, text.len, 0);
217 defer allocator.free(terminated);
218 @memcpy(terminated, text);
219 return j.newStringUtf(terminated.ptr);
220}
221
222fn stringArray(j: Jni, allocator: std.mem.Allocator, values: []const []const u8) !jobject {
223 const string_cls = try j.findClass("java/lang/String");
224 const array = try j.newObjectArray(values.len, string_cls);
225 for (values, 0..) |value, i| {
226 try j.pushLocalFrame(4);
227 defer _ = j.popLocalFrame(null);
228 try j.setObjectArrayElement(array, i, try javaString(j, allocator, value));
229 }
230 return array;
231}
232
233// =============================================================================
234// Tests
235// =============================================================================
236
237const testing = std.testing;
238
239fn boundArgs(json: []const u8) !?[][]const u8 {
240 // An arena, because that is what the native passes — `bindArgs` prints
241 // integers into it and frees nothing individually.
242 var arena = std.heap.ArenaAllocator.init(testing.allocator);
243 defer arena.deinit();
244 const allocator = arena.allocator();
245
246 var parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{});
247 defer parsed.deinit();
248
249 const args = try bindArgs(allocator, parsed.value) orelse return null;
250
251 // Copied out, because everything above dies with the arena.
252 const copy = try testing.allocator.alloc([]const u8, args.len);
253 for (args, 0..) |arg, i| copy[i] = try testing.allocator.dupe(u8, arg);
254 return copy;
255}
256
257fn freeArgs(args: [][]const u8) void {
258 for (args) |arg| testing.allocator.free(arg);
259 testing.allocator.free(args);
260}
261
262test "the parameter shapes this serves are the strings getString would produce" {
263 const args = (try boundArgs(
264 \\["a", 1, -2, true, false, null, ""]
265 )).?;
266 defer freeArgs(args);
267
268 try testing.expectEqual(@as(usize, 7), args.len);
269 try testing.expectEqualStrings("a", args[0]);
270 try testing.expectEqualStrings("1", args[1]);
271 try testing.expectEqualStrings("-2", args[2]);
272 try testing.expectEqualStrings("true", args[3]);
273 try testing.expectEqualStrings("false", args[4]);
274 // `JSONArray.getString` on an explicit null gives the text, not SQL NULL.
275 try testing.expectEqualStrings("null", args[5]);
276 try testing.expectEqualStrings("", args[6]);
277}
278
279test "an empty parameter list is served rather than declined" {
280 // `execSQL(sql, arrayOf())` is the common case — a statement with no
281 // placeholders — so this is the path most calls take.
282 const args = (try boundArgs("[]")).?;
283 defer freeArgs(args);
284 try testing.expectEqual(@as(usize, 0), args.len);
285}
286
287test "a shape only org.json would stringify is handed back" {
288 for ([_][]const u8{
289 // Double.toString picks the shortest round-tripping form.
290 \\[1.5]
291 ,
292 \\[1.0]
293 ,
294 // org.json's own printer, key order included.
295 \\[{"a":1}]
296 ,
297 \\[[1,2]]
298 ,
299 // Past i64, where std.json hands back the text and org.json a Double.
300 \\[99999999999999999999]
301 ,
302 // JSONArray(paramsJson) throws on anything that is not an array.
303 \\{"0":"a"}
304 ,
305 \\"a"
306 ,
307 \\null
308 ,
309 }) |payload| {
310 if (try boundArgs(payload)) |args| {
311 freeArgs(args);
312 std.debug.print("payload was served rather than handed back: {s}\n", .{payload});
313 return error.CoercedShapeAccepted;
314 }
315 }
316}
317
318fn renderRow(columns: []const []const u8, values: []const ?[]const u8) ![]u8 {
319 var out: std.ArrayListUnmanaged(u8) = .empty;
320 errdefer out.deinit(testing.allocator);
321 try appendRow(testing.allocator, &out, columns, values);
322 return out.toOwnedSlice(testing.allocator);
323}
324
325test "a row prints its columns in cursor order" {
326 const json = try renderRow(
327 &.{ "id", "name" },
328 &.{ "1", "Ada" },
329 );
330 defer testing.allocator.free(json);
331 try testing.expectEqualStrings("{\"id\":\"1\",\"name\":\"Ada\"}", json);
332}
333
334test "a NULL column is absent from the row rather than null" {
335 // `put(col, null)` removes the mapping. A page cannot tell a NULL column
336 // from one the query never selected, and that is what the shim sends.
337 const json = try renderRow(
338 &.{ "id", "name", "note" },
339 &.{ "1", null, "hi" },
340 );
341 defer testing.allocator.free(json);
342 try testing.expectEqualStrings("{\"id\":\"1\",\"note\":\"hi\"}", json);
343
344 // Including when it is the first column, where a naive comma would show.
345 const leading = try renderRow(&.{ "a", "b" }, &.{ null, "x" });
346 defer testing.allocator.free(leading);
347 try testing.expectEqualStrings("{\"b\":\"x\"}", leading);
348
349 // And when every column is null.
350 const all_null = try renderRow(&.{ "a", "b" }, &.{ null, null });
351 defer testing.allocator.free(all_null);
352 try testing.expectEqualStrings("{}", all_null);
353}
354
355test "a column name or value carrying a quote survives as JSON" {
356 // Column names come from the caller's own SQL — `select 1 as "a\"b"` is
357 // legal — and values are whatever is in the database.
358 const json = try renderRow(
359 &.{"a\"b"},
360 &.{"it's \"fine\""},
361 );
362 defer testing.allocator.free(json);
363
364 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
365 defer parsed.deinit();
366 try testing.expectEqualStrings("it's \"fine\"", parsed.value.object.get("a\"b").?.string);
367}
368
369test "dbExecute resolves with the shim's constant, not a row count" {
370 // `execSQL` returns void, so the 1 is a literal in the Kotlin and stays a
371 // literal here. Asserted as the exact bytes because the page parses them.
372 try testing.expectEqualStrings("{\"rowsAffected\":1}", exec_result);
373
374 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, exec_result, .{});
375 defer parsed.deinit();
376 try testing.expectEqual(@as(i64, 1), parsed.value.object.get("rowsAffected").?.integer);
377}
378
379test "the actions and globals match the shim exactly" {
380 try testing.expectEqualStrings("dbExecute", A.db_execute);
381 try testing.expectEqualStrings("dbQuery", A.db_query);
382 try testing.expectEqualStrings("_craftDbExecResolve", exec_resolve_global);
383 try testing.expectEqualStrings("_craftDbExecReject", exec_reject_global);
384 try testing.expectEqualStrings("_craftDbQueryResolve", query_resolve_global);
385 try testing.expectEqualStrings("_craftDbQueryReject", query_reject_global);
386}