From 482f7b34fa234e5a81883a78cff980740443d0f0 Mon Sep 17 00:00:00 2001 From: Samir B Amin Date: Mon, 2 Mar 2026 16:16:54 -0500 Subject: [PATCH 01/43] advanced search with filter over muliple columns --- static/css/app.css | 93 +++++++++++++++++ static/index.html | 20 ++++ static/js/app.js | 241 ++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 338 insertions(+), 16 deletions(-) diff --git a/static/css/app.css b/static/css/app.css index b3aaebdfe..ebfd8e7b7 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -914,3 +914,96 @@ .ace_autocomplete .ace_active-line { background: #eee !important; } + +/* ------------------------------------------------------------------ */ +/* Advanced Search Panel */ +/* ------------------------------------------------------------------ */ + +#pagination.adv-search-open { + height: auto; + min-height: 50px; +} + +#advanced_search_panel { + clear: both; + padding: 8px 10px 4px 10px; + border-top: 1px solid #eee; + background: #fafafa; +} + +.adv-search-header { + font-size: 12px; + margin-bottom: 6px; + line-height: 26px; +} + +.adv-match-label { + color: #777; + margin: 0 6px; +} + +.adv-conjunction-toggle { + display: inline-block; + vertical-align: middle; +} + +.adv-search-row { + display: flex; + align-items: center; + margin-bottom: 4px; + gap: 6px; +} + +.adv-search-row .adv-col { + width: 150px; + flex-shrink: 0; + height: 28px; + font-size: 12px; + padding: 2px 6px; +} + +.adv-search-row .adv-op { + width: 110px; + flex-shrink: 0; + height: 28px; + font-size: 12px; + padding: 2px 6px; +} + +.adv-search-row .adv-val { + flex: 1; + min-width: 80px; + max-width: 220px; + height: 28px; + font-size: 12px; + padding: 2px 6px; +} + +.adv-search-row .adv-remove-row { + flex-shrink: 0; + padding: 2px 7px; + height: 28px; + line-height: 22px; +} + +.adv-search-footer { + margin-top: 6px; + padding-bottom: 4px; +} + +.adv-search-footer .btn { + margin-right: 6px; +} + +#advanced-search-toggle.adv-open { + background: #f0e8ff; + border-color: #79589f; + color: #79589f; +} + +#adv_search_active_badge { + font-size: 11px; + margin-right: 6px; + vertical-align: middle; + background-color: #79589f; +} diff --git a/static/index.html b/static/index.html index 71ccaf7c0..19d47f290 100644 --- a/static/index.html +++ b/static/index.html @@ -133,13 +133,33 @@ + +
+ rows
diff --git a/static/js/app.js b/static/js/app.js index d5b26d4ef..102910427 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -8,6 +8,7 @@ var currentObject = null; var autocompleteObjects = []; var inputResizing = false; var inputResizeOffset = null; +var advancedSearchActive = false; var filterOptions = { "equal": "= 'DATA'", @@ -22,6 +23,12 @@ var filterOptions = { "not_null": "IS NOT NULL" }; +// Escape a filter value for inline SQL string literals. +// Doubles single-quotes to prevent basic SQL injection via filter values. +function escapeSqlLiteral(val) { + return String(val).replace(/'/g, "''"); +} + function getSessionId() { var id = sessionStorage.getItem("session_id"); @@ -623,20 +630,26 @@ function showTableContent(sortColumn, sortOrder) { sort_order: sortOrder }; - var filter = { - column: $(".filters select.column").val(), - op: $(".filters select.filter").val(), - input: $(".filters input").val() - }; + // Advanced search takes precedence over the simple filter + if (advancedSearchActive) { + var advWhere = $("#advanced_search_panel").data("where"); + if (advWhere) opts["where"] = advWhere; + } else { + var filter = { + column: $(".filters select.column").val(), + op: $(".filters select.filter").val(), + input: $(".filters input").val() + }; - // Apply filtering only if column is selected - if (filter.column && filter.op) { - var where = [ - '"' + filter.column + '"', - filterOptions[filter.op].replace("DATA", filter.input) - ].join(" "); + // Apply filtering only if column is selected + if (filter.column && filter.op) { + var where = [ + '"' + filter.column + '"', + filterOptions[filter.op].replace("DATA", escapeSqlLiteral(filter.input)) + ].join(" "); - opts["where"] = where; + opts["where"] = where; + } } getTableRows(name, opts, function(data) { @@ -1023,6 +1036,131 @@ function buildTableFilters(name, type) { var el = $(""; + + var opHtml = [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '' + ].join(""); + + var row = $('
'); + row.append(''); + row.append(''); + row.append(''); + row.append(''); + + return row; +} + +// Build a combined SQL WHERE clause from all advanced search condition rows. +function buildAdvancedWhereClause() { + var conjunction = $(".adv-conjunction-toggle .btn.active").data("conj") || "AND"; + var parts = []; + + $("#adv_search_rows .adv-search-row").each(function() { + var col = $(this).find(".adv-col").val(); + var op = $(this).find(".adv-op").val(); + var val = $.trim($(this).find(".adv-val").val()); + + if (!col || !op) return; + + var template = filterOptions[op]; + if (!template) return; + + var fragment; + if (template.indexOf("DATA") >= 0) { + if (val === "") return; + fragment = '"' + col + '" ' + template.replace("DATA", escapeSqlLiteral(val)); + } else { + fragment = '"' + col + '" ' + template; + } + + parts.push("(" + fragment + ")"); + }); + + if (parts.length === 0) return null; + return parts.join(" " + conjunction + " "); +} + +// Apply the advanced search conditions and reload table rows. +function applyAdvancedSearch() { + var where = buildAdvancedWhereClause(); + + if (where === null) { + alert("Please complete at least one filter condition (column and operator are required)."); + return; + } + + $("#advanced_search_panel").data("where", where); + advancedSearchActive = true; + + $("#adv_search_active_badge").show(); + $("#advanced-search-toggle").addClass("adv-open"); + + $(".current-page").data("page", 1); + showTableContent(); +} + +// Clear the advanced search state and reset the panel to one empty row. +function resetAdvancedSearch() { + advancedSearchActive = false; + $("#advanced_search_panel").data("where", null); + $("#adv_search_active_badge").hide(); + $("#advanced-search-toggle").removeClass("adv-open"); + + $("#adv_search_rows").empty(); + $("#adv_search_rows").append(buildAdvancedSearchRow()); +} + +// Recalculate #output top offset to account for the advanced panel height. +function adjustOutputTop() { + if ($("#pagination").is(":visible")) { + var h = $("#pagination").outerHeight(true); + $("#output").css("top", h + "px"); + } +} + +// Bind operator change handlers for advanced rows (delegated, called once). +function bindAdvancedOpHandlers() { + if ($("#adv_search_rows").data("op-handler-bound")) return; + $("#adv_search_rows").data("op-handler-bound", true); + + $("#adv_search_rows").on("change", ".adv-op", function() { + var val = $(this).val(); + var valInput = $(this).closest(".adv-search-row").find(".adv-val"); + if (val === "null" || val === "not_null") { + valInput.hide().val(""); + } else { + valInput.show(); + } }); } @@ -1270,10 +1408,19 @@ function bindTableHeaderMenu() { var colValue = $(context).text(); var colName = $("#results_header th").eq(colIdx).data("name"); - $("select.column").val(colName); - $("select.filter").val("equal"); - $("#table_filter_value").val(colValue); - $("#rows_filter").submit(); + if ($("#advanced_search_panel").is(":visible")) { + var newRow = buildAdvancedSearchRow(); + newRow.find(".adv-col").val(colName); + newRow.find(".adv-op").val("equal"); + newRow.find(".adv-val").val(colValue); + $("#adv_search_rows").append(newRow); + } else { + $("select.column").val(colName); + $("select.filter").val("equal"); + $("#table_filter_value").val(colValue); + $("#rows_filter").submit(); + } + break; } } }); @@ -1594,6 +1741,7 @@ $(document).ready(function() { $(this).addClass("active"); $(".current-page").data("page", 1); $(".filters select, .filters input").val(""); + resetAdvancedSearch(); if (currentObject.type == "function") { sessionStorage.setItem("tab", "table_structure"); @@ -1681,6 +1829,67 @@ $(document).ready(function() { $("button.reset-filters").on("click", function() { $(".filters select, .filters input").val(""); + resetAdvancedSearch(); + showTableContent(); + }); + + // ---- Advanced Search ------------------------------------------------ + + // Toggle the advanced search panel open/closed + $("#advanced-search-toggle").on("click", function() { + var panel = $("#advanced_search_panel"); + if (panel.is(":visible")) { + panel.slideUp(150, function() { + $("#pagination").removeClass("adv-search-open"); + adjustOutputTop(); + }); + $(this).find("i").removeClass("fa-caret-up").addClass("fa-caret-down"); + } else { + if ($("#adv_search_rows .adv-search-row").length === 0) { + $("#adv_search_rows").append(buildAdvancedSearchRow()); + bindAdvancedOpHandlers(); + } + panel.slideDown(150, function() { + $("#pagination").addClass("adv-search-open"); + adjustOutputTop(); + }); + $(this).find("i").removeClass("fa-caret-down").addClass("fa-caret-up"); + } + }); + + // Add a new condition row + $("#adv-add-condition").on("click", function() { + var newRow = buildAdvancedSearchRow(); + $("#adv_search_rows").append(newRow); + newRow.find(".adv-col").focus(); + }); + + // Remove a condition row (clear instead of remove if it's the last row) + $("#adv_search_rows").on("click", ".adv-remove-row", function() { + var rows = $("#adv_search_rows .adv-search-row"); + if (rows.length <= 1) { + $(this).closest(".adv-search-row").find("select").val(""); + $(this).closest(".adv-search-row").find("input").val("").show(); + } else { + $(this).closest(".adv-search-row").remove(); + } + adjustOutputTop(); + }); + + // Toggle AND/OR conjunction + $(".adv-conjunction-toggle").on("click", "button", function() { + $(".adv-conjunction-toggle button").removeClass("active"); + $(this).addClass("active"); + }); + + // Apply advanced search + $("#adv-apply").on("click", function() { + applyAdvancedSearch(); + }); + + // Clear advanced search (keep panel open) + $("#adv-reset").on("click", function() { + resetAdvancedSearch(); showTableContent(); }); From c5c56cdc45ba98c7b574127fbb9a9cf8da8e4de1 Mon Sep 17 00:00:00 2001 From: Samir B Amin Date: Mon, 2 Mar 2026 16:42:45 -0500 Subject: [PATCH 02/43] =?UTF-8?q?=E2=9C=A8=20=F0=9F=94=8E=20add=20advanced?= =?UTF-8?q?=20search=20panel=20to=20Rows=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advanced Search panel** (`Advanced` toggle button next to the existing filter bar): - **Multiple conditions** — add unlimited filter rows with `+ Add Condition` - **Per-row AND/OR connectors** — each row after the first has AND/OR toggle pills, enabling mixed logic like `(A AND B) OR (C AND D)` - **18 filter operators** grouped by category: - Comparison: `=`, `<>`, `<`, `>`, `<=`, `>=` - List: `IN`, `NOT IN` (comma-separated values) - Null: `IS NULL`, `IS NOT NULL` - Range: `BETWEEN`, `NOT BETWEEN` (From / To inputs) - Pattern: Contains, Not contains, Has prefix, Has suffix — plus case-insensitive (ILIKE) variants for all four - **Show Query** — previews the full `SELECT * FROM ... WHERE ...;` SQL inline, with a Copy button - **"Advanced Filter Active"** badge in the pagination row when a multi-condition filter is applied - **Right-click → Filter Rows By Value** adds a pre-filled condition row when the panel is open - Filter state resets automatically on table switch or clicking the basic `×` reset button - No backend changes — uses the existing `where` parameter on `GET /api/tables/:table/rows` **Files changed:** `static/index.html`, `static/js/app.js`, `static/css/app.css --- .claude/memory/advanced_search.md | 63 ++++++++ .claude/settings.local.json | 3 +- static/css/app.css | 80 +++++++++++ static/index.html | 12 +- static/js/app.js | 231 ++++++++++++++++++++++++------ 5 files changed, 336 insertions(+), 53 deletions(-) create mode 100644 .claude/memory/advanced_search.md diff --git a/.claude/memory/advanced_search.md b/.claude/memory/advanced_search.md new file mode 100644 index 000000000..84eeac3e0 --- /dev/null +++ b/.claude/memory/advanced_search.md @@ -0,0 +1,63 @@ +# Advanced Search Feature + +## Status: Implemented and working (v2) + +## What was built +Multi-condition advanced search panel for the Rows tab. Accessible via an "Advanced" toggle button next to the existing Apply/× buttons. + +### Features (v1) +- Multiple filter conditions (unlimited rows) +- "Advanced Filter Active" badge in pagination row when active +- Right-click "Filter Rows By Value" appends a pre-filled row when panel is open +- Resets on table switch and basic × reset button +- `#output` top offset recalculated when panel opens/closes + +### Features added in v2 +- **Per-row AND/OR connector pills** — each row after the first has AND/OR buttons; first row shows "WHERE" label; enables `(A) AND (B) OR (C)` style expressions +- **Show Query button** — toggles a `
` box showing the full `SELECT * FROM "schema"."table" WHERE ...;` with a Copy button (reuses `copyToClipboard()`)
+- **Expanded operator set** (18 operators, grouped with ``):
+  - Comparison: `=`, `<>`, `<`, `>`, `<=`, `>=`
+  - List: `IN` (comma-sep → `IN ('a','b')`), `NOT IN`
+  - Null: `IS NULL`, `IS NOT NULL`
+  - Range: `BETWEEN` (From/To inputs), `NOT BETWEEN`
+  - Pattern: Contains, Not contains, Has prefix, Has suffix + case-insensitive variants (ILIKE)
+- **`getOpInputType(op)`** helper: returns `"none" | "single" | "list" | "range"` — controls which input variant is shown
+- **`buildFullQuery()`** — builds full SELECT string using `getCurrentObject().name`
+
+## Files changed
+- `static/index.html` — Advanced button, `#advanced_search_panel`, `#adv_search_active_badge`, Show Query button, `#adv_query_display`
+- `static/css/app.css` — appended styles for panel, connector pills, range inputs, query display box
+- `static/js/app.js` — see key functions below
+
+## Key JS functions (app.js)
+- `var advancedSearchActive = false` — global flag
+- `escapeSqlLiteral(val)` — doubles single-quotes (also applied to simple filter)
+- `getOpInputType(op)` — returns input variant type for an operator
+- `buildAdvancedSearchRow(isFirst)` — builds a condition row; `isFirst=true` → WHERE label, no pill
+- `buildAdvancedWhereClause()` — iterates rows, reads `data-row-conj` per row, builds SQL
+- `buildFullQuery()` — wraps WHERE clause in full SELECT statement
+- `applyAdvancedSearch()` — stores WHERE in panel `.data("where")`, sets flag, reloads
+- `resetAdvancedSearch()` — clears flag/badge, empties rows, adds `buildAdvancedSearchRow(true)`
+- `adjustOutputTop()` — sets `#output` CSS top to `#pagination` outerHeight
+- `bindAdvancedOpHandlers()` — delegated handler showing correct input variant per operator
+
+## Key JS edits (app.js)
+- `showTableContent()` — advanced takes precedence over simple filter when `advancedSearchActive`
+- `buildTableFilters()` — syncs columns into existing advanced rows; passes `isFirst=true`
+- Objects click handler — calls `resetAdvancedSearch()` on table switch
+- `reset-filters` button — calls `resetAdvancedSearch()`
+- `filter_by_value` context menu — appends pre-filled row when panel visible
+
+## No backend changes needed
+The existing `where` param on `GET /api/tables/:table/rows` accepts raw SQL.
+`TableRows()` in `pkg/client/client.go` appends `WHERE ` directly.
+
+## Build command (GOROOT is broken in this environment)
+```bash
+GOROOT=/opt/homebrew/Cellar/go/1.25.7_1/libexec \
+GOPROXY=https://proxy.golang.org,direct \
+GONOSUMDB='*' \
+GOOS=linux GOARCH=amd64 \
+go build -o ./bin/pgweb_linux_amd64
+```
+Output: `bin/pgweb_linux_amd64` (~28MB, ELF 64-bit, statically linked)
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 73572edfe..7bf165bca 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -6,7 +6,8 @@
       "Bash(git checkout:*)",
       "Bash(go test:*)",
       "Bash(go build:*)",
-      "Bash(make:*)"
+      "Bash(make:*)",
+      "Bash(go env:*)"
     ]
   }
 }
