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 new file mode 100644 index 000000000..86190281a --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.test.ts @@ -0,0 +1,112 @@ +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 new file mode 100644 index 000000000..0d28bac66 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.ts @@ -0,0 +1,102 @@ +/** + * 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; +}