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 <HStack
as={Button} as={Button}
variant="ghost" 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} spacing={0}
fontSize="sm" fontSize="sm"
> >

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { comparators, type LogFilter } from "~/state/logFiltersSlice";
import { useAppStore } from "~/state/store"; import { useAppStore } from "~/state/store";
import InputDropdown from "~/components/InputDropdown"; 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 updateFilter = useAppStore((s) => s.logFilters.updateFilter);
const { comparator } = filter; const { comparator } = filter;
@@ -11,7 +11,7 @@ const SelectComparatorDropdown = ({ filter, index }: { filter: LogFilter; index:
<InputDropdown <InputDropdown
options={comparators} options={comparators}
selectedOption={comparator} 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 { useTagNames } from "~/utils/hooks";
import InputDropdown from "~/components/InputDropdown"; import InputDropdown from "~/components/InputDropdown";
const SelectFieldDropdown = ({ filter, index }: { filter: LogFilter; index: number }) => { const SelectFieldDropdown = ({ filter }: { filter: LogFilter }) => {
const tagNames = useTagNames().data; const tagNames = useTagNames().data;
const updateFilter = useAppStore((s) => s.logFilters.updateFilter); const updateFilter = useAppStore((s) => s.logFilters.updateFilter);
@@ -14,7 +14,7 @@ const SelectFieldDropdown = ({ filter, index }: { filter: LogFilter; index: numb
<InputDropdown <InputDropdown
options={[...defaultFilterableFields, ...(tagNames || [])]} options={[...defaultFilterableFields, ...(tagNames || [])]}
selectedOption={field} 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 { 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 { jsonArrayFrom } from "kysely/helpers/postgres";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
@@ -8,15 +8,22 @@ import { comparators, defaultFilterableFields } from "~/state/logFiltersSlice";
import { requireCanViewProject } from "~/utils/accessControl"; import { requireCanViewProject } from "~/utils/accessControl";
// create comparator type based off of comparators // create comparator type based off of comparators
const comparatorToSqlValue = (comparator: (typeof comparators)[number], value: string) => { const comparatorToSqlExpression = (comparator: (typeof comparators)[number], value: string) => {
switch (comparator) { return (reference: RawBuilder<unknown>): Expression<SqlBool> => {
case "=": switch (comparator) {
return `= '${value}'`; case "=":
case "!=": return sql`${reference} = ${value}`;
return `!= '${value}'`; case "!=":
case "CONTAINS": // Handle NULL values
return `like '%${value}%'`; 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({ export const loggedCallsRouter = createTRPCRouter({
@@ -30,7 +37,7 @@ export const loggedCallsRouter = createTRPCRouter({
z.object({ z.object({
field: z.string(), field: z.string(),
comparator: z.enum(comparators), 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) { for (const filter of input.filters) {
if (!filter.value) continue; if (!filter.value) continue;
const filterExpression = comparatorToSqlExpression(filter.comparator, filter.value);
if (filter.field === "Request") { if (filter.field === "Request") {
wheres.push( wheres.push(filterExpression(sql.raw(`lcmr."reqPayload"::text`)));
sql.raw(
`lcmr."reqPayload"::text ${comparatorToSqlValue(
filter.comparator,
filter.value,
)}`,
),
);
} }
if (filter.field === "Response") { if (filter.field === "Response") {
wheres.push( wheres.push(filterExpression(sql.raw(`lcmr."respPayload"::text`)));
sql.raw(
`lcmr."respPayload"::text ${comparatorToSqlValue(
filter.comparator,
filter.value,
)}`,
),
);
} }
if (filter.field === "Model") { if (filter.field === "Model") {
wheres.push( wheres.push(filterExpression(sql.raw(`lc."model"`)));
sql.raw(`lc."model" ${comparatorToSqlValue(filter.comparator, filter.value)}`),
);
} }
if (filter.field === "Status Code") { if (filter.field === "Status Code") {
wheres.push( wheres.push(filterExpression(sql.raw(`lcmr."statusCode"::text`)));
sql.raw(
`lcmr."statusCode"::text ${comparatorToSqlValue(
filter.comparator,
filter.value,
)}`,
),
);
} }
} }
@@ -101,15 +87,15 @@ export const loggedCallsRouter = createTRPCRouter({
const filter = tagFilters[i]; const filter = tagFilters[i];
if (!filter?.value) continue; if (!filter?.value) continue;
const tableAlias = `lct${i}`; const tableAlias = `lct${i}`;
const filterExpression = comparatorToSqlExpression(filter.comparator, filter.value);
updatedBaseQuery = updatedBaseQuery updatedBaseQuery = updatedBaseQuery
.leftJoin(`LoggedCallTag as ${tableAlias}`, (join) => .leftJoin(`LoggedCallTag as ${tableAlias}`, (join) =>
join join
.onRef("lc.id", "=", `${tableAlias}.loggedCallId`) .onRef("lc.id", "=", `${tableAlias}.loggedCallId`)
.on(`${tableAlias}.name`, "=", filter.field), .on(`${tableAlias}.name`, "=", filter.field),
) )
.where( .where(filterExpression(sql.raw(`${tableAlias}.value`))) as unknown as typeof baseQuery;
sql.raw(`${tableAlias}.value ${comparatorToSqlValue(filter.comparator, filter.value)}`),
) as unknown as typeof baseQuery;
} }
const rawCalls = await updatedBaseQuery const rawCalls = await updatedBaseQuery

View File

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