Add NOT_CONTAINS, fix bugs (#160)

* Fix null case for tag comparisons

* Change debounce time to 500ms

* Add NOT_CONTAINS

* Avoid sql injection

* Store filters by id

* Fix chained NOT_CONTAINS
This commit is contained in:
arcticfly
2023-08-15 16:43:59 -07:00
committed by GitHub
parent ac2ca0f617
commit 0fba2c9ee7
7 changed files with 59 additions and 68 deletions

View File

@@ -10,7 +10,14 @@ const AddFilterButton = () => {
<HStack
as={Button}
variant="ghost"
onClick={() => addFilter({ field: defaultFilterableFields[0], comparator: comparators[0] })}
onClick={() =>
addFilter({
id: Date.now().toString(),
field: defaultFilterableFields[0],
comparator: comparators[0],
value: "",
})
}
spacing={0}
fontSize="sm"
>

View File

@@ -8,39 +8,34 @@ import { debounce } from "lodash-es";
import SelectFieldDropdown from "./SelectFieldDropdown";
import SelectComparatorDropdown from "./SelectComparatorDropdown";
const LogFilter = ({ filter, index }: { filter: LogFilter; index: number }) => {
const LogFilter = ({ filter }: { filter: LogFilter }) => {
const updateFilter = useAppStore((s) => s.logFilters.updateFilter);
const deleteFilter = useAppStore((s) => s.logFilters.deleteFilter);
const [editedValue, setEditedValue] = useState("");
const [editedValue, setEditedValue] = useState(filter.value);
const debouncedUpdateFilter = useCallback(
debounce(
(index: number, filter: LogFilter) => {
console.log("updating filter!!!");
updateFilter(index, filter);
},
200,
{ leading: true },
),
debounce((filter: LogFilter) => updateFilter(filter), 500, {
leading: true,
}),
[updateFilter],
);
return (
<HStack>
<SelectFieldDropdown filter={filter} index={index} />
<SelectComparatorDropdown filter={filter} index={index} />
<SelectFieldDropdown filter={filter} />
<SelectComparatorDropdown filter={filter} />
<Input
value={editedValue}
onChange={(e) => {
setEditedValue(e.target.value);
debouncedUpdateFilter(index, { ...filter, value: e.target.value });
debouncedUpdateFilter({ ...filter, value: e.target.value });
}}
/>
<IconButton
aria-label="Delete Filter"
icon={<BsTrash />}
onClick={() => deleteFilter(index)}
onClick={() => deleteFilter(filter.id)}
/>
</HStack>
);

View File

@@ -19,8 +19,8 @@ const LogFilters = () => {
<Text fontWeight="bold" color="gray.500">
Filters
</Text>
{filters.map((filter, index) => (
<LogFilter key={index} filter={filter} index={index} />
{filters.map((filter) => (
<LogFilter key={filter.id} filter={filter} />
))}
<AddFilterButton />
</VStack>

View File

@@ -2,7 +2,7 @@ import { comparators, type LogFilter } from "~/state/logFiltersSlice";
import { useAppStore } from "~/state/store";
import InputDropdown from "~/components/InputDropdown";
const SelectComparatorDropdown = ({ filter, index }: { filter: LogFilter; index: number }) => {
const SelectComparatorDropdown = ({ filter }: { filter: LogFilter }) => {
const updateFilter = useAppStore((s) => s.logFilters.updateFilter);
const { comparator } = filter;
@@ -11,7 +11,7 @@ const SelectComparatorDropdown = ({ filter, index }: { filter: LogFilter; index:
<InputDropdown
options={comparators}
selectedOption={comparator}
onSelect={(option) => updateFilter(index, { ...filter, comparator: option })}
onSelect={(option) => updateFilter({ ...filter, comparator: option })}
/>
);
};

View File

@@ -3,7 +3,7 @@ import { useAppStore } from "~/state/store";
import { useTagNames } from "~/utils/hooks";
import InputDropdown from "~/components/InputDropdown";
const SelectFieldDropdown = ({ filter, index }: { filter: LogFilter; index: number }) => {
const SelectFieldDropdown = ({ filter }: { filter: LogFilter }) => {
const tagNames = useTagNames().data;
const updateFilter = useAppStore((s) => s.logFilters.updateFilter);
@@ -14,7 +14,7 @@ const SelectFieldDropdown = ({ filter, index }: { filter: LogFilter; index: numb
<InputDropdown
options={[...defaultFilterableFields, ...(tagNames || [])]}
selectedOption={field}
onSelect={(option) => updateFilter(index, { ...filter, field: option })}
onSelect={(option) => updateFilter({ ...filter, field: option })}
/>
);
};

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { type Expression, type SqlBool, sql } from "kysely";
import { type Expression, type SqlBool, sql, type RawBuilder } from "kysely";
import { jsonArrayFrom } from "kysely/helpers/postgres";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
@@ -8,15 +8,22 @@ import { comparators, defaultFilterableFields } from "~/state/logFiltersSlice";
import { requireCanViewProject } from "~/utils/accessControl";
// create comparator type based off of comparators
const comparatorToSqlValue = (comparator: (typeof comparators)[number], value: string) => {
switch (comparator) {
case "=":
return `= '${value}'`;
case "!=":
return `!= '${value}'`;
case "CONTAINS":
return `like '%${value}%'`;
}
const comparatorToSqlExpression = (comparator: (typeof comparators)[number], value: string) => {
return (reference: RawBuilder<unknown>): Expression<SqlBool> => {
switch (comparator) {
case "=":
return sql`${reference} = ${value}`;
case "!=":
// Handle NULL values
return sql`${reference} IS DISTINCT FROM ${value}`;
case "CONTAINS":
return sql`${reference} LIKE ${"%" + value + "%"}`;
case "NOT_CONTAINS":
return sql`(${reference} NOT LIKE ${"%" + value + "%"} OR ${reference} IS NULL)`;
default:
throw new Error("Unknown comparator");
}
};
};
export const loggedCallsRouter = createTRPCRouter({
@@ -30,7 +37,7 @@ export const loggedCallsRouter = createTRPCRouter({
z.object({
field: z.string(),
comparator: z.enum(comparators),
value: z.string().optional(),
value: z.string(),
}),
),
}),
@@ -48,40 +55,19 @@ export const loggedCallsRouter = createTRPCRouter({
for (const filter of input.filters) {
if (!filter.value) continue;
const filterExpression = comparatorToSqlExpression(filter.comparator, filter.value);
if (filter.field === "Request") {
wheres.push(
sql.raw(
`lcmr."reqPayload"::text ${comparatorToSqlValue(
filter.comparator,
filter.value,
)}`,
),
);
wheres.push(filterExpression(sql.raw(`lcmr."reqPayload"::text`)));
}
if (filter.field === "Response") {
wheres.push(
sql.raw(
`lcmr."respPayload"::text ${comparatorToSqlValue(
filter.comparator,
filter.value,
)}`,
),
);
wheres.push(filterExpression(sql.raw(`lcmr."respPayload"::text`)));
}
if (filter.field === "Model") {
wheres.push(
sql.raw(`lc."model" ${comparatorToSqlValue(filter.comparator, filter.value)}`),
);
wheres.push(filterExpression(sql.raw(`lc."model"`)));
}
if (filter.field === "Status Code") {
wheres.push(
sql.raw(
`lcmr."statusCode"::text ${comparatorToSqlValue(
filter.comparator,
filter.value,
)}`,
),
);
wheres.push(filterExpression(sql.raw(`lcmr."statusCode"::text`)));
}
}
@@ -101,15 +87,15 @@ export const loggedCallsRouter = createTRPCRouter({
const filter = tagFilters[i];
if (!filter?.value) continue;
const tableAlias = `lct${i}`;
const filterExpression = comparatorToSqlExpression(filter.comparator, filter.value);
updatedBaseQuery = updatedBaseQuery
.leftJoin(`LoggedCallTag as ${tableAlias}`, (join) =>
join
.onRef("lc.id", "=", `${tableAlias}.loggedCallId`)
.on(`${tableAlias}.name`, "=", filter.field),
)
.where(
sql.raw(`${tableAlias}.value ${comparatorToSqlValue(filter.comparator, filter.value)}`),
) as unknown as typeof baseQuery;
.where(filterExpression(sql.raw(`${tableAlias}.value`))) as unknown as typeof baseQuery;
}
const rawCalls = await updatedBaseQuery

View File

@@ -1,20 +1,21 @@
import { type SliceCreator } from "./store";
export const comparators = ["=", "!=", "CONTAINS"] as const;
export const comparators = ["=", "!=", "CONTAINS", "NOT_CONTAINS"] as const;
export const defaultFilterableFields = ["Request", "Response", "Model", "Status Code"] as const;
export interface LogFilter {
id: string;
field: string;
comparator: (typeof comparators)[number];
value?: string;
value: string;
}
export type LogFiltersSlice = {
filters: LogFilter[];
addFilter: (filter: LogFilter) => void;
updateFilter: (index: number, filter: LogFilter) => void;
deleteFilter: (index: number) => void;
updateFilter: (filter: LogFilter) => void;
deleteFilter: (id: string) => void;
clearSelectedLogIds: () => void;
};
@@ -24,12 +25,14 @@ export const createLogFiltersSlice: SliceCreator<LogFiltersSlice> = (set, get) =
set((state) => {
state.logFilters.filters.push(filter);
}),
updateFilter: (index: number, filter: LogFilter) =>
updateFilter: (filter: LogFilter) =>
set((state) => {
const index = state.logFilters.filters.findIndex((f) => f.id === filter.id);
state.logFilters.filters[index] = filter;
}),
deleteFilter: (index: number) =>
deleteFilter: (id: string) =>
set((state) => {
const index = state.logFilters.filters.findIndex((f) => f.id === id);
state.logFilters.filters.splice(index, 1);
}),
clearSelectedLogIds: () =>