Skip to content

Commit 4b76ba6

Browse files
authored
Address Claude review feedback (#382)
- gpkg-ogr-contents.ts: when repairing an existing gpkg_ogr_contents row, match it by the exact stored name (not `lower(table_name)`, which is ASCII-only in SQLite and misses non-ASCII names) and normalise table_name to the canonical gpkg_contents casing, so GDAL's case-sensitive lookup finds the repaired row instead of falling back to the threaded count path. - tests: assert the case-insensitive repair normalises to the canonical casing; add a non-ASCII NULL-count repair test and a phantom-table (unreadable table skipped, others repaired) test.
1 parent c49d3db commit 4b76ba6

2 files changed

Lines changed: 70 additions & 10 deletions

File tree

apps/geolibre-desktop/src/lib/gpkg-ogr-contents.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,15 +101,15 @@ export function ensureGpkgFeatureCountSync(
101101
// the row's presence (the previous behaviour) let those files through. See
102102
// issues #258 and #376.
103103
const tablesWithValidCount = new Set<string>(); // lowercase keys
104-
const tablesWithRow = new Set<string>(); // lowercase keys
104+
const tablesWithRow = new Map<string, string>(); // lowercase key → name as stored
105105
if (hasOgrContents) {
106106
for (const row of db.exec(
107107
"SELECT table_name, typeof(feature_count), feature_count FROM gpkg_ogr_contents",
108108
)[0]?.values ?? []) {
109109
const name = row[0];
110110
if (typeof name !== "string") continue;
111111
const key = name.toLowerCase();
112-
tablesWithRow.add(key);
112+
tablesWithRow.set(key, name);
113113
// A valid cached count is a non-negative integer. GDAL uses -1 as a
114114
// "dirty/invalid" sentinel and recomputes the count for it (the
115115
// multithreaded path that crashes WASM), so a negative value is not safe.
@@ -143,12 +143,18 @@ export function ensureGpkgFeatureCountSync(
143143
`SELECT count(*) FROM ${quoteIdentifier(tableName)}`,
144144
);
145145
const count = countResult[0]?.values[0]?.[0] ?? 0;
146-
if (tablesWithRow.has(key)) {
146+
const storedName = tablesWithRow.get(key);
147+
if (storedName !== undefined) {
147148
// Repair a stale/NULL count rather than INSERT (which would collide
148-
// with the existing primary-key row).
149+
// with the existing primary-key row). Match on the exact stored name
150+
// (SQLite's lower() is ASCII-only, so a `lower(table_name) = :key`
151+
// predicate would miss non-ASCII names) and normalise table_name to
152+
// the canonical (gpkg_contents) casing: GDAL looks the row up with a
153+
// case-sensitive `table_name = <name from gpkg_contents>`, so a
154+
// wrong-cased row would not be found and would still crash.
149155
db.run(
150-
"UPDATE gpkg_ogr_contents SET feature_count = :count WHERE lower(table_name) = :name",
151-
{ ":name": key, ":count": count },
156+
"UPDATE gpkg_ogr_contents SET feature_count = :count, table_name = :canonical WHERE table_name = :stored",
157+
{ ":canonical": tableName, ":stored": storedName, ":count": count },
152158
);
153159
} else {
154160
db.run(

tests/gpkg-ogr-contents.test.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -239,10 +239,12 @@ describe("ensureGpkgFeatureCountSync", () => {
239239
]);
240240
});
241241

242-
it("matches table names case-insensitively across metadata tables", () => {
242+
it("matches table names case-insensitively and normalises to the canonical casing", () => {
243243
// gpkg_contents and gpkg_ogr_contents disagree on casing for the same
244-
// (case-insensitive) SQLite table. The repair must treat them as one table
245-
// and UPDATE the existing NULL row rather than INSERT a duplicate.
244+
// (case-insensitive) SQLite table. The repair must treat them as one table,
245+
// UPDATE the existing NULL row rather than INSERT a duplicate, and rewrite
246+
// table_name to the gpkg_contents spelling so GDAL's case-sensitive lookup
247+
// finds it.
246248
const db: Database = new SQL.Database();
247249
db.run(`
248250
CREATE TABLE gpkg_contents (
@@ -262,7 +264,34 @@ describe("ensureGpkgFeatureCountSync", () => {
262264
const patched = ensureGpkgFeatureCountSync(SQL, original);
263265
assert.notEqual(patched, original);
264266
assert.deepEqual(readOgrContents(patched), [
265-
{ table_name: "places", feature_count: 3 },
267+
{ table_name: "Places", feature_count: 3 },
268+
]);
269+
});
270+
271+
it("repairs a non-ASCII table name whose count is NULL", () => {
272+
// SQLite's lower() is ASCII-only, so a `lower(table_name) = :key` predicate
273+
// would never match a non-ASCII name; matching on the exact stored name
274+
// keeps the UPDATE working here.
275+
const db: Database = new SQL.Database();
276+
db.run(`
277+
CREATE TABLE gpkg_contents (
278+
table_name TEXT NOT NULL PRIMARY KEY, data_type TEXT NOT NULL, srs_id INTEGER
279+
);
280+
CREATE TABLE "Über" (fid INTEGER PRIMARY KEY, geom BLOB);
281+
INSERT INTO gpkg_contents VALUES ('Über', 'features', 4326);
282+
INSERT INTO "Über" (geom) VALUES (NULL), (NULL);
283+
CREATE TABLE gpkg_ogr_contents (
284+
table_name TEXT NOT NULL PRIMARY KEY, feature_count INTEGER
285+
);
286+
INSERT INTO gpkg_ogr_contents (table_name, feature_count) VALUES ('Über', NULL);
287+
`);
288+
const original = db.export();
289+
db.close();
290+
291+
const patched = ensureGpkgFeatureCountSync(SQL, original);
292+
assert.notEqual(patched, original);
293+
assert.deepEqual(readOgrContents(patched), [
294+
{ table_name: "Über", feature_count: 2 },
266295
]);
267296
});
268297

@@ -321,6 +350,31 @@ describe("ensureGpkgFeatureCountSync", () => {
321350
]);
322351
});
323352

353+
it("skips an unreadable phantom table and still repairs the others", () => {
354+
// gpkg_contents lists a table that does not exist as a real SQLite table
355+
// (a deleted/virtual/view entry). count(*) on it throws; the repair must
356+
// skip it and still patch the readable feature table.
357+
const db: Database = new SQL.Database();
358+
db.run(`
359+
CREATE TABLE gpkg_contents (
360+
table_name TEXT NOT NULL PRIMARY KEY, data_type TEXT NOT NULL, srs_id INTEGER
361+
);
362+
CREATE TABLE real_table (fid INTEGER PRIMARY KEY, geom BLOB);
363+
INSERT INTO gpkg_contents VALUES ('real_table', 'features', 4326);
364+
INSERT INTO gpkg_contents VALUES ('ghost_table', 'features', 4326);
365+
INSERT INTO real_table (geom) VALUES (NULL), (NULL);
366+
`);
367+
const original = db.export();
368+
db.close();
369+
370+
const patched = ensureGpkgFeatureCountSync(SQL, original);
371+
assert.notEqual(patched, original);
372+
// ghost_table is silently skipped; real_table is repaired.
373+
assert.deepEqual(readOgrContents(patched), [
374+
{ table_name: "real_table", feature_count: 2 },
375+
]);
376+
});
377+
324378
it("leaves a complete GeoPackage untouched", () => {
325379
const original = buildGpkg({ withOgrContents: true, featureCount: 3 });
326380
const patched = ensureGpkgFeatureCountSync(SQL, original);

0 commit comments

Comments
 (0)