diff --git a/static/css/app.css b/static/css/app.css
index ebfd8e7b7..93af973e5 100644
--- a/static/css/app.css
+++ b/static/css/app.css
@@ -1007,3 +1007,83 @@
   vertical-align: middle;
   background-color: #79589f;
 }
+
+/* Per-row AND/OR connector pill */
+.adv-row-conj {
+  display: flex;
+  align-items: center;
+  flex-shrink: 0;
+  width: 72px;
+  gap: 2px;
+}
+
+.adv-row-conj-first {
+  justify-content: flex-end;
+}
+
+.adv-row-conj-first span {
+  font-size: 11px;
+  color: #999;
+  font-weight: bold;
+  padding-right: 4px;
+}
+
+.adv-conj-btn {
+  font-size: 10px;
+  padding: 1px 5px;
+  border: 1px solid #ccc;
+  background: #fff;
+  color: #777;
+  border-radius: 3px;
+  cursor: pointer;
+  line-height: 16px;
+}
+
+.adv-conj-btn.active {
+  background: #79589f;
+  border-color: #79589f;
+  color: #fff;
+}
+
+/* Range input pair */
+.adv-val-range {
+  display: flex;
+  align-items: center;
+  flex: 1;
+  min-width: 0;
+  gap: 4px;
+}
+
+.adv-val-range .adv-val-from,
+.adv-val-range .adv-val-to {
+  flex: 1;
+  min-width: 60px;
+  height: 28px;
+  font-size: 12px;
+  padding: 2px 6px;
+}
+
+.adv-range-sep {
+  flex-shrink: 0;
+  font-size: 11px;
+  color: #999;
+}
+
+/* Query preview box */
+#adv_query_display {
+  margin-top: 6px;
+  padding: 6px 8px;
+  background: #f5f5f5;
+  border: 1px solid #e0e0e0;
+  border-radius: 3px;
+}
+
+#adv_query_display pre {
+  margin: 0 0 6px 0;
+  font-size: 11px;
+  white-space: pre-wrap;
+  word-break: break-all;
+  color: #333;
+  max-height: 80px;
+  overflow-y: auto;
+}
diff --git a/static/index.html b/static/index.html
index 19d47f290..6afce809c 100644
--- a/static/index.html
+++ b/static/index.html
@@ -137,12 +137,7 @@
         
         
         
