EventFilter now supports more complex condition filters #271

This commit is contained in:
Eric Allam
2023-08-08 15:01:40 +01:00
parent fa942fc9f5
commit 59075f5fc2
12 changed files with 1731 additions and 33 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
EventFilter now supports more complex condition filters #271
@@ -1,6 +1,6 @@
import type { EventDispatcher, EventRecord } from "@trigger.dev/database";
import type { EventFilter } from "@trigger.dev/core";
import { EventFilterSchema } from "@trigger.dev/core";
import { EventFilterSchema, eventFilterMatches } from "@trigger.dev/core";
import { $transaction, PrismaClientOrTransaction, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { workerQueue } from "../worker.server";
@@ -124,29 +124,6 @@ export class EventMatcher {
}
public matches(filter: EventFilter) {
return patternMatches(this.event, filter);
return eventFilterMatches(this.event, filter);
}
}
function patternMatches(payload: any, pattern: any): boolean {
for (const [patternKey, patternValue] of Object.entries(pattern)) {
const payloadValue = payload[patternKey];
if (Array.isArray(patternValue)) {
if (patternValue.length > 0 && !patternValue.includes(payloadValue)) {
return false;
}
} else if (typeof patternValue === "object") {
if (Array.isArray(payloadValue)) {
if (!payloadValue.some((item) => patternMatches(item, patternValue))) {
return false;
}
} else {
if (!patternMatches(payloadValue, patternValue)) {
return false;
}
}
}
}
return true;
}
+8
View File
@@ -0,0 +1,8 @@
module.exports = {
moduleFileExtensions: ["ts", "tsx", "js"],
transform: {
"^.+\\.(ts|tsx)$": "ts-jest",
},
testMatch: ["<rootDir>/test/**/*.ts?(x)", "<rootDir>/test/**/?(*.)+(spec|test).ts?(x)"],
testEnvironment: "node",
};
+6 -2
View File
@@ -22,7 +22,8 @@
"clean": "rimraf dist",
"build": "npm run clean && npm run build:tsup",
"build:tsup": "tsup --dts-resolve",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "jest"
},
"dependencies": {
"ulid": "^2.3.0",
@@ -31,12 +32,15 @@
},
"devDependencies": {
"@trigger.dev/tsconfig": "workspace:*",
"@types/jest": "^29.5.3",
"@types/node": "16",
"jest": "^29.6.2",
"rimraf": "^3.0.2",
"ts-jest": "^29.1.1",
"tsup": "^7.1.0",
"typescript": "^4.9.4"
},
"engines": {
"node": ">=16.8.0"
}
}
}
+186
View File
@@ -0,0 +1,186 @@
import { EventFilter } from "./schemas/eventFilter";
// EventFilter is a recursive type, where the keys are strings and the values are an array of strings, numbers, booleans, or objects.
// If the values of the array are strings, numbers, or booleans, than we are matching against the value of the payload.
// If the values of the array are objects, then we are doing content filtering
// An example would be [{ $endsWith: ".png" }, { $startsWith: "images/" } ]
export function eventFilterMatches(payload: any, filter: EventFilter): boolean {
for (const [patternKey, patternValue] of Object.entries(filter)) {
const payloadValue = payload[patternKey];
if (Array.isArray(patternValue)) {
if (patternValue.length === 0) {
continue;
}
// Check to see if all the items in the array are a string
if ((patternValue as unknown[]).every((item) => typeof item === "string")) {
if ((patternValue as string[]).includes(payloadValue)) {
continue;
}
return false;
}
// Check to see if all the items in the array are a number
if ((patternValue as unknown[]).every((item) => typeof item === "number")) {
if ((patternValue as number[]).includes(payloadValue)) {
continue;
}
return false;
}
// Check to see if all the items in the array are a boolean
if ((patternValue as unknown[]).every((item) => typeof item === "boolean")) {
if ((patternValue as boolean[]).includes(payloadValue)) {
continue;
}
return false;
}
// Now we know that all the items in the array are objects
const objectArray = patternValue as Exclude<
typeof patternValue,
number[] | string[] | boolean[]
>;
if (!contentFiltersMatches(payloadValue, objectArray)) {
return false;
}
continue;
} else if (typeof patternValue === "object") {
if (Array.isArray(payloadValue)) {
if (!payloadValue.some((item) => eventFilterMatches(item, patternValue))) {
return false;
}
} else {
if (!eventFilterMatches(payloadValue, patternValue)) {
return false;
}
}
}
}
return true;
}
type ContentFilters = Exclude<EventFilter[string], EventFilter | string[] | number[] | boolean[]>;
function contentFiltersMatches(actualValue: any, contentFilters: ContentFilters): boolean {
for (const contentFilter of contentFilters) {
if (typeof contentFilter === "object") {
const [key, value] = Object.entries(contentFilter)[0];
if (!contentFilterMatches(actualValue, contentFilter)) {
return false;
}
}
}
return true;
}
function contentFilterMatches(actualValue: any, contentFilter: ContentFilters[number]): boolean {
if ("$endsWith" in contentFilter) {
if (typeof actualValue !== "string") {
return false;
}
return actualValue.endsWith(contentFilter.$endsWith);
}
if ("$startsWith" in contentFilter) {
if (typeof actualValue !== "string") {
return false;
}
return actualValue.startsWith(contentFilter.$startsWith);
}
if ("$anythingBut" in contentFilter) {
if (Array.isArray(contentFilter.$anythingBut)) {
if ((contentFilter.$anythingBut as any[]).includes(actualValue)) {
return false;
}
}
if (contentFilter.$anythingBut === actualValue) {
return false;
}
return true;
}
if ("$exists" in contentFilter) {
if (contentFilter.$exists) {
return actualValue !== undefined;
}
return actualValue === undefined;
}
if ("$gt" in contentFilter) {
if (typeof actualValue !== "number") {
return false;
}
return actualValue > contentFilter.$gt;
}
if ("$lt" in contentFilter) {
if (typeof actualValue !== "number") {
return false;
}
return actualValue < contentFilter.$lt;
}
if ("$gte" in contentFilter) {
if (typeof actualValue !== "number") {
return false;
}
return actualValue >= contentFilter.$gte;
}
if ("$lte" in contentFilter) {
if (typeof actualValue !== "number") {
return false;
}
return actualValue <= contentFilter.$lte;
}
if ("$between" in contentFilter) {
if (typeof actualValue !== "number") {
return false;
}
return actualValue >= contentFilter.$between[0] && actualValue <= contentFilter.$between[1];
}
if ("$includes" in contentFilter) {
if (Array.isArray(actualValue)) {
return actualValue.includes(contentFilter.$includes);
}
return false;
}
// Use localCompare
if ("$ignoreCaseEquals" in contentFilter) {
if (typeof actualValue !== "string") {
return false;
}
return (
actualValue.localeCompare(contentFilter.$ignoreCaseEquals, undefined, {
sensitivity: "accent",
}) === 0
);
}
return true;
}
+1
View File
@@ -5,3 +5,4 @@ export * from "./utils";
export * from "./retry";
export * from "./replacements";
export * from "./searchParams";
export * from "./eventFilterMatches";
+40
View File
@@ -7,6 +7,46 @@ const EventMatcherSchema = z.union([
z.array(z.number()),
/** Match against a boolean */
z.array(z.boolean()),
z.array(
z.union([
z.object({
$endsWith: z.string(),
}),
z.object({
$startsWith: z.string(),
}),
z.object({
$exists: z.boolean(),
}),
z.object({
$anythingBut: z.union([z.string(), z.number(), z.boolean()]),
}),
z.object({
$anythingBut: z.union([z.array(z.string()), z.array(z.number()), z.array(z.boolean())]),
}),
z.object({
$gt: z.number(),
}),
z.object({
$lt: z.number(),
}),
z.object({
$gte: z.number(),
}),
z.object({
$lte: z.number(),
}),
z.object({
$between: z.tuple([z.number(), z.number()]),
}),
z.object({
$includes: z.union([z.string(), z.number(), z.boolean()]),
}),
z.object({
$ignoreCaseEquals: z.string(),
}),
])
),
]);
type EventMatcher = z.infer<typeof EventMatcherSchema>;
@@ -0,0 +1,522 @@
import { EventFilter } from "../src";
import { eventFilterMatches } from "../src/eventFilterMatches";
describe("eventFilterMatches", () => {
it("should return true when payload matches string filter", () => {
const payload = {
name: "John",
age: 30,
isAdmin: false,
hobbies: ["reading", "swimming"],
address: {
street: "123 Main St",
city: "Anytown",
state: "CA",
zip: "12345",
},
};
const filter: EventFilter = {
name: ["John"],
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});
it("should return true when payload matches boolean filter", () => {
const payload = {
name: "John",
age: 30,
isAdmin: false,
hobbies: ["reading", "swimming"],
address: {
street: "123 Main St",
city: "Anytown",
state: "CA",
zip: "12345",
},
};
const filter: EventFilter = {
isAdmin: [false],
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});
it("should return true when payload matches number filter", () => {
const payload = {
name: "John",
age: 30,
isAdmin: false,
hobbies: ["reading", "swimming"],
address: {
street: "123 Main St",
city: "Anytown",
state: "CA",
zip: "12345",
},
};
const filter: EventFilter = {
age: [30],
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});
it("should return true when payload matches $startsWith content filter", () => {
const payload = {
name: "John",
age: 30,
isAdmin: false,
hobbies: ["reading", "swimming"],
address: {
street: "123 Main St",
city: "Anytown",
state: "CA",
zip: "12345",
},
};
const filter: EventFilter = {
name: [{ $startsWith: "Jo" }],
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});
it("should return true when payload matches $endsWith content filter", () => {
const payload = {
name: "John",
age: 30,
isAdmin: false,
hobbies: ["reading", "swimming"],
address: {
street: "123 Main St",
city: "Anytown",
state: "CA",
zip: "12345",
},
};
const filter: EventFilter = {
name: [{ $endsWith: "hn" }],
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});
it("should return true when payload matches $startsWith and $endsWith content filters", () => {
const payload = {
name: "John",
age: 30,
isAdmin: false,
hobbies: ["reading", "swimming"],
address: {
street: "123 Main St",
city: "Anytown",
state: "CA",
zip: "12345",
},
};
const filter: EventFilter = {
name: [{ $startsWith: "Jo" }, { $endsWith: "hn" }],
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});
it("should return true when payload does not match $anythingBut filter", () => {
const payload = {
name: "John",
age: 30,
isAdmin: false,
hobbies: ["reading", "swimming"],
address: {
street: "123 Main St",
city: "Anytown",
state: "CA",
zip: "12345",
},
};
const filter: EventFilter = {
name: [{ $anythingBut: "Jane" }],
address: {
street: [{ $anythingBut: "456 Elm St" }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});
it("should return true when payload does not match $anythingBut filter with an array", () => {
const payload = {
name: "John",
age: 30,
isAdmin: false,
hobbies: ["reading", "swimming"],
address: {
street: "123 Main St",
city: "Anytown",
state: "CA",
zip: "12345",
},
};
const filter: EventFilter = {
name: [{ $anythingBut: ["Jane", "Joe"] }],
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});
it("should return true when payload does have a key that $exists = true", () => {
const payload = {
name: "John",
age: 30,
isAdmin: false,
hobbies: ["reading", "swimming"],
address: {
street: "123 Main St",
city: "Anytown",
state: "CA",
zip: "12345",
},
};
const filter: EventFilter = {
name: [{ $exists: true }],
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});
it("should return true when payload does NOT have a key that $exists = false", () => {
const payload = {
name: "John",
age: 30,
isAdmin: false,
hobbies: ["reading", "swimming"],
address: {
street: "123 Main St",
city: "Anytown",
state: "CA",
zip: "12345",
},
};
const filter: EventFilter = {
foo: [{ $exists: false }],
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});
it("should return true when payload does match numeric condition", () => {
const payload = {
name: "John",
age: 30,
score: 100,
isAdmin: false,
hobbies: ["reading", "swimming"],
address: {
street: "123 Main St",
city: "Anytown",
state: "CA",
zip: "12345",
},
};
const filter: EventFilter = {
age: [{ $gt: 20 }, { $lt: 40 }],
score: [{ $between: [90, 110] }],
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});
it("should return true when payload matches an includes condition", () => {
const payload = {
name: "John",
age: 30,
score: 100,
isAdmin: false,
hobbies: ["reading", "swimming"],
address: {
street: "123 Main St",
city: "Anytown",
state: "CA",
zip: "12345",
},
};
const filter: EventFilter = {
hobbies: [{ $includes: "reading" }],
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});
it("should return true when payload matches an ignoreCaseEquals condition", () => {
const payload = {
name: "John",
age: 30,
score: 100,
isAdmin: false,
hobbies: ["reading", "swimming"],
address: {
street: "123 Main St",
city: "Anytown",
state: "CA",
zip: "12345",
},
};
const filter: EventFilter = {
name: [{ $ignoreCaseEquals: "john" }],
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});
it("should return false when payload does not match string filter", () => {
const payload = {
name: "Jane",
age: 25,
hobbies: ["running", "yoga"],
address: {
street: "456 Elm St",
city: "Othertown",
state: "NY",
zip: "67890",
},
};
const filter: EventFilter = {
name: ["John"],
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});
it("should return false when payload does not match string filter because it's the wrong type", () => {
const payload = {
name: "Jane",
age: 25,
hobbies: ["running", "yoga"],
address: {
street: "456 Elm St",
city: "Othertown",
state: "NY",
zip: "67890",
},
};
const filter: EventFilter = {
age: ["John"],
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});
it("should return false when payload does not match boolean filter", () => {
const payload = {
name: "Jane",
age: 25,
isAdmin: true,
hobbies: ["running", "yoga"],
address: {
street: "456 Elm St",
city: "Othertown",
state: "NY",
zip: "67890",
},
};
const filter: EventFilter = {
isAdmin: [false],
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});
it("should return false when payload does not match number filter", () => {
const payload = {
name: "Jane",
age: 25,
isAdmin: true,
hobbies: ["running", "yoga"],
address: {
street: "456 Elm St",
city: "Othertown",
state: "NY",
zip: "67890",
},
};
const filter: EventFilter = {
age: [30],
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});
it("should return false when payload does not match $startsWith content filter", () => {
const payload = {
name: "Jane",
age: 25,
isAdmin: true,
hobbies: ["running", "yoga"],
address: {
street: "456 Elm St",
city: "Othertown",
state: "NY",
zip: "67890",
},
};
const filter: EventFilter = {
name: [{ $startsWith: "Jo" }],
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});
it("should return false when payload does not match $endsWith content filter", () => {
const payload = {
name: "Jane",
age: 25,
isAdmin: true,
hobbies: ["running", "yoga"],
address: {
street: "456 Elm St",
city: "Othertown",
state: "NY",
zip: "67890",
},
};
const filter: EventFilter = {
name: [{ $startsWith: "Ja" }, { $endsWith: "hn" }],
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});
it("should return false when payload does match $anythingBut content filters", () => {
const payload = {
name: "Jane",
age: 25,
isAdmin: true,
hobbies: ["running", "yoga"],
address: {
street: "456 Elm St",
city: "Othertown",
state: "NY",
zip: "67890",
},
};
const filter: EventFilter = {
name: [{ $anythingBut: "Jane" }],
address: {
street: [{ $anythingBut: "456 Elm St" }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});
it("should return false when payload does match $anythingBut content filters with an array", () => {
const payload = {
name: "Jane",
age: 25,
isAdmin: true,
hobbies: ["running", "yoga"],
address: {
street: "456 Elm St",
city: "Othertown",
state: "NY",
zip: "67890",
},
};
const filter: EventFilter = {
name: [{ $anythingBut: ["Jane", "John"] }],
address: {
street: [{ $anythingBut: "456 Elm St" }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});
it("should return false when payload does not have a key that $exists = true", () => {
const payload = {
name: "Jane",
age: 25,
isAdmin: true,
hobbies: ["running", "yoga"],
address: {
street: "456 Elm St",
city: "Othertown",
state: "NY",
zip: "67890",
},
};
const filter: EventFilter = {
foo: [{ $exists: true }],
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});
it("should return false when payload does have a key that $exists = false", () => {
const payload = {
name: "Jane",
age: 25,
score: 100,
isAdmin: true,
hobbies: ["running", "yoga"],
address: {
street: "456 Elm St",
city: "Othertown",
state: "NY",
zip: "67890",
},
};
const filter: EventFilter = {
name: [{ $exists: false }],
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});
it("should return false when the payload does not match the numeric filters", () => {
const payload = {
name: "Jane",
age: 25,
score: 100,
isAdmin: true,
hobbies: ["running", "yoga"],
address: {
street: "456 Elm St",
city: "Othertown",
state: "NY",
zip: "67890",
latitude: 37.7749,
longitude: 122.4194,
},
};
const filter: EventFilter = {
age: [{ $gt: 30 }],
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});
it("Should return false when the payload does not match an includes filter", () => {
const payload = {
name: "Jane",
age: 25,
score: 100,
isAdmin: true,
hobbies: ["running", "yoga"],
address: {
street: "456 Elm St",
city: "San Francisco",
state: "CA",
zip: "67890",
latitude: 37.7749,
longitude: 122.4194,
},
};
const filter: EventFilter = {
hobbies: [{ $includes: "swimming" }],
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});
it("Should return false when the payload does not match any ignoreCaseEquals condition", () => {
const payload = {
name: "Jane",
age: 25,
score: 100,
isAdmin: true,
hobbies: ["running", "yoga"],
address: {
street: "456 Elm St",
city: "San Francisco",
state: "CA",
zip: "67890",
latitude: 37.7749,
longitude: 122.4194,
},
};
const filter: EventFilter = {
name: [{ $ignoreCaseEquals: "john" }],
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});
});
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "@trigger.dev/tsconfig/node18.json",
"include": ["src/globals.d.ts", "./src/**/*.ts", "tsup.config.ts"],
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"declaration": false,
"declarationMap": false
},
"exclude": ["node_modules"]
}
+3 -2
View File
@@ -1,11 +1,12 @@
{
"extends": "@trigger.dev/tsconfig/node18.json",
"include": ["src/globals.d.ts", "./src/**/*.ts", "tsup.config.ts"],
"include": ["src/globals.d.ts", "./src/**/*.ts", "tsup.config.ts", "./test/**/*.ts"],
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"declaration": false,
"declarationMap": false
"declarationMap": false,
"types": ["jest"]
},
"exclude": ["node_modules"]
}
+1
View File
@@ -3,6 +3,7 @@ import { defineConfig } from "tsup";
export default defineConfig([
{
name: "main",
config: "tsconfig.build.json",
entry: ["./src/index.ts"],
outDir: "./dist",
platform: "neutral",
+946 -4
View File
File diff suppressed because it is too large Load Diff