+ {/* Meta line */}
+
+
+ {t("rowsTotal", { count: result.total, shown: result.rows.length })}
+
+
+
+ {t("tookMs", { ms: result.tookMs })}
+
+
+
+ {/* Truncated banner */}
+ {result.truncated ? (
+
+
+ {t("truncated", { limit: result.limit })}
+
+ ) : null}
+
+ {/* Warnings */}
+ {result.warnings && result.warnings.length > 0 ? (
+
+ {result.warnings.map((w, i) => (
+
+
+ {w}
+
+ ))}
+
+ ) : null}
+
+ {/* Table */}
+ {result.rows.length === 0 ? (
+
+ ) : (
+
+
+
+
+
+ {result.columns.map((col) => (
+ |
+ {col}
+ |
+ ))}
+
+
+
+ {result.rows.map((row, ri) => (
+
+ {result.columns.map((col) => (
+ |
+ {formatCell(row[col])}
+ |
+ ))}
+
+ ))}
+
+
+
+
+ )}
+
+ );
+}
+
+/** Render a cell value as a string. Objects/arrays are JSON-stringified; null /
+ * undefined render as an em-dash so empty cells are visually distinct. */
+function formatCell(value: unknown): string {
+ if (value === null || value === undefined) return "—";
+ if (typeof value === "object") {
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return String(value);
+ }
+ }
+ return String(value);
+}
diff --git a/server/__tests__/query.test.js b/server/__tests__/query.test.js
new file mode 100644
index 00000000..776ce12a
--- /dev/null
+++ b/server/__tests__/query.test.js
@@ -0,0 +1,497 @@
+/**
+ * @file Integration tests for the safe Query Explorer (/api/query). Exercises a
+ * real on-disk SQLite DB (temp file) through the HTTP layer: filter operators,
+ * match/sort/limit semantics, the truncation/warning contract, CSV export, and
+ * saved-query CRUD. The security block asserts that the DSL rejects unknown
+ * entities/fields, type-mismatched operators, and SQL-injection attempts in
+ * both field names and values, with the underlying table left intact.
+ * @author Son Nguyen