| @@ -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 | +} |