feat: implement mergeFilters function and enhance query object merging

- Added mergeFilters function to combine filter objects with priority handling.
- Introduced comprehensive tests for mergeFilters in permissions.spec.ts.
- Created query.spec.ts to validate query structure and expression handling.
- Enhanced error messages in query.ts for better debugging and clarity.
This commit is contained in:
dswbx
2025-10-26 21:05:11 +01:00
parent 574b37abcd
commit 0b58cadbd0
4 changed files with 196 additions and 7 deletions

View File

@@ -5,9 +5,10 @@ import { Policy } from "auth/authorize/Policy";
import { Hono } from "hono"; import { Hono } from "hono";
import { getPermissionRoutes, permission } from "auth/middlewares/permission.middleware"; import { getPermissionRoutes, permission } from "auth/middlewares/permission.middleware";
import { auth } from "auth/middlewares/auth.middleware"; import { auth } from "auth/middlewares/auth.middleware";
import { Guard, type GuardConfig } from "auth/authorize/Guard"; import { Guard, mergeFilters, type GuardConfig } from "auth/authorize/Guard";
import { Role, RolePermission } from "auth/authorize/Role"; import { Role, RolePermission } from "auth/authorize/Role";
import { Exception } from "bknd"; import { Exception } from "bknd";
import { convert } from "core/object/query/object-query";
describe("Permission", () => { describe("Permission", () => {
it("works with minimal schema", () => { it("works with minimal schema", () => {
@@ -177,6 +178,46 @@ describe("Guard", () => {
// hence it can be found // hence it can be found
expect(guard.filters(p, {}, { a: 1 }).filter).toEqual({ foo: "bar" }); expect(guard.filters(p, {}, { a: 1 }).filter).toEqual({ foo: "bar" });
}); });
it("merges filters correctly", () => {
expect(mergeFilters({ foo: "bar" }, { baz: "qux" })).toEqual({
foo: { $eq: "bar" },
baz: { $eq: "qux" },
});
expect(mergeFilters({ foo: "bar" }, { baz: { $eq: "qux" } })).toEqual({
foo: { $eq: "bar" },
baz: { $eq: "qux" },
});
expect(mergeFilters({ foo: "bar" }, { foo: "baz" })).toEqual({ foo: { $eq: "baz" } });
expect(mergeFilters({ foo: "bar" }, { foo: { $lt: 1 } })).toEqual({
foo: { $eq: "bar", $lt: 1 },
});
// overwrite base $or with priority
expect(mergeFilters({ $or: { foo: "one" } }, { foo: "bar" })).toEqual({
$or: {
foo: {
$eq: "bar",
},
},
foo: {
$eq: "bar",
},
});
// ignore base $or if priority has different key
expect(mergeFilters({ $or: { other: "one" } }, { foo: "bar" })).toEqual({
$or: {
other: {
$eq: "one",
},
},
foo: {
$eq: "bar",
},
});
});
}); });
describe("permission middleware", () => { describe("permission middleware", () => {

View File

@@ -6,6 +6,7 @@ import type { ServerEnv } from "modules/Controller";
import type { Role } from "./Role"; import type { Role } from "./Role";
import { HttpStatus } from "bknd/utils"; import { HttpStatus } from "bknd/utils";
import type { Policy, PolicySchema } from "./Policy"; import type { Policy, PolicySchema } from "./Policy";
import { convert, type ObjectQuery } from "core/object/query/object-query";
export type GuardUserContext = { export type GuardUserContext = {
role?: string | null; role?: string | null;
@@ -294,7 +295,7 @@ export class Guard {
filter, filter,
policies, policies,
merge: (givenFilter: object | undefined) => { merge: (givenFilter: object | undefined) => {
return mergeObject(givenFilter ?? {}, filter ?? {}); return mergeFilters(givenFilter ?? {}, filter ?? {});
}, },
matches: (subject: object | object[], opts?: { throwOnError?: boolean }) => { matches: (subject: object | object[], opts?: { throwOnError?: boolean }) => {
const subjects = Array.isArray(subject) ? subject : [subject]; const subjects = Array.isArray(subject) ? subject : [subject];
@@ -319,3 +320,22 @@ export class Guard {
}; };
} }
} }
export function mergeFilters(base: ObjectQuery, priority: ObjectQuery) {
const base_converted = convert(base);
const priority_converted = convert(priority);
const merged = mergeObject(base_converted, priority_converted);
// in case priority filter is also contained in base's $and, merge priority in
if ("$or" in base_converted && base_converted.$or) {
const $ors = base_converted.$or as ObjectQuery;
const priority_keys = Object.keys(priority_converted);
for (const key of priority_keys) {
if (key in $ors) {
merged.$or[key] = mergeObject($ors[key], priority_converted[key]);
}
}
}
return merged;
}

View File

@@ -0,0 +1,111 @@
import { describe, expect, test } from "bun:test";
import { makeValidator, exp, Expression, isPrimitive, type Primitive } from "./query";
describe("query", () => {
test("isPrimitive", () => {
expect(isPrimitive(1)).toBe(true);
expect(isPrimitive("1")).toBe(true);
expect(isPrimitive(true)).toBe(true);
expect(isPrimitive(false)).toBe(true);
// not primitives
expect(isPrimitive(null)).toBe(false);
expect(isPrimitive(undefined)).toBe(false);
expect(isPrimitive([])).toBe(false);
expect(isPrimitive({})).toBe(false);
expect(isPrimitive(Symbol("test"))).toBe(false);
expect(isPrimitive(new Date())).toBe(false);
expect(isPrimitive(new Error())).toBe(false);
expect(isPrimitive(new Set())).toBe(false);
expect(isPrimitive(new Map())).toBe(false);
});
test("strict expression creation", () => {
// @ts-expect-error
expect(() => exp()).toThrow();
// @ts-expect-error
expect(() => exp("")).toThrow();
// @ts-expect-error
expect(() => exp("invalid")).toThrow();
// @ts-expect-error
expect(() => exp("$eq")).toThrow();
// @ts-expect-error
expect(() => exp("$eq", 1)).toThrow();
// @ts-expect-error
expect(() => exp("$eq", () => null)).toThrow();
// @ts-expect-error
expect(() => exp("$eq", () => null, 1)).toThrow();
expect(
exp(
"$eq",
() => true,
() => null,
),
).toBeInstanceOf(Expression);
});
test("$eq is required", () => {
expect(() => makeValidator([])).toThrow();
expect(() =>
makeValidator([
exp(
"$valid",
() => true,
() => null,
),
]),
).toThrow();
expect(
makeValidator([
exp(
"$eq",
() => true,
() => null,
),
]),
).toBeDefined();
});
test("validates filter structure", () => {
const validator = makeValidator([
exp(
"$eq",
(v: Primitive) => isPrimitive(v),
(e, a) => e === a,
),
exp(
"$like",
(v: string) => typeof v === "string",
(e, a) => e === a,
),
]);
// @ts-expect-error intentionally typed as union of given expression keys
expect(validator.expressionKeys).toEqual(["$eq", "$like"]);
// @ts-expect-error "$and" is not allowed
expect(() => validator.convert({ $and: {} })).toThrow();
// @ts-expect-error "$or" must be an object
expect(() => validator.convert({ $or: [] })).toThrow();
// @ts-expect-error "invalid" is not a valid expression key
expect(() => validator.convert({ foo: { invalid: "bar" } })).toThrow();
// @ts-expect-error "invalid" is not a valid expression key
expect(() => validator.convert({ foo: { $invalid: "bar" } })).toThrow();
// @ts-expect-error "null" is not a valid value
expect(() => validator.convert({ foo: null })).toThrow();
// @ts-expect-error only primitives are allowed for $eq
expect(() => validator.convert({ foo: { $eq: [] } })).toThrow();
// @ts-expect-error only strings are allowed for $like
expect(() => validator.convert({ foo: { $like: 1 } })).toThrow();
expect(validator.convert({ foo: "bar" })).toEqual({ foo: { $eq: "bar" } });
expect(validator.convert({ foo: { $eq: "bar" } })).toEqual({ foo: { $eq: "bar" } });
expect(validator.convert({ foo: { $like: "bar" } })).toEqual({ foo: { $like: "bar" } });
});
});

View File

@@ -1,5 +1,5 @@
import type { PrimaryFieldType } from "core/config"; import type { PrimaryFieldType } from "core/config";
import { getPath, invariant } from "bknd/utils"; import { getPath, invariant, isPlainObject } from "bknd/utils";
export type Primitive = PrimaryFieldType | string | number | boolean; export type Primitive = PrimaryFieldType | string | number | boolean;
export function isPrimitive(value: any): value is Primitive { export function isPrimitive(value: any): value is Primitive {
@@ -26,6 +26,10 @@ export function exp<const Key, const Expect, CTX = any>(
valid: (v: Expect) => boolean, valid: (v: Expect) => boolean,
validate: (e: Expect, a: unknown, ctx: CTX) => any, validate: (e: Expect, a: unknown, ctx: CTX) => any,
): Expression<Key, Expect, CTX> { ): Expression<Key, Expect, CTX> {
invariant(typeof key === "string", "key must be a string");
invariant(key[0] === "$", "key must start with '$'");
invariant(typeof valid === "function", "valid must be a function");
invariant(typeof validate === "function", "validate must be a function");
return new Expression(key, valid, validate); return new Expression(key, valid, validate);
} }
@@ -85,13 +89,16 @@ function _convert<Exps extends Expressions>(
function validate(key: string, value: any, path: string[] = []) { function validate(key: string, value: any, path: string[] = []) {
const exp = getExpression(expressions, key as any); const exp = getExpression(expressions, key as any);
if (exp.valid(value) === false) { if (exp.valid(value) === false) {
throw new Error(`Invalid value at "${[...path, key].join(".")}": ${value}`); throw new Error(
`Given value at "${[...path, key].join(".")}" is invalid, got "${JSON.stringify(value)}"`,
);
} }
} }
for (const [key, value] of Object.entries($query)) { for (const [key, value] of Object.entries($query)) {
// if $or, convert each value // if $or, convert each value
if (key === "$or") { if (key === "$or") {
invariant(isPlainObject(value), "$or must be an object");
newQuery.$or = _convert(value, expressions, [...path, key]); newQuery.$or = _convert(value, expressions, [...path, key]);
// if primitive, assume $eq // if primitive, assume $eq
@@ -100,7 +107,7 @@ function _convert<Exps extends Expressions>(
newQuery[key] = { $eq: value }; newQuery[key] = { $eq: value };
// if object, check for expressions // if object, check for expressions
} else if (typeof value === "object") { } else if (isPlainObject(value)) {
// when object is given, check if all keys are expressions // when object is given, check if all keys are expressions
const invalid = Object.keys(value).filter( const invalid = Object.keys(value).filter(
(f) => !ExpressionConditionKeys.includes(f as any), (f) => !ExpressionConditionKeys.includes(f as any),
@@ -114,9 +121,13 @@ function _convert<Exps extends Expressions>(
} }
} else { } else {
throw new Error( throw new Error(
`Invalid key(s) at "${key}": ${invalid.join(", ")}. Expected expressions.`, `Invalid key(s) at "${key}": ${invalid.join(", ")}. Expected expression key: ${ExpressionConditionKeys.join(", ")}.`,
); );
} }
} else {
throw new Error(
`Invalid value at "${[...path, key].join(".")}", got "${JSON.stringify(value)}"`,
);
} }
} }
@@ -151,7 +162,9 @@ function _build<Exps extends Expressions>(
throw new Error(`Expression does not exist: "${$op}"`); throw new Error(`Expression does not exist: "${$op}"`);
} }
if (!exp.valid(expected)) { if (!exp.valid(expected)) {
throw new Error(`Invalid expected value at "${[...path, $op].join(".")}": ${expected}`); throw new Error(
`Invalid value at "${[...path, $op].join(".")}", got "${JSON.stringify(expected)}"`,
);
} }
return exp.validate(expected, actual, options.exp_ctx); return exp.validate(expected, actual, options.exp_ctx);
} }
@@ -191,6 +204,10 @@ function _validate(results: ValidationResults): boolean {
} }
export function makeValidator<Exps extends Expressions>(expressions: Exps) { export function makeValidator<Exps extends Expressions>(expressions: Exps) {
if (!expressions.some((e) => e.key === "$eq")) {
throw new Error("'$eq' expression is required");
}
return { return {
convert: (query: FilterQuery<Exps>) => _convert(query, expressions), convert: (query: FilterQuery<Exps>) => _convert(query, expressions),
build: (query: FilterQuery<Exps>, options: BuildOptions) => build: (query: FilterQuery<Exps>, options: BuildOptions) =>