Skip to content

Commit

Permalink
feat(oquery): add matchers for use w/ query()
Browse files Browse the repository at this point in the history
- add matchStrings(), matchPattern(), matchCompare()
- add tests
  • Loading branch information
postspectacular committed Sep 11, 2023
1 parent 4305748 commit 20e1baf
Show file tree
Hide file tree
Showing 2 changed files with 238 additions and 0 deletions.
169 changes: 169 additions & 0 deletions packages/oquery/src/match.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import type { NumOrString, Predicate, TypedKeys } from "@thi.ng/api";
import { isNumber } from "@thi.ng/checks/is-number";
import { isString } from "@thi.ng/checks/is-string";
import { numericOp, stringOp, type Operator } from "@thi.ng/compare";
import type { QueryObj, QueryOpts, QueryTerm } from "./api.js";

/**
* Returns a {@link QueryTerm} for use with {@link query} to perform tag-like
* intersection or union queries with optional negation/exclusions. Matches set
* of given strings against an item's chosen field of strings and by default
* only succeeds if all provided strings can be matched.
*
* @remarks
* If `union` is true, only one of the provided strings needs to match. Any
* provided string prefixed with `!` is treated as an exclusion and any item
* which contains any of these exclusions is automatically rejected, even if
* other strings could be matched. Exclusions _always_ take precedence.
*
* Note: This matcher can only be used for item keys of type `string[]`.
*
* @example
* ```ts
* const DB = [
* { id: 1, tags: ["a", "b"] },
* { id: 2, tags: ["c", "b"] },
* { id: 3, tags: ["c", "a"] },
* ];
*
* // tag intersection
* query(DB, [matchStrings("tags", ["a", "b"])])
* // [ { id: 1, tags: ["a", "b"] } ]
*
* // tag union
* query(DB, [matchStrings("tags", ["a", "b"])])
* // here returns full DB...
* // since each item either has `a` and/or `b` tags
*
* // tag exclusion (require `a`, disallow `b`)
* query(DB, [matchStrings("tags", ["a", "!b"])])
* // [ { id: 3, tags: ["c", "a"] } ]
* ```
*
* @param key
* @param opts
* @param union
*/
export const matchStrings = <T extends QueryObj = QueryObj>(
key: TypedKeys<T, string[]>,
opts: string[],
union = false
): QueryTerm<T> => {
const [positives, negatives] = opts.reduce(
(acc, x) => {
x[0] === "!" ? acc[1].push(x.substring(1)) : acc[0].push(x);
return acc;
},
<string[][]>[[], []]
);
if (!negatives.length) {
return { q: [key, positives], opts: { intersect: !union } };
}
return {
q: [
key,
(values: string[]) => {
for (let x of negatives) {
if (values.includes(x)) return false;
}
let match = false;
for (let x of positives) {
if (values.includes(x)) {
match = true;
if (union) break;
} else if (!union) {
match = false;
break;
}
}
return match;
},
],
opts: { cwise: false },
};
};