diff --git a/static/js/app.js b/static/js/app.js index 102910427..7aec415c2 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -11,18 +11,48 @@ var inputResizeOffset = null; var advancedSearchActive = false; var filterOptions = { - "equal": "= 'DATA'", - "not_equal": "!= 'DATA'", - "greater": "> 'DATA'" , - "greater_eq": ">= 'DATA'", - "less": "< 'DATA'", - "less_eq": "<= 'DATA'", - "like": "LIKE 'DATA'", - "ilike": "ILIKE 'DATA'", - "null": "IS NULL", - "not_null": "IS NOT NULL" + // Standard comparison (single value) — also used by simple filter + "equal": "= 'DATA'", + "not_equal": "!= 'DATA'", + "greater": "> 'DATA'", + "greater_eq": ">= 'DATA'", + "less": "< 'DATA'", + "less_eq": "<= 'DATA'", + "like": "LIKE 'DATA'", + "ilike": "ILIKE 'DATA'", + + // NULL checks (no value) + "null": "IS NULL", + "not_null": "IS NOT NULL", + + // List operators — comma-separated values → IN (...) + "in": "IN (DATA)", + "not_in": "NOT IN (DATA)", + + // Range operators — two values + "between": "BETWEEN 'DATA1' AND 'DATA2'", + "not_between": "NOT BETWEEN 'DATA1' AND 'DATA2'", + + // Pattern operators + "contains": "LIKE '%DATA%'", + "not_contains": "NOT LIKE '%DATA%'", + "icontains": "ILIKE '%DATA%'", + "not_icontains": "NOT ILIKE '%DATA%'", + "starts_with": "LIKE 'DATA%'", + "ends_with": "LIKE '%DATA'", + "istarts_with": "ILIKE 'DATA%'", + "iends_with": "ILIKE '%DATA'" }; +// Returns the input type required for an operator: "none" | "single" | "list" | "range" +function getOpInputType(op) { + if (!op || !filterOptions[op]) return "single"; + if (op === "null" || op === "not_null") return "none"; + if (op === "in" || op === "not_in") return "list"; + if (op === "between" || op === "not_between") return "range"; + return "single"; +} + // Escape a filter value for inline SQL string literals. // Doubles single-quotes to prevent basic SQL injection via filter values. function escapeSqlLiteral(val) { @@ -1047,34 +1077,75 @@ function buildTableFilters(name, type) { // Ensure at least one empty row exists in the panel if ($("#adv_search_rows .adv-search-row").length === 0) { - $("#adv_search_rows").append(buildAdvancedSearchRow()); + $("#adv_search_rows").append(buildAdvancedSearchRow(true)); bindAdvancedOpHandlers(); } }); } // Build a new advanced search condition row element. -function buildAdvancedSearchRow() { +// isFirst=true omits the AND/OR connector pill and shows a "WHERE" label instead. +function buildAdvancedSearchRow(isFirst) { var colHtml = $("#pagination select.column").html() || ""; var opHtml = [ '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '' + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '' ].join(""); - var row = $('
'); + var row = $('
'); + + if (isFirst) { + row.append('
WHERE
'); + } else { + row.append( + '
' + + '' + + '' + + '
' + ); + } + row.append(''); row.append(''); row.append(''); + row.append(''); + row.append( + '' + ); row.append(''); return row; @@ -1082,13 +1153,13 @@ function buildAdvancedSearchRow() { // Build a combined SQL WHERE clause from all advanced search condition rows. function buildAdvancedWhereClause() { - var conjunction = $(".adv-conjunction-toggle .btn.active").data("conj") || "AND"; - var parts = []; + var parts = []; // array of {expr, conj} $("#adv_search_rows .adv-search-row").each(function() { - var col = $(this).find(".adv-col").val(); - var op = $(this).find(".adv-op").val(); - var val = $.trim($(this).find(".adv-val").val()); + var col = $(this).find(".adv-col").val(); + var op = $(this).find(".adv-op").val(); + var rowConj = $(this).data("row-conj") || "AND"; + var inputType = getOpInputType(op); if (!col || !op) return; @@ -1096,18 +1167,42 @@ function buildAdvancedWhereClause() { if (!template) return; var fragment; - if (template.indexOf("DATA") >= 0) { - if (val === "") return; - fragment = '"' + col + '" ' + template.replace("DATA", escapeSqlLiteral(val)); - } else { + + if (inputType === "none") { fragment = '"' + col + '" ' + template; + + } else if (inputType === "list") { + var raw = $.trim($(this).find(".adv-val-list").val()); + if (!raw) return; + var items = raw.split(",").map(function(s) { + return "'" + escapeSqlLiteral($.trim(s)) + "'"; + }); + fragment = '"' + col + '" ' + template.replace("DATA", items.join(", ")); + + } else if (inputType === "range") { + var from = $.trim($(this).find(".adv-val-from").val()); + var to = $.trim($(this).find(".adv-val-to").val()); + if (!from || !to) return; + fragment = '"' + col + '" ' + template + .replace("DATA1", escapeSqlLiteral(from)) + .replace("DATA2", escapeSqlLiteral(to)); + + } else { + var val = $.trim($(this).find(".adv-val").val()); + if (!val) return; + fragment = '"' + col + '" ' + template.replace("DATA", escapeSqlLiteral(val)); } - parts.push("(" + fragment + ")"); + parts.push({ expr: "(" + fragment + ")", conj: rowConj }); }); if (parts.length === 0) return null; - return parts.join(" " + conjunction + " "); + + var sql = parts[0].expr; + for (var i = 1; i < parts.length; i++) { + sql += " " + parts[i].conj + " " + parts[i].expr; + } + return sql; } // Apply the advanced search conditions and reload table rows. @@ -1137,7 +1232,23 @@ function resetAdvancedSearch() { $("#advanced-search-toggle").removeClass("adv-open"); $("#adv_search_rows").empty(); - $("#adv_search_rows").append(buildAdvancedSearchRow()); + $("#adv_search_rows").append(buildAdvancedSearchRow(true)); +} + +// Build the full SELECT query string for the current advanced search conditions. +function buildFullQuery() { + var where = buildAdvancedWhereClause(); + if (!where) return null; + + var table = getCurrentObject().name; + var nameParts = table.split("."); + var sql; + if (nameParts.length === 2) { + sql = 'SELECT * FROM "' + nameParts[0] + '"."' + nameParts[1] + '"'; + } else { + sql = 'SELECT * FROM "' + table + '"'; + } + return sql + " WHERE " + where + ";"; } // Recalculate #output top offset to account for the advanced panel height. @@ -1154,12 +1265,14 @@ function bindAdvancedOpHandlers() { $("#adv_search_rows").data("op-handler-bound", true); $("#adv_search_rows").on("change", ".adv-op", function() { - var val = $(this).val(); - var valInput = $(this).closest(".adv-search-row").find(".adv-val"); - if (val === "null" || val === "not_null") { - valInput.hide().val(""); - } else { - valInput.show(); + var row = $(this).closest(".adv-search-row"); + var inputType = getOpInputType($(this).val()); + row.find(".adv-val").toggle(inputType === "single"); + row.find(".adv-val-list").toggle(inputType === "list"); + row.find(".adv-val-range").toggle(inputType === "range"); + if (inputType === "none") { + row.find(".adv-val, .adv-val-list").val(""); + row.find(".adv-val-from, .adv-val-to").val(""); } }); } @@ -1846,7 +1959,7 @@ $(document).ready(function() { $(this).find("i").removeClass("fa-caret-up").addClass("fa-caret-down"); } else { if ($("#adv_search_rows .adv-search-row").length === 0) { - $("#adv_search_rows").append(buildAdvancedSearchRow()); + $("#adv_search_rows").append(buildAdvancedSearchRow(true)); bindAdvancedOpHandlers(); } panel.slideDown(150, function() { @@ -1859,9 +1972,10 @@ $(document).ready(function() { // Add a new condition row $("#adv-add-condition").on("click", function() { - var newRow = buildAdvancedSearchRow(); + var newRow = buildAdvancedSearchRow(false); $("#adv_search_rows").append(newRow); newRow.find(".adv-col").focus(); + adjustOutputTop(); }); // Remove a condition row (clear instead of remove if it's the last row) @@ -1876,10 +1990,11 @@ $(document).ready(function() { adjustOutputTop(); }); - // Toggle AND/OR conjunction - $(".adv-conjunction-toggle").on("click", "button", function() { - $(".adv-conjunction-toggle button").removeClass("active"); + // Toggle per-row AND/OR connector + $("#adv_search_rows").on("click", ".adv-conj-btn", function() { + $(this).closest(".adv-row-conj").find(".adv-conj-btn").removeClass("active"); $(this).addClass("active"); + $(this).closest(".adv-search-row").data("row-conj", $(this).data("conj")); }); // Apply advanced search @@ -1890,9 +2005,33 @@ $(document).ready(function() { // Clear advanced search (keep panel open) $("#adv-reset").on("click", function() { resetAdvancedSearch(); + $("#adv_query_display").hide(); showTableContent(); }); + // Show/hide the raw SQL query preview + $("#adv-show-query").on("click", function() { + var display = $("#adv_query_display"); + if (display.is(":visible")) { + display.slideUp(100, adjustOutputTop); + $(this).find("i").removeClass("fa-chevron-up").addClass("fa-code"); + } else { + var sql = buildFullQuery(); + if (!sql) { + alert("No valid conditions to preview. Fill in at least one complete condition."); + return; + } + $("#adv_query_text").text(sql); + display.slideDown(100, adjustOutputTop); + $(this).find("i").removeClass("fa-code").addClass("fa-chevron-up"); + } + }); + + // Copy raw query to clipboard + $("#adv-copy-query").on("click", function() { + copyToClipboard($("#adv_query_text").text()); + }); + // Automatically prefill the filter if it's not set yet $("select.column").on("change", function() { if ($("select.filter").val() == "") { From b5edcd970428c7654510ae5dd08c8030e8667d45 Mon Sep 17 00:00:00 2001 From: Samir B Amin Date: Tue, 3 Mar 2026 08:17:55 -0500 Subject: [PATCH 03/43] update version --- pkg/command/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/command/version.go b/pkg/command/version.go index 143a8194c..b83ca8bc0 100644 --- a/pkg/command/version.go +++ b/pkg/command/version.go @@ -8,7 +8,7 @@ import ( const ( // Version is the current Pgweb application version - Version = "0.17.0" + Version = "0.17.0p1" ) var ( From 25a9c28bdcf2767bf040512cba1ad789769b3a05 Mon Sep 17 00:00:00 2001 From: Samir B Amin Date: Tue, 3 Mar 2026 09:18:01 -0500 Subject: [PATCH 04/43] offset table when search is expanded --- .claude/memory/advanced_search.md | 9 +++++++-- static/css/app.css | 2 +- static/js/app.js | 1 + 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.claude/memory/advanced_search.md b/.claude/memory/advanced_search.md index 84eeac3e0..d8c6f9d97 100644 --- a/.claude/memory/advanced_search.md +++ b/.claude/memory/advanced_search.md @@ -1,6 +1,6 @@ # Advanced Search Feature -## Status: Implemented and working (v2) +## Status: Implemented and working (v3) ## What was built Multi-condition advanced search panel for the Rows tab. Accessible via an "Advanced" toggle button next to the existing Apply/× buttons. @@ -41,8 +41,13 @@ Multi-condition advanced search panel for the Rows tab. Accessible via an "Advan - `adjustOutputTop()` — sets `#output` CSS top to `#pagination` outerHeight - `bindAdvancedOpHandlers()` — delegated handler showing correct input variant per operator +## Bug fix (v3): advanced panel obscuring table rows +- **Root cause**: `.with-pagination #output { top: 50px !important }` in `app.css` — the `!important` beat jQuery's inline style set by `adjustOutputTop()` +- **Fix 1**: removed `!important` from that CSS rule so JS inline style wins +- **Fix 2**: added `adjustOutputTop()` call immediately after `$("#body").prop("class", "with-pagination")` in `showTableContent()` — so offset is recalculated on every table load, not just on panel open/close + ## Key JS edits (app.js) -- `showTableContent()` — advanced takes precedence over simple filter when `advancedSearchActive` +- `showTableContent()` — advanced takes precedence over simple filter when `advancedSearchActive`; calls `adjustOutputTop()` after setting `with-pagination` class - `buildTableFilters()` — syncs columns into existing advanced rows; passes `isFirst=true` - Objects click handler — calls `resetAdvancedSearch()` on table switch - `reset-filters` button — calls `resetAdvancedSearch()` diff --git a/static/css/app.css b/static/css/app.css index 93af973e5..b5d8a205a 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -615,7 +615,7 @@ } .with-pagination #output { - top: 50px !important; + top: 50px; } .with-pagination #pagination { diff --git a/static/js/app.js b/static/js/app.js index 7aec415c2..8fce6a42d 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -685,6 +685,7 @@ function showTableContent(sortColumn, sortOrder) { getTableRows(name, opts, function(data) { $("#input").hide(); $("#body").prop("class", "with-pagination"); + adjustOutputTop(); buildTable(data, sortColumn, sortOrder); setCurrentTab("table_content"); From e08aec27920c95e49ad1512496d83a8ba0df775d Mon Sep 17 00:00:00 2001 From: Samir B Amin Date: Tue, 3 Mar 2026 09:30:03 -0500 Subject: [PATCH 05/43] regex operators --- .claude/memory/advanced_search.md | 8 +++++++- static/js/app.js | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.claude/memory/advanced_search.md b/.claude/memory/advanced_search.md index d8c6f9d97..670a2eb58 100644 --- a/.claude/memory/advanced_search.md +++ b/.claude/memory/advanced_search.md @@ -1,6 +1,6 @@ # Advanced Search Feature -## Status: Implemented and working (v3) +## Status: Implemented and working (v4) ## What was built Multi-condition advanced search panel for the Rows tab. Accessible via an "Advanced" toggle button next to the existing Apply/× buttons. @@ -21,6 +21,7 @@ Multi-condition advanced search panel for the Rows tab. Accessible via an "Advan - Null: `IS NULL`, `IS NOT NULL` - Range: `BETWEEN` (From/To inputs), `NOT BETWEEN` - Pattern: Contains, Not contains, Has prefix, Has suffix + case-insensitive variants (ILIKE) + - Regex: Matches regex (`~`), Matches regex case insensitive (`~*`) - **`getOpInputType(op)`** helper: returns `"none" | "single" | "list" | "range"` — controls which input variant is shown - **`buildFullQuery()`** — builds full SELECT string using `getCurrentObject().name` @@ -41,6 +42,11 @@ Multi-condition advanced search panel for the Rows tab. Accessible via an "Advan - `adjustOutputTop()` — sets `#output` CSS top to `#pagination` outerHeight - `bindAdvancedOpHandlers()` — delegated handler showing correct input variant per operator +## Added in v4: regex operators +- `"regex": "~ 'DATA'"` and `"iregex": "~* 'DATA'"` added to `filterOptions` +- Two new options appended to the Pattern `` in `buildAdvancedSearchRow()` +- No other changes needed — `getOpInputType()` returns `"single"` by default, `buildAdvancedWhereClause()` handles it unchanged + ## Bug fix (v3): advanced panel obscuring table rows - **Root cause**: `.with-pagination #output { top: 50px !important }` in `app.css` — the `!important` beat jQuery's inline style set by `adjustOutputTop()` - **Fix 1**: removed `!important` from that CSS rule so JS inline style wins diff --git a/static/js/app.js b/static/js/app.js index 8fce6a42d..eb3ba3fe9 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -41,7 +41,11 @@ var filterOptions = { "starts_with": "LIKE 'DATA%'", "ends_with": "LIKE '%DATA'", "istarts_with": "ILIKE 'DATA%'", - "iends_with": "ILIKE '%DATA'" + "iends_with": "ILIKE '%DATA'", + + // Regex operators + "regex": "~ 'DATA'", + "iregex": "~* 'DATA'" }; // Returns the input type required for an operator: "none" | "single" | "list" | "range" @@ -1120,6 +1124,8 @@ function buildAdvancedSearchRow(isFirst) { '', '', '', + '', + '', '' ].join(""); From 1e4d9fd3569aec17ced7fae5908cebd40b96f801 Mon Sep 17 00:00:00 2001 From: Samir B Amin Date: Tue, 3 Mar 2026 09:40:08 -0500 Subject: [PATCH 06/43] always keep first operator --- .claude/memory/advanced_search.md | 3 +++ static/js/app.js | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.claude/memory/advanced_search.md b/.claude/memory/advanced_search.md index 670a2eb58..bcb762464 100644 --- a/.claude/memory/advanced_search.md +++ b/.claude/memory/advanced_search.md @@ -42,6 +42,9 @@ Multi-condition advanced search panel for the Rows tab. Accessible via an "Advan - `adjustOutputTop()` — sets `#output` CSS top to `#pagination` outerHeight - `bindAdvancedOpHandlers()` — delegated handler showing correct input variant per operator +## Bug fix (v4b): first row showed unnecessary − delete button +- `buildAdvancedSearchRow(isFirst)` now only appends the remove button when `isFirst=false` + ## Added in v4: regex operators - `"regex": "~ 'DATA'"` and `"iregex": "~* 'DATA'"` added to `filterOptions` - Two new options appended to the Pattern `` in `buildAdvancedSearchRow()` diff --git a/static/js/app.js b/static/js/app.js index eb3ba3fe9..3336eb38c 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1153,7 +1153,9 @@ function buildAdvancedSearchRow(isFirst) { '' + '' ); - row.append(''); + if (!isFirst) { + row.append(''); + } return row; } From 432fbdf946cce2db1beba8549c1dd2ca5668db8a Mon Sep 17 00:00:00 2001 From: Samir B Amin Date: Thu, 5 Mar 2026 13:38:32 -0500 Subject: [PATCH 07/43] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20fixed=20IN,=20BET?= =?UTF-8?q?WEEN,=20and=20NULL=20operators?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fix (v5): LIST, RANGE, NULL operators not working - **Root cause 1**: `.adv-val-range` shown via `.toggle(true)` renders as `display:block`, breaking the flex From/To layout — fixed by using `.css("display","flex")` in new `updateAdvRowInputs()` helper - **Root cause 2**: `buildAdvancedWhereClause()` read from `.adv-val-list` / `.adv-val-from/to` but if user typed in the always-visible `.adv-val` fallback was missing — added fallback: LIST reads `.adv-val` if `.adv-val-list` is empty; RANGE parses `"val1, val2"` or `"val1 and val2"` from `.adv-val` if From/To are empty - Extracted `updateAdvRowInputs(row, op)` from inline `change` handler for reuse --- .claude/memory/advanced_search.md | 10 ++++++-- static/js/app.js | 41 +++++++++++++++++++++++-------- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/.claude/memory/advanced_search.md b/.claude/memory/advanced_search.md index bcb762464..10bcdeabd 100644 --- a/.claude/memory/advanced_search.md +++ b/.claude/memory/advanced_search.md @@ -1,6 +1,6 @@ # Advanced Search Feature -## Status: Implemented and working (v4) +## Status: Implemented and working (v5) ## What was built Multi-condition advanced search panel for the Rows tab. Accessible via an "Advanced" toggle button next to the existing Apply/× buttons. @@ -40,7 +40,13 @@ Multi-condition advanced search panel for the Rows tab. Accessible via an "Advan - `applyAdvancedSearch()` — stores WHERE in panel `.data("where")`, sets flag, reloads - `resetAdvancedSearch()` — clears flag/badge, empties rows, adds `buildAdvancedSearchRow(true)` - `adjustOutputTop()` — sets `#output` CSS top to `#pagination` outerHeight -- `bindAdvancedOpHandlers()` — delegated handler showing correct input variant per operator +- `updateAdvRowInputs(row, op)` — switches visible input variant for a row; uses `.css("display","flex")` for range span (not `.toggle()` which would give `block`) +- `bindAdvancedOpHandlers()` — delegated `change` handler on `.adv-op`; calls `updateAdvRowInputs()` + +## Bug fix (v5): LIST, RANGE, NULL operators not working +- **Root cause 1**: `.adv-val-range` shown via `.toggle(true)` renders as `display:block`, breaking the flex From/To layout — fixed by using `.css("display","flex")` in new `updateAdvRowInputs()` helper +- **Root cause 2**: `buildAdvancedWhereClause()` read from `.adv-val-list` / `.adv-val-from/to` but if user typed in the always-visible `.adv-val` fallback was missing — added fallback: LIST reads `.adv-val` if `.adv-val-list` is empty; RANGE parses `"val1, val2"` or `"val1 and val2"` from `.adv-val` if From/To are empty +- Extracted `updateAdvRowInputs(row, op)` from inline `change` handler for reuse ## Bug fix (v4b): first row showed unnecessary − delete button - `buildAdvancedSearchRow(isFirst)` now only appends the remove button when `isFirst=false` diff --git a/static/js/app.js b/static/js/app.js index 3336eb38c..2a7c59ef6 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1181,7 +1181,9 @@ function buildAdvancedWhereClause() { fragment = '"' + col + '" ' + template; } else if (inputType === "list") { - var raw = $.trim($(this).find(".adv-val-list").val()); + // Read from list input; fall back to single input if user typed there + var raw = $.trim($(this).find(".adv-val-list").val()) || + $.trim($(this).find(".adv-val").val()); if (!raw) return; var items = raw.split(",").map(function(s) { return "'" + escapeSqlLiteral($.trim(s)) + "'"; @@ -1189,8 +1191,18 @@ function buildAdvancedWhereClause() { fragment = '"' + col + '" ' + template.replace("DATA", items.join(", ")); } else if (inputType === "range") { + // Read from range inputs; fall back to parsing single input as "from,to" var from = $.trim($(this).find(".adv-val-from").val()); var to = $.trim($(this).find(".adv-val-to").val()); + if (!from || !to) { + // Try parsing "val1,val2" or "val1 and val2" from single input + var single = $.trim($(this).find(".adv-val").val()); + var rangeParts = single.split(/\s*(?:,|and)\s*/i); + if (rangeParts.length === 2) { + from = $.trim(rangeParts[0]); + to = $.trim(rangeParts[1]); + } + } if (!from || !to) return; fragment = '"' + col + '" ' + template .replace("DATA1", escapeSqlLiteral(from)) @@ -1268,21 +1280,30 @@ function adjustOutputTop() { } } +// Update the visible input controls for an advanced row based on its operator. +function updateAdvRowInputs(row, op) { + var inputType = getOpInputType(op); + row.find(".adv-val").toggle(inputType === "single"); + row.find(".adv-val-list").toggle(inputType === "list"); + // Use css() for range span so it shows as flex, not block + if (inputType === "range") { + row.find(".adv-val-range").css("display", "flex"); + } else { + row.find(".adv-val-range").hide(); + } + if (inputType === "none") { + row.find(".adv-val, .adv-val-list").val(""); + row.find(".adv-val-from, .adv-val-to").val(""); + } +} + // Bind operator change handlers for advanced rows (delegated, called once). function bindAdvancedOpHandlers() { if ($("#adv_search_rows").data("op-handler-bound")) return; $("#adv_search_rows").data("op-handler-bound", true); $("#adv_search_rows").on("change", ".adv-op", function() { - var row = $(this).closest(".adv-search-row"); - var inputType = getOpInputType($(this).val()); - row.find(".adv-val").toggle(inputType === "single"); - row.find(".adv-val-list").toggle(inputType === "list"); - row.find(".adv-val-range").toggle(inputType === "range"); - if (inputType === "none") { - row.find(".adv-val, .adv-val-list").val(""); - row.find(".adv-val-from, .adv-val-to").val(""); - } + updateAdvRowInputs($(this).closest(".adv-search-row"), $(this).val()); }); } From ee7516bc5cb461800caf9b93daa1f974b1279721 Mon Sep 17 00:00:00 2001 From: Samir B Amin Date: Tue, 31 Mar 2026 23:21:25 -0400 Subject: [PATCH 08/43] chore: ignore .worktrees/ directory --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index c3d720a9f..a9f11a2d6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ pgweb bin tmp/ cover.out +docs/** +.worktrees/ + From 98c489faf767b1f34afbbb78560c69a815c62451 Mon Sep 17 00:00:00 2001 From: Samir B Amin Date: Tue, 31 Mar 2026 23:28:43 -0400 Subject: [PATCH 09/43] feat: add aggregate panel HTML skeleton Add three new UI elements to support the aggregate panel: 1. Aggregate toggle button next to Advanced search toggle 2. Aggregate panel div with GROUP BY, AGGREGATES, and HAVING sections 3. Aggregate active badge in the current-page display Co-Authored-By: Claude Sonnet 4.6 --- static/index.html | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/static/index.html b/static/index.html index 6afce809c..93b90843a 100644 --- a/static/index.html +++ b/static/index.html @@ -134,6 +134,7 @@ + +
@@ -160,6 +192,7 @@
+ rows
From a59a5f714087799bbb1a6bd28968fa5426f73f29 Mon Sep 17 00:00:00 2001 From: Samir B Amin Date: Tue, 31 Mar 2026 23:32:57 -0400 Subject: [PATCH 10/43] feat: add aggregate panel CSS --- static/css/app.css | 107 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/static/css/app.css b/static/css/app.css index b5d8a205a..57cdf7c0d 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -1087,3 +1087,110 @@ max-height: 80px; overflow-y: auto; } + +/* ── Aggregate panel ───────────────────────────────────────────── */ + +#pagination.agg-panel-open { + height: auto; + min-height: 50px; +} + +#aggregate_panel { + clear: both; + padding: 8px 10px 4px 10px; + border-top: 1px solid #eee; + background: #fafafa; +} + +#aggregate-toggle.agg-open { + background: #f0e8ff; + border-color: #79589f; + color: #79589f; +} + +#agg_active_badge { + font-size: 11px; + margin-right: 6px; + vertical-align: middle; + background-color: #79589f; +} + +.agg-section { + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid #eee; +} + +.agg-section:last-child { + border-bottom: none; +} + +.agg-section-header { + font-size: 11px; + font-weight: bold; + color: #999; + text-transform: uppercase; + letter-spacing: 0.5px; + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.agg-group-by-grid { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; +} + +.agg-group-by-grid label { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: normal; + margin: 0; + cursor: pointer; +} + +.agg-expr-row { + display: flex; + align-items: center; + margin-bottom: 4px; + gap: 6px; +} + +.agg-expr-row .agg-fn { + width: 120px; + flex-shrink: 0; + height: 28px; + font-size: 12px; + padding: 2px 6px; +} + +.agg-expr-row .agg-col { + width: 140px; + flex-shrink: 0; + height: 28px; + font-size: 12px; + padding: 2px 6px; +} + +.agg-expr-row .agg-alias { + width: 120px; + flex-shrink: 0; + height: 28px; + font-size: 12px; + padding: 2px 6px; +} + +.agg-expr-row .agg-remove-row { + flex-shrink: 0; + padding: 2px 7px; + height: 28px; + line-height: 22px; +} + +.agg-col-hidden { + display: none !important; +} From 75fc8f250c2bfa3155ffea5370ecdddacd468b18 Mon Sep 17 00:00:00 2001 From: Samir B Amin Date: Tue, 31 Mar 2026 23:33:46 -0400 Subject: [PATCH 11/43] feat: add aggregateActive flag, buildGroupBySection, and aggregate panel toggle --- static/js/app.js | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/static/js/app.js b/static/js/app.js index 2a7c59ef6..59fdef0f7 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -9,6 +9,7 @@ var autocompleteObjects = []; var inputResizing = false; var inputResizeOffset = null; var advancedSearchActive = false; +var aggregateActive = false; var filterOptions = { // Standard comparison (single value) — also used by simple filter @@ -1085,6 +1086,8 @@ function buildTableFilters(name, type) { $("#adv_search_rows").append(buildAdvancedSearchRow(true)); bindAdvancedOpHandlers(); } + + buildGroupBySection(); }); } @@ -1160,6 +1163,19 @@ function buildAdvancedSearchRow(isFirst) { return row; } +function buildGroupBySection() { + var grid = $("#agg_group_by_grid"); + grid.empty(); + $("#pagination select.column option").each(function() { + var val = $(this).val(); + if (!val) return; + var label = $("