mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Revert "Add analytics table search bar routing and validation logic."
This reverts commit 78af13b5dc.
This commit is contained in:
-112
@@ -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],
|
||||
["[email protected]", false],
|
||||
["[email protected]", 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);
|
||||
});
|
||||
});
|
||||
-102
@@ -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 <table> 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 ("[email protected]").
|
||||
* 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.]<table>` 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;
|
||||
}
|
||||
Reference in New Issue
Block a user