/**
* Returns a {@link QueryTerm} to match a key's value against a regexp or string
* expression.
*
* @remarks
* If `expr` is a regexp it will be used as is, but if given a string the
* following rules apply:
*
* - if `expr` is the sole `*` it will match any non-null value
* - if `expr` starts with `=`, `!=`, `<`, `<=`, `>=` or `>`, values will be
* matched using comparators. If the following chars in `expr` are numeric,
* the comparisons will be done as numbers otherwise as strings. Whitespace
* between operator and value are ignored.
*
* @example
* ```ts
* const DB = [
* { id: "aaa", score: 32 },
* { id: "bbbb", score: 60 },
* { id: "c", score: 15 },
* ];
*
* query(DB, [matchPattern("id", /[a-z]{4,}/)]);
* // [{ id: "bbbb", score: 60 }]
* query(DB, [matchPattern("id", ">= c")]);
* // [{ id: "c", score: 15 }]
*
* query(DB, [matchPattern("score", "<50")]);
* // [{ id: "a", score: 32 }, { id: "c", score: 15 }]
* ```
*
* @param key
* @param expr
* @param opts
*/
export const matchPattern = <T extends QueryObj = QueryObj>(
key: QueryTerm["q"][0],
expr: string | RegExp,
opts?: Partial<QueryOpts>
): QueryTerm<T> => {
let re: RegExp;
if (expr instanceof RegExp) {
re = expr;
} else {
if (expr === "*") return { q: [key, (x: any) => x != null], opts };
if (/^[<>=!]/.test(expr)) {
const op = /^[<>=!]+/.exec(expr)![0];
const arg = expr.substring(op.length).trim();
const argN = parseFloat(arg);
return matchCompare(
key,
<Operator>op,
isNaN(argN) ? arg : argN,
opts
);
}
re = new RegExp(expr, "i");
}
return {
q: [
key,
(x: any) => (isString(x) || isNumber(x)) && re.test(String(x)),
],
opts,
};
};

/**
* Same as the comparison expression case of {@link matchPattern}, only
* accepting different args.
*
* @param key
* @param match
* @param opts
*/
export const matchCompare = <T extends QueryObj = QueryObj>(
key: QueryTerm["q"][0],
op: Operator | Predicate<any>,
arg: NumOrString,
opts?: Partial<QueryOpts>
): QueryTerm<T> => ({
q: [key, isNumber(arg) ? numericOp(op, arg) : stringOp(op, arg)],
opts,
});
69 changes: 69 additions & 0 deletions packages/oquery/test/match.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { group } from "@thi.ng/testament";
import * as assert from "assert";
import { matchPattern, matchStrings, query } from "../src/index.js";

const DB = [
{ id: 0, tags: ["a", "b"] },
{ id: 1, tags: ["c", "b"] },
{ id: 2, tags: ["c", "a"] },
{ id: 3, tags: ["a", "aaa"], extra: "yes" },
{ id: 4, tags: ["c"] },
];

group("oquery matchers", {
matchStrings: () => {
assert.deepStrictEqual(query(DB, [matchStrings("tags", ["a"])]), [
DB[0],
DB[2],
DB[3],
]);
assert.deepStrictEqual(query(DB, [matchStrings("tags", ["a", "b"])]), [
DB[0],
]);
assert.deepStrictEqual(
query(DB, [matchStrings("tags", ["a", "b"], true)]),
[DB[0], DB[1], DB[2], DB[3]]
);
assert.deepStrictEqual(query(DB, [matchStrings("tags", ["a", "!b"])]), [
DB[2],
DB[3],
]);
assert.deepStrictEqual(
query(DB, [matchStrings("tags", ["a", "!b", "c"])]),
[DB[2]]
);
assert.deepStrictEqual(
query(DB, [matchStrings("tags", ["a", "!b", "c"], true)]),
[DB[2], DB[3], DB[4]]
);
assert.deepStrictEqual(
query(DB, [matchStrings("tags", ["c", "d"])]),
[]
);
assert.deepStrictEqual(
query(DB, [matchStrings("tags", ["c", "d"], true)]),
[DB[1], DB[2], DB[4]]
);
},

matchPattern: () => {
assert.deepStrictEqual(query(DB, [matchPattern("extra", "*")]), [
DB[3],
]);
assert.deepStrictEqual(query(DB, [matchPattern("id", "=2")]), [DB[2]]);
assert.deepStrictEqual(query(DB, [matchPattern("id", ">2")]), [
DB[3],
DB[4],
]);
},

combined: () => {
assert.deepStrictEqual(
query(DB, [
matchStrings("tags", ["a"]),
matchPattern("tags", "a{2,}"),
]),
[DB[3]]
);
},
});

0 comments on commit 20e1baf

Please sign in to comment.