From 5c7c202fb26c641babe01ea85d935ab8c32ff428 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Tue, 14 Jul 2026 11:14:28 -0700 Subject: [PATCH] Revert "Add analytics table search bar routing and validation logic." This reverts commit 78af13b5dcf52b48d9df5af0b7c0ee5ae1d11b22. --- .../analytics/tables/search-bar-logic.test.ts | 112 ------------------ .../analytics/tables/search-bar-logic.ts | 102 ---------------- 2 files changed, 214 deletions(-) delete mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.test.ts delete mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.ts diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.test.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.test.ts deleted file mode 100644 index 86190281a..000000000 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - getValidatedTableFilterQuery, - looksLikeNaturalLanguageQuery, -} from "./search-bar-logic"; - -describe("looksLikeNaturalLanguageQuery", () => { - it.each([ - // plain substring searches stay plain - ["", false], - [" ", false], - ["alice", false], - ["alice@example.com", false], - ["no-reply@example.com", false], // single token; "no" cue must not trigger - ["4c5ba425-6c22-4f65-9e33-3e2b1c5a8d1f", false], - ["/dashboard/settings", false], - ["utm_source=google", false], // bare "=" appears in real data values - ["john smith", false], - ["page view", false], - // natural-language conditions go to the AI - ["users who signed up last week", true], - ["verified users", true], - ["more than 5 events", true], - ["signed up before january", true], - ["emails that bounced", true], - ["count > 10", true], - ["how many users are there?", true], - ["anything at all?", true], // trailing question mark is always a question - ["users without a display name", true], - ["events from the past 3 days", true], - ])("%j → %s", (input, expected) => { - expect(looksLikeNaturalLanguageQuery(input)).toBe(expected); - }); -}); - -describe("getValidatedTableFilterQuery", () => { - it("accepts a bare SELECT * on the table", () => { - expect(getValidatedTableFilterQuery("SELECT * FROM users", "users")).toBe( - "SELECT * FROM users", - ); - }); - - it("accepts WHERE filters, default. prefixes, backticks, and mixed case", () => { - expect( - getValidatedTableFilterQuery( - "select * from default.users where primary_email_verified = 1", - "users", - ), - ).toBe("select * from default.users where primary_email_verified = 1"); - expect( - getValidatedTableFilterQuery( - "SELECT * FROM `users` WHERE signed_up_at >= now() - INTERVAL 7 DAY", - "users", - ), - ).toContain("WHERE signed_up_at"); - }); - - it("accepts multi-line queries and strips trailing semicolons", () => { - expect( - getValidatedTableFilterQuery( - "SELECT *\nFROM users\nWHERE is_anonymous = 0;", - "users", - ), - ).toBe("SELECT *\nFROM users\nWHERE is_anonymous = 0"); - }); - - it("accepts subqueries inside the WHERE condition", () => { - const query = - "SELECT * FROM users WHERE toString(id) IN (SELECT user_id FROM events GROUP BY user_id HAVING count() > 5 LIMIT 100)"; - expect(getValidatedTableFilterQuery(query, "users")).toBe(query); - }); - - it("rejects queries on a different table", () => { - expect(getValidatedTableFilterQuery("SELECT * FROM events", "users")).toBe(null); - // prefix of another table name must not match - expect(getValidatedTableFilterQuery("SELECT * FROM users2 WHERE 1", "users")).toBe(null); - expect(getValidatedTableFilterQuery("SELECT * FROM users_archive", "users")).toBe(null); - }); - - it("rejects anything that could change the column set", () => { - expect( - getValidatedTableFilterQuery("SELECT id, primary_email FROM users", "users"), - ).toBe(null); - expect( - getValidatedTableFilterQuery("SELECT count() FROM users", "users"), - ).toBe(null); - expect( - getValidatedTableFilterQuery( - "SELECT * FROM users u JOIN events e ON toString(u.id) = e.user_id", - "users", - ), - ).toBe(null); - expect( - getValidatedTableFilterQuery( - "SELECT * FROM users, events", - "users", - ), - ).toBe(null); - expect( - getValidatedTableFilterQuery( - "SELECT * FROM users GROUP BY id", - "users", - ), - ).toBe(null); - }); - - it("rejects empty and non-SELECT input", () => { - expect(getValidatedTableFilterQuery("", "users")).toBe(null); - expect(getValidatedTableFilterQuery(";", "users")).toBe(null); - expect(getValidatedTableFilterQuery("DROP TABLE users", "users")).toBe(null); - }); -}); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.ts deleted file mode 100644 index 0d28bac66..000000000 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Pure decision logic for the analytics table search bar, kept free of React - * so the heuristics can be unit-tested exhaustively (see - * search-bar-logic.test.ts). - * - * The search bar is filter-first: typing applies a plain substring (ILIKE) - * filter over the current table. Only input that a substring match cannot - * express is routed to the AI, which must respond with a row filter of the - * shape `SELECT * FROM WHERE ...` so the grid's columns never change. - */ - -/** - * Words that signal the user is describing a CONDITION ("users who signed up - * last week") rather than typing a substring to match ("alice@example.com"). - * Deliberately excludes anything likely to appear inside real data values — - * e.g. bare "com" or "www" — since false positives would hijack ordinary - * searches. - */ -const NATURAL_LANGUAGE_CUES = new Set([ - // question / command words - "who", "whose", "which", "whom", "what", "when", "how", "why", - "show", "find", "list", "give", "display", "filter", "get", - // comparison / quantity words - "more", "less", "most", "least", "fewer", "greater", "than", - "under", "over", "above", "below", "between", "top", "bottom", "count", - // temporal words - "before", "after", "since", "ago", "during", "past", "last", "first", - "latest", "newest", "oldest", "recent", "recently", "earlier", - "today", "yesterday", "tomorrow", - "day", "days", "week", "weeks", "month", "months", "year", "years", - "hour", "hours", "minute", "minutes", - // state / negation words - "verified", "unverified", "anonymous", "empty", "missing", "null", - "never", "ever", "only", "all", "any", "every", "none", "not", "without", - // relational / auxiliary words common in questions - "with", "have", "has", "had", "are", "is", "was", "were", "that", - "contains", "containing", "starts", "ends", "starting", "ending", - // domain verbs that read as conditions - "signed", "created", "updated", "joined", "expired", "delivered", - "opened", "clicked", "bounced", "sent", - // plural domain nouns — people describe segments with these ("verified - // users"), while substring searches contain concrete values instead - "users", "teams", "members", "accounts", "sessions", "invitations", - "permissions", "channels", "tokens", "emails", "events", -]); - -/** - * Heuristic: does this input look like a natural-language request (→ send to - * the AI on Enter) rather than a plain substring search (→ keep the live - * filter)? The zero-results fallback in the search bar catches misses, so - * this errs on the side of NOT flagging: single-token input (emails, IDs, - * paths) is never natural language. - */ -export function looksLikeNaturalLanguageQuery(text: string): boolean { - const trimmed = text.trim(); - if (trimmed.length === 0) return false; - // Comparisons can never be expressed as a substring match. Bare `=` is - // deliberately NOT a cue — it shows up in legitimate substring searches - // like URL query params ("utm_source=google"). - if (/[<>]=?|!=/.test(trimmed)) return true; - if (trimmed.endsWith("?")) return true; - // Single tokens (emails, UUIDs, paths) are always substring searches, even - // when their word-fragments happen to hit the cue list ("no-reply@…"). - if (!/\s/.test(trimmed)) return false; - const words = trimmed.toLowerCase().match(/[a-z']+/g) ?? []; - const cueCount = words.reduce( - (count, word) => count + (NATURAL_LANGUAGE_CUES.has(word) ? 1 : 0), - 0, - ); - return (words.length >= 3 && cueCount >= 1) || cueCount >= 2; -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -/** - * Validates that an AI-committed query is a pure row filter over the given - * table, i.e. it cannot change the grid's columns. Accepts - * `SELECT * FROM [default.]
` optionally followed by - * WHERE/PREWHERE/ORDER BY/LIMIT — anything else (column lists, JOINs at the - * top level, other tables) returns null and must not be applied to the grid. - * Subqueries inside the WHERE condition are fine: the `SELECT *` prefix on - * the outer query is what guarantees the column set stays identical. - * - * Returns the query normalized for embedding as a subquery (trailing - * semicolons stripped — the grid wraps it in `SELECT * FROM (...)`). - */ -export function getValidatedTableFilterQuery( - query: string, - tableName: string, -): string | null { - const normalized = query.trim().replace(/;+\s*$/, "").trim(); - if (normalized.length === 0) return null; - const collapsed = normalized.replace(/\s+/g, " "); - const table = escapeRegExp(tableName); - const pattern = new RegExp( - `^select \\* from (?:default\\.)?\`?${table}\`?(?: (?:where|prewhere|order by|limit)\\b.*)?$`, - "i", - ); - return pattern.test(collapsed) ? normalized : null; -}