also looking at this
feat(android): dbExecute and dbQuery in Zig — ratchet 84 → 82
#160
7 files
+541
-3
| @@ -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 | ||
| 36 | const std = ; | |
| 37 | const jni = ; | |
| 38 | const bridge_error = ; | |
| 39 | ||
| 40 | const Jni = jni.Jni; | |
| 41 | const jobject = jni.jobject; | |
| 42 | ||
| 43 | pub const A = struct { | |
| 44 | pub const db_execute = "dbExecute"; | |
| 45 | pub const db_query = "dbQuery"; | |
| 46 | }; | |
| 47 | ||
| 48 | pub const exec_resolve_global = "_craftDbExecResolve"; | |
| 49 | pub const exec_reject_global = "_craftDbExecReject"; | |
| 50 | pub const query_resolve_global = "_craftDbQueryResolve"; | |
| 51 | pub 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. | |
| 60 | pub 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. | |
| 68 | pub 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. | |
| 103 | pub 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)`. | |
| 127 | pub 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. | |
| 144 | pub 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 | ||
| 215 | fn 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 | ||
| 222 | fn 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 | ||
| 237 | const testing = std.testing; | |
| 238 | ||
| 239 | fn 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 | ||
| 257 | fn freeArgs(args: [][]const u8) void { | |
| 258 | for (args) |arg| testing.allocator.free(arg); | |
| 259 | testing.allocator.free(args); | |
| 260 | } | |
| 261 | ||
| 262 | test "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 | ||
| 279 | test "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 | ||
| 287 | test "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 | ||
| 318 | fn 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 | ||
| 325 | test "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 | ||
| 334 | test "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 | ||
| 355 | test "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 | ||
| 369 | test "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 | ||
| 379 | test "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 | } | |