Complete redo of content indexing and querying (#517)

Complete redo of data store
Introduces live queries and live templates
This commit is contained in:
Zef Hemel
2023-10-03 14:16:33 +02:00
committed by GitHub
parent 7af98e7c7b
commit 0313565610
200 changed files with 4675 additions and 4363 deletions
+39
View File
@@ -0,0 +1,39 @@
import type { FunctionMap } from "$sb/types.ts";
import { niceDate } from "$sb/lib/dates.ts";
export const builtinFunctions: FunctionMap = {
today() {
return niceDate(new Date());
},
max(...args: number[]) {
return Math.max(...args);
},
min(...args: number[]) {
return Math.min(...args);
},
toJSON(obj: any) {
return JSON.stringify(obj);
},
// Note: these assume Monday as the first day of the week
firstDayOfWeek(dateString: string): string {
const date = new Date(dateString);
const dayOfWeek = date.getDay();
const daysToSubtract = (dayOfWeek + 7 - 1) % 7;
const firstDayOfWeek = new Date(date);
firstDayOfWeek.setDate(date.getDate() - daysToSubtract);
return niceDate(firstDayOfWeek);
},
lastDayOfWeek(dateString: string): string {
const date = new Date(dateString);
const dayOfWeek = date.getDay();
const daysToAdd = (7 - dayOfWeek) % 7;
const lastDayOfWeek = new Date(date);
lastDayOfWeek.setDate(date.getDate() + daysToAdd);
return niceDate(lastDayOfWeek);
},
addDays(dateString: string, daysToAdd: number): string {
const date = new Date(dateString);
date.setDate(date.getDate() + daysToAdd);
return niceDate(date);
},
};
+11 -45
View File
@@ -2,7 +2,7 @@ import { YAML } from "$sb/plugos-syscall/mod.ts";
import {
addParentPointers,
findNodeOfType,
collectNodesOfType,
ParseTree,
renderToText,
replaceNodesMatchingAsync,
@@ -18,21 +18,24 @@ export async function extractFrontmatter(
): Promise<any> {
let data: any = {};
addParentPointers(tree);
let paragraphCounter = 0;
await replaceNodesMatchingAsync(tree, async (t) => {
// Find top-level hash tags
if (t.type === "Hashtag") {
// Check if if nested directly into a Paragraph
if (t.parent && t.parent.type === "Paragraph") {
const tagname = t.children![0].text!.substring(1);
if (t.type === "Paragraph") {
paragraphCounter++;
// Only attach hashtags in the first paragraph to the page
if (paragraphCounter !== 1) {
return;
}
collectNodesOfType(t, "Hashtag").forEach((h) => {
if (!data.tags) {
data.tags = [];
}
const tagname = h.children![0].text!.substring(1);
if (Array.isArray(data.tags) && !data.tags.includes(tagname)) {
data.tags.push(tagname);
}
}
return;
});
}
// Find FrontMatter and parse it
if (t.type === "FrontMatter") {
@@ -64,43 +67,6 @@ export async function extractFrontmatter(
}
}
// Find a fenced code block with `meta` as the language type
if (t.type !== "FencedCode") {
return;
}
const codeInfoNode = findNodeOfType(t, "CodeInfo");
if (!codeInfoNode) {
return;
}
if (codeInfoNode.children![0].text !== "meta") {
return;
}
const codeTextNode = findNodeOfType(t, "CodeText");
if (!codeTextNode) {
// Honestly, this shouldn't happen
return;
}
const codeText = codeTextNode.children![0].text!;
const parsedData: any = YAML.parse(codeText);
const newData = { ...parsedData };
data = { ...data, ...parsedData };
if (removeKeys.length > 0) {
let removedOne = false;
for (const key of removeKeys) {
if (key in newData) {
delete newData[key];
removedOne = true;
}
}
if (removedOne) {
codeTextNode.children![0].text = (await YAML.stringify(newData)).trim();
}
}
// If nothing is left, let's just delete this whole block
if (Object.keys(newData).length === 0) {
return null;
}
return undefined;
});
+180
View File
@@ -0,0 +1,180 @@
import type { AST } from "$sb/lib/tree.ts";
import type { Query, QueryExpression } from "$sb/types.ts";
export function astToKvQuery(
node: AST,
): Query {
const query: Query = {
querySource: "",
};
const [queryType, querySource, ...clauses] = node;
if (queryType !== "Query") {
throw new Error(`Expected query type, got ${queryType}`);
}
query.querySource = querySource[1] as string;
for (const clause of clauses) {
const [clauseType] = clause;
switch (clauseType) {
case "WhereClause": {
if (query.filter) {
query.filter = [
"and",
query.filter,
expressionToKvQueryFilter(clause[2]),
];
} else {
query.filter = expressionToKvQueryFilter(clause[2]);
}
break;
}
case "OrderClause": {
if (!query.orderBy) {
query.orderBy = [];
}
for (const orderBy of clause.slice(2)) {
if (orderBy[0] === "OrderBy") {
// console.log("orderBy", orderBy);
const expr = orderBy[1][1];
if (orderBy[2]) {
query.orderBy.push({
expr: expressionToKvQueryExpression(expr),
desc: orderBy[2][1][1] === "desc",
});
} else {
query.orderBy.push({
expr: expressionToKvQueryExpression(expr),
desc: false,
});
}
}
}
break;
}
case "LimitClause": {
query.limit = expressionToKvQueryExpression(clause[2][1]);
break;
}
case "SelectClause": {
for (const select of clause.slice(2)) {
if (select[0] === "Select") {
if (!query.select) {
query.select = [];
}
if (select.length === 2) {
query.select.push({ name: select[1][1] as string });
} else {
query.select.push({
name: select[3][1] as string,
expr: expressionToKvQueryExpression(select[1]),
});
}
}
}
break;
}
case "RenderClause": {
query.render = (clause[2][1] as string).slice(2, -2);
break;
}
default:
throw new Error(`Unknown clause type: ${clauseType}`);
}
}
return query;
}
export function expressionToKvQueryExpression(node: AST): QueryExpression {
if (["LVal", "Expression", "Value"].includes(node[0])) {
return expressionToKvQueryExpression(node[1]);
}
// console.log("Got expression", node);
switch (node[0]) {
case "Attribute": {
return [
"attr",
expressionToKvQueryExpression(node[1]),
node[3][1] as string,
];
}
case "Identifier":
return ["attr", node[1] as string];
case "String":
return ["string", (node[1] as string).slice(1, -1)];
case "Number":
return ["number", +(node[1])];
case "Bool":
return ["boolean", node[1][1] === "true"];
case "null":
return ["null"];
case "Regex":
return ["regexp", (node[1] as string).slice(1, -1), "i"];
case "List": {
const exprs: AST[] = [];
for (const expr of node.slice(2)) {
if (expr[0] === "Expression") {
exprs.push(expr);
}
}
return ["array", exprs.map(expressionToKvQueryExpression)];
}
case "BinExpression": {
const lval = expressionToKvQueryExpression(node[1]);
const binOp = (node[2] as string).trim();
const val = expressionToKvQueryExpression(node[3]);
return [binOp as any, lval, val];
}
case "LogicalExpression": {
const op1 = expressionToKvQueryFilter(node[1]);
const op = node[2];
const op2 = expressionToKvQueryFilter(node[3]);
return [op[1] as any, op1, op2];
}
case "ParenthesizedExpression": {
return expressionToKvQueryFilter(node[2]);
}
case "Call": {
// console.log("Call", node);
const fn = node[1][1] as string;
const args: AST[] = [];
for (const expr of node.slice(2)) {
if (expr[0] === "Expression") {
args.push(expr);
}
}
return ["call", fn, args.map(expressionToKvQueryExpression)];
}
default:
throw new Error(`Not supported: ${node[0]}`);
}
}
function expressionToKvQueryFilter(
node: AST,
): QueryExpression {
const [expressionType] = node;
if (expressionType === "Expression") {
return expressionToKvQueryFilter(node[1]);
}
switch (expressionType) {
case "BinExpression": {
const lval = expressionToKvQueryExpression(node[1]);
const binOp = node[2][0] === "InKW" ? "in" : (node[2] as string).trim();
const val = expressionToKvQueryExpression(node[3]);
return [binOp as any, lval, val];
}
case "LogicalExpression": {
// console.log("Logical expression", node);
// 0 = first operand, 1 = whitespace, 2 = operator, 3 = whitespace, 4 = second operand
const op1 = expressionToKvQueryFilter(node[1]);
const op = node[2]; // 1 is whitespace
const op2 = expressionToKvQueryFilter(node[3]);
return [op[1] as any, op1, op2];
}
case "ParenthesizedExpression": {
return expressionToKvQueryFilter(node[2]);
}
default:
throw new Error(`Unknown expression type: ${expressionType}`);
}
}
+193
View File
@@ -0,0 +1,193 @@
import { parse } from "../../common/markdown_parser/parse_tree.ts";
import buildMarkdown from "../../common/markdown_parser/parser.ts";
import { AST, findNodeOfType, parseTreeToAST } from "$sb/lib/tree.ts";
import { assertEquals } from "../../test_deps.ts";
import { astToKvQuery } from "$sb/lib/parse-query.ts";
const lang = buildMarkdown([]);
function wrapQueryParse(query: string): AST | null {
const tree = parse(lang, `<!-- #query ${query} -->\n$\n<!-- /query -->`);
return parseTreeToAST(findNodeOfType(tree, "Query")!);
}
Deno.test("Test directive parser", () => {
// const query = ;
// console.log("query", query);
assertEquals(
astToKvQuery(wrapQueryParse(`page where name = "test"`)!),
{
querySource: "page",
filter: ["=", ["attr", "name"], ["string", "test"]],
},
);
assertEquals(
astToKvQuery(wrapQueryParse(`page where name =~ /test/`)!),
{
querySource: "page",
filter: ["=~", ["attr", "name"], ["regexp", "test", "i"]],
},
);
assertEquals(
astToKvQuery(wrapQueryParse(`page where parent.name = "test"`)!),
{
querySource: "page",
filter: ["=", ["attr", ["attr", "parent"], "name"], ["string", "test"]],
},
);
assertEquals(
astToKvQuery(
wrapQueryParse(`page where name = "test" and age > 20`)!,
),
{
querySource: "page",
filter: ["and", ["=", ["attr", "name"], ["string", "test"]], [">", [
"attr",
"age",
], ["number", 20]]],
},
);
assertEquals(
astToKvQuery(
wrapQueryParse(`page where name = "test" and age > 20 or done = true`)!,
),
{
querySource: "page",
filter: ["or", ["and", ["=", ["attr", "name"], ["string", "test"]], [
">",
[
"attr",
"age",
],
["number", 20],
]], ["=", ["attr", "done"], ["boolean", true]]],
},
);
assertEquals(
astToKvQuery(
wrapQueryParse(`page where (age <= 20) or task.done = null`)!,
),
{
querySource: "page",
filter: ["or", ["<=", ["attr", "age"], ["number", 20]], [
"=",
[
"attr",
[
"attr",
"task",
],
"done",
],
["null"],
]],
},
);
assertEquals(
astToKvQuery(
wrapQueryParse(`task order by lastModified asc`)!,
),
{
querySource: "task",
orderBy: [{ expr: ["attr", "lastModified"], desc: false }],
},
);
assertEquals(
astToKvQuery(
wrapQueryParse(`task order by lastModified`)!,
),
{
querySource: "task",
orderBy: [{ expr: ["attr", "lastModified"], desc: false }],
},
);
assertEquals(
astToKvQuery(
wrapQueryParse(`task order by lastModified desc, name, age asc`)!,
),
{
querySource: "task",
orderBy: [{ expr: ["attr", "lastModified"], desc: true }, {
expr: ["attr", "name"],
desc: false,
}, { expr: ["attr", "age"], desc: false }],
},
);
assertEquals(
astToKvQuery(
wrapQueryParse(`task order by lastModified desc limit 5`)!,
),
{
querySource: "task",
orderBy: [{ expr: ["attr", "lastModified"], desc: true }],
limit: ["number", 5],
},
);
assertEquals(
astToKvQuery(
wrapQueryParse(`task select name, lastModified + 20 as modified`)!,
),
{
querySource: "task",
select: [{ name: "name" }, {
name: "modified",
expr: ["+", ["attr", "lastModified"], ["number", 20]],
}],
},
);
assertEquals(
astToKvQuery(
wrapQueryParse(`task render [[my/page]]`)!,
),
{
querySource: "task",
render: "my/page",
},
);
assertEquals(
astToKvQuery(
wrapQueryParse(`task where name in ["hello", 1]`)!,
),
{
querySource: "task",
filter: ["in", ["attr", "name"], ["array", [["string", "hello"], [
"number",
1,
]]]],
},
);
assertEquals(
astToKvQuery(
wrapQueryParse(`task select today() as today2`)!,
),
{
querySource: "task",
select: [{
name: "today2",
expr: ["call", "today", []],
}],
},
);
assertEquals(
astToKvQuery(
wrapQueryParse(`task select today(1, 2, 3) as today`)!,
),
{
querySource: "task",
select: [{
name: "today",
expr: ["call", "today", [["number", 1], ["number", 2], ["number", 3]]],
}],
},
);
});
+200 -118
View File
@@ -1,145 +1,227 @@
import { ParseTree, renderToText, replaceNodesMatching } from "$sb/lib/tree.ts";
import { FunctionMap, KV, Query, QueryExpression } from "$sb/types.ts";
export const queryRegex =
/(<!--\s*#query\s+(.+?)-->)(.+?)(<!--\s*\/query\s*-->)/gs;
export const directiveStartRegex = /<!--\s*#([\w\-]+)\s+(.+?)-->/s;
export const directiveEndRegex = /<!--\s*\/([\w\-]+)\s*-->/s;
export type QueryFilter = {
op: string;
prop: string;
value: any;
};
export function evalQueryExpression(
val: QueryExpression,
obj: any,
functionMap: FunctionMap = {},
): any {
const [type, op1] = val;
export type QueryOrdering = {
orderBy: string;
orderDesc: boolean;
};
export type ParsedQuery = {
table: string;
limit?: number;
ordering: QueryOrdering[];
/** @deprecated Please use ordering.
* Deprecated due to PR #387
* Currently holds ordering[0] if exists
*/
orderBy?: string;
/** @deprecated Please use ordering.
* Deprecated due to PR #387
* Currently holds ordering[0] if exists
*/
orderDesc?: boolean;
filter: QueryFilter[];
select?: string[];
render?: string;
};
export function applyQuery<T>(parsedQuery: ParsedQuery, records: T[]): T[] {
let resultRecords: any[] = [];
if (parsedQuery.filter.length === 0) {
resultRecords = records.slice();
} else {
recordLoop:
for (const record of records) {
const recordAny: any = record;
for (const { op, prop, value } of parsedQuery.filter) {
switch (op) {
case "=": {
const recordPropVal = recordAny[prop];
if (Array.isArray(recordPropVal) && !Array.isArray(value)) {
// Record property is an array, and value is a scalar: find the value in the array
if (!recordPropVal.includes(value)) {
continue recordLoop;
}
} else if (Array.isArray(recordPropVal) && Array.isArray(value)) {
// Record property is an array, and value is an array: find the value in the array
if (!recordPropVal.some((v) => value.includes(v))) {
continue recordLoop;
}
} else if (!(recordPropVal == value)) {
// Both are scalars: exact value
continue recordLoop;
}
break;
}
case "!=":
if (!(recordAny[prop] != value)) {
continue recordLoop;
}
break;
case "<":
if (!(recordAny[prop] < value)) {
continue recordLoop;
}
break;
case "<=":
if (!(recordAny[prop] <= value)) {
continue recordLoop;
}
break;
case ">":
if (!(recordAny[prop] > value)) {
continue recordLoop;
}
break;
case ">=":
if (!(recordAny[prop] >= value)) {
continue recordLoop;
}
break;
case "=~":
// TODO: Cache regexps somehow
if (!new RegExp(value).exec(recordAny[prop])) {
continue recordLoop;
}
break;
case "!=~":
if (new RegExp(value).exec(recordAny[prop])) {
continue recordLoop;
}
break;
case "in":
if (!value.includes(recordAny[prop])) {
continue recordLoop;
}
break;
switch (type) {
// Logical operators
case "and":
return evalQueryExpression(op1, obj, functionMap) &&
evalQueryExpression(val[2], obj, functionMap);
case "or":
return evalQueryExpression(op1, obj, functionMap) ||
evalQueryExpression(val[2], obj, functionMap);
// Value types
case "null":
return null;
case "number":
case "string":
case "boolean":
return op1;
case "regexp":
return [op1, val[2]];
case "attr": {
let attributeVal = obj;
if (val.length === 3) {
attributeVal = evalQueryExpression(val[1], obj, functionMap);
if (attributeVal) {
return attributeVal[val[2]];
} else {
return null;
}
} else if (!val[1]) {
return obj;
} else {
return attributeVal[val[1]];
}
resultRecords.push(recordAny);
}
case "array": {
return op1.map((v) => evalQueryExpression(v, obj, functionMap));
}
case "object":
return obj;
case "call": {
const fn = functionMap[op1];
if (!fn) {
throw new Error(`Unknown function: ${op1}`);
}
return fn(
...val[2].map((v) => evalQueryExpression(v, obj, functionMap)),
);
}
}
if (parsedQuery.ordering.length > 0) {
resultRecords = resultRecords.sort((a: any, b: any) => {
for (const { orderBy, orderDesc } of parsedQuery.ordering) {
if (a[orderBy] < b[orderBy] || a[orderBy] === undefined) {
return orderDesc ? 1 : -1;
// Binary operators, here we can pre-calculate the two operand values
const val1 = evalQueryExpression(op1, obj, functionMap);
const val2 = evalQueryExpression(val[2], obj, functionMap);
switch (type) {
case "+":
return val1 + val2;
case "-":
return val1 - val2;
case "*":
return val1 * val2;
case "/":
return val1 / val2;
case "%":
return val1 % val2;
case "=": {
if (Array.isArray(val1) && !Array.isArray(val2)) {
// Record property is an array, and value is a scalar: find the value in the array
if (val1.includes(val2)) {
return true;
}
if (a[orderBy] > b[orderBy] || b[orderBy] === undefined) {
return orderDesc ? -1 : 1;
} else if (Array.isArray(val1) && Array.isArray(val2)) {
// Record property is an array, and value is an array: find the value in the array
if (val1.some((v) => val2.includes(v))) {
return true;
}
// Consider them equal. This way helps with comparing arrays (like tags)
}
return val1 == val2;
}
case "!=":
return val1 != val2;
case "=~": {
if (!Array.isArray(val2)) {
throw new Error(`Invalid regexp: ${val2}`);
}
const r = new RegExp(val2[0], val2[1]);
return r.test(val1);
}
case "!=~": {
if (!Array.isArray(val2)) {
throw new Error(`Invalid regexp: ${val2}`);
}
const r = new RegExp(val2[0], val2[1]);
return !r.test(val1);
}
case "<":
return val1 < val2;
case "<=":
return val1 <= val2;
case ">":
return val1 > val2;
case ">=":
return val1 >= val2;
case "in":
return val2.includes(val1);
default:
throw new Error(`Unupported operator: ${type}`);
}
}
/**
* Looks for an attribute assignment in the expression, and returns the expression assigned to the attribute or throws an error when not found
* Side effect: effectively removes the attribute assignment from the expression (by replacing it with true = true)
*/
export function liftAttributeFilter(
expression: QueryExpression | undefined,
attributeName: string,
): QueryExpression {
if (!expression) {
throw new Error(`Cannot find attribute assignment for ${attributeName}`);
}
switch (expression[0]) {
case "=": {
if (expression[1][0] === "attr" && expression[1][1] === attributeName) {
const val = expression[2];
// Remove the filter by changing it to true = true
expression[1] = ["boolean", true];
expression[2] = ["boolean", true];
return val;
}
break;
}
case "and":
case "or": {
const newOp1 = liftAttributeFilter(expression[1], attributeName);
if (newOp1) {
return newOp1;
}
const newOp2 = liftAttributeFilter(expression[2], attributeName);
if (newOp2) {
return newOp2;
}
throw new Error(`Cannot find attribute assignment for ${attributeName}`);
}
}
throw new Error(`Cannot find attribute assignment for ${attributeName}`);
}
export function applyQuery<T>(query: Query, allItems: T[]): T[] {
// Filter
if (query.filter) {
allItems = allItems.filter((item) =>
evalQueryExpression(query.filter!, item)
);
}
// Add dummy keys, then remove them
return applyQueryNoFilterKV(
query,
allItems.map((v) => ({ key: [], value: v })),
).map((v) => v.value);
}
export function applyQueryNoFilterKV(
query: Query,
allItems: KV[],
functionMap: FunctionMap = {}, // TODO: Figure this out later
): KV[] {
// Order by
if (query.orderBy) {
allItems.sort((a, b) => {
const aVal = a.value;
const bVal = b.value;
for (const { expr, desc } of query.orderBy!) {
const evalA = evalQueryExpression(expr, aVal, functionMap);
const evalB = evalQueryExpression(expr, bVal, functionMap);
if (
evalA < evalB || evalA === undefined
) {
return desc ? 1 : -1;
}
if (
evalA > evalB || evalB === undefined
) {
return desc ? -1 : 1;
}
}
// Consider them equal. This helps with comparing arrays (like tags)
return 0;
});
}
if (parsedQuery.limit) {
resultRecords = resultRecords.slice(0, parsedQuery.limit);
}
if (parsedQuery.select) {
resultRecords = resultRecords.map((rec) => {
if (query.select) {
for (let i = 0; i < allItems.length; i++) {
const rec = allItems[i].value;
const newRec: any = {};
for (const k of parsedQuery.select!) {
newRec[k] = rec[k];
for (const { name, expr } of query.select) {
newRec[name] = expr
? evalQueryExpression(expr, rec, functionMap)
: rec[name];
}
return newRec;
});
allItems[i].value = newRec;
}
}
return resultRecords;
if (query.limit) {
const limit = evalQueryExpression(query.limit, {}, functionMap);
if (allItems.length > limit) {
allItems = allItems.slice(0, limit);
}
}
return allItems;
}
export function removeQueries(pt: ParseTree) {
+7
View File
@@ -4,6 +4,7 @@ import {
collectNodesMatching,
findParentMatching,
nodeAtPos,
parseTreeToAST,
removeParentPointers,
renderToText,
replaceNodesMatching,
@@ -77,3 +78,9 @@ Deno.test("Test parsing", () => {
let mdTree3 = parse(lang, mdTest3);
// console.log(JSON.stringify(mdTree3, null, 2));
});
Deno.test("AST functions", () => {
const lang = wikiMarkdownLang([]);
const mdTree = parse(lang, mdTest1);
console.log(JSON.stringify(parseTreeToAST(mdTree), null, 2));
});
+18
View File
@@ -8,6 +8,8 @@ export type ParseTree = {
parent?: ParseTree;
};
export type AST = [string, ...AST[]] | string;
export function addParentPointers(tree: ParseTree) {
if (!tree.children) {
return;
@@ -208,3 +210,19 @@ export function cloneTree(tree: ParseTree): ParseTree {
delete newTree.parent;
return newTree;
}
export function parseTreeToAST(tree: ParseTree): AST {
if (tree.text !== undefined) {
return tree.text;
}
const ast: AST = [tree.type!];
for (const node of tree.children!) {
if (node.type && !node.type.endsWith("Mark")) {
ast.push(parseTreeToAST(node));
}
if (node.text && node.text.trim()) {
ast.push(node.text);
}
}
return ast;
}