diff --git a/.claude/memory/advanced_search.md b/.claude/memory/advanced_search.md new file mode 100644 index 000000000..bcb762464 --- /dev/null +++ b/.claude/memory/advanced_search.md @@ -0,0 +1,77 @@ +# Advanced Search Feature + +## 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. + +### 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)
+  - 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`
+
+## 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
+
+## 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()`
+- 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
+- **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`; 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()`
+- `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/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 (
diff --git a/static/css/app.css b/static/css/app.css
index b3aaebdfe..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 {
@@ -914,3 +914,176 @@
 .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;
+}
+
+/* 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 71ccaf7c0..6afce809c 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..3336eb38c 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -8,20 +8,61 @@ var currentObject = null; var autocompleteObjects = []; var inputResizing = false; 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'", + + // Regex operators + "regex": "~ 'DATA'", + "iregex": "~* '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) { + return String(val).replace(/'/g, "''"); +} + function getSessionId() { var id = sessionStorage.getItem("session_id"); @@ -623,25 +664,32 @@ 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) { $("#input").hide(); $("#body").prop("class", "with-pagination"); + adjustOutputTop(); buildTable(data, sortColumn, sortOrder); setCurrentTab("table_content"); @@ -1023,6 +1071,218 @@ function buildTableFilters(name, type) { var el = $(""; + + var opHtml = [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '' + ].join(""); + + var row = $('
'); + + if (isFirst) { + row.append('
WHERE
'); + } else { + row.append( + '
' + + '' + + '' + + '
' + ); + } + + row.append(''); + row.append(''); + row.append(''); + row.append(''); + row.append( + '' + ); + if (!isFirst) { + row.append(''); + } + + return row; +} + +// Build a combined SQL WHERE clause from all advanced search condition rows. +function buildAdvancedWhereClause() { + 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 rowConj = $(this).data("row-conj") || "AND"; + var inputType = getOpInputType(op); + + if (!col || !op) return; + + var template = filterOptions[op]; + if (!template) return; + + var fragment; + + 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({ expr: "(" + fragment + ")", conj: rowConj }); + }); + + if (parts.length === 0) return null; + + 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. +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(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. +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 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(""); + } }); } @@ -1270,10 +1530,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 +1863,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,9 +1951,96 @@ $(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(true)); + 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(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) + $("#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 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 + $("#adv-apply").on("click", function() { + applyAdvancedSearch(); + }); + + // 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() == "") {