This commit is contained in:
2025-11-17 18:45:35 +01:00
parent 0f58e3bdff
commit 14d6f9aa73
7607 changed files with 1969407 additions and 0 deletions

View File

@@ -0,0 +1,153 @@
import { CompConstructor } from "baseComps/comp";
import { JSONValue } from "util/jsonTypes";
export enum CompActionTypes {
CHANGE_VALUE = "CHANGE_VALUE",
RENAME = "RENAME",
MULTI_CHANGE = "MULTI_CHANGE",
DELETE_COMP = "DELETE_COMP",
REPLACE_COMP = "REPLACE_COMP",
ONLY_EVAL = "NEED_EVAL",
// UPDATE_NODES = "UPDATE_NODES",
UPDATE_NODES_V2 = "UPDATE_NODES_V2",
EXECUTE_QUERY = "EXECUTE_QUERY",
TRIGGER_MODULE_EVENT = "TRIGGER_MODULE_EVENT",
/**
* this action can pass data to the comp by name
*/
ROUTE_BY_NAME = "ROUTE_BY_NAME",
/**
* execute action with context. for example, buttons in table's column should has currentRow as context
* FIXME: this is a broadcast message, better to be improved by a heritage mechanism.
*/
UPDATE_ACTION_CONTEXT = "UPDATE_ACTION_CONTEXT",
/**
* comp-specific action can be placed not globally.
* use CUSTOM uniformly.
*/
CUSTOM = "CUSTOM",
/**
* broadcast other actions in comp tree structure.
* used for encapsulate MultiBaseComp
*/
BROADCAST = "BROADCAST",
}
export type ExtraActionType =
| "layout"
| "delete"
| "add"
| "modify"
| "rename"
| "recover"
| "upgrade";
export type ActionExtraInfo = {
compInfos?: {
compName: string;
compType: string;
type: ExtraActionType;
}[];
};
export type ActionPriority = "sync" | "defer";
interface ActionCommon {
path: Array<string>;
editDSL: boolean; // FIXME: remove the "?" mark
skipHistory?: boolean;
extraInfo?: ActionExtraInfo;
priority?: ActionPriority;
}
export interface CustomAction<DataType = JSONValue> extends ActionCommon {
type: CompActionTypes.CUSTOM;
value: DataType;
}
export interface ChangeValueAction<DataType extends JSONValue = JSONValue> extends ActionCommon {
type: CompActionTypes.CHANGE_VALUE;
value: DataType;
}
export interface ReplaceCompAction extends ActionCommon {
type: CompActionTypes.REPLACE_COMP;
compFactory: CompConstructor;
}
export interface RenameAction extends ActionCommon {
type: CompActionTypes.RENAME;
oldName: string;
name: string;
}
export interface BroadcastAction<Action extends ActionCommon = ActionCommon> extends ActionCommon {
type: CompActionTypes.BROADCAST;
action: Action;
}
export interface MultiChangeAction extends ActionCommon {
type: CompActionTypes.MULTI_CHANGE;
changes: Record<string, CompAction>;
}
export interface SimpleCompAction extends ActionCommon {
type: CompActionTypes.DELETE_COMP | CompActionTypes.ONLY_EVAL;
}
export interface ExecuteQueryAction extends ActionCommon {
type: CompActionTypes.EXECUTE_QUERY;
queryName?: string;
args?: Record<string, unknown>;
afterExecFunc?: () => void;
}
export interface TriggerModuleEventAction extends ActionCommon {
type: CompActionTypes.TRIGGER_MODULE_EVENT;
name: string;
}
export interface RouteByNameAction extends ActionCommon {
type: CompActionTypes.ROUTE_BY_NAME;
name: string;
action: CompAction<any>;
}
// export interface ExecuteCompAction extends ActionCommon {
// type: CompActionTypes.EXECUTE_COMP;
// compName: string;
// methodName: string;
// params: Array<string>;
// }
export interface UpdateNodesV2Action extends ActionCommon {
type: CompActionTypes.UPDATE_NODES_V2;
value: any;
}
export type ActionContextType = Record<string, unknown>;
export interface UpdateActionContextAction extends ActionCommon {
type: CompActionTypes.UPDATE_ACTION_CONTEXT;
context: ActionContextType;
}
export type CompAction<DataType extends JSONValue = JSONValue> =
| CustomAction<unknown>
| ChangeValueAction<DataType>
| BroadcastAction
| RenameAction
| ReplaceCompAction
| MultiChangeAction
| SimpleCompAction
| ExecuteQueryAction
| UpdateActionContextAction
| RouteByNameAction
| TriggerModuleEventAction
| UpdateNodesV2Action;

View File

@@ -0,0 +1,219 @@
import { CompConstructor } from "baseComps/comp";
import _ from "lodash";
import { JSONValue } from "util/jsonTypes";
import {
ActionContextType,
ActionExtraInfo,
BroadcastAction,
ChangeValueAction,
CompAction,
CompActionTypes,
CustomAction,
ExecuteQueryAction,
MultiChangeAction,
RenameAction,
ReplaceCompAction,
RouteByNameAction,
SimpleCompAction,
TriggerModuleEventAction,
UpdateActionContextAction,
UpdateNodesV2Action,
} from "./actionTypes";
export function customAction<DataType>(value: DataType, editDSL: boolean): CustomAction<DataType> {
return {
type: CompActionTypes.CUSTOM,
path: [],
value: value,
editDSL,
};
}
export function updateActionContextAction(
context: ActionContextType
): BroadcastAction<UpdateActionContextAction> {
const value: UpdateActionContextAction = {
type: CompActionTypes.UPDATE_ACTION_CONTEXT,
path: [],
editDSL: false,
context: context,
};
return {
type: CompActionTypes.BROADCAST,
path: [],
editDSL: false,
action: value,
};
}
/**
* check if it's current custom action.
* keep type safe via generics, users should keep type the same as T, otherwise may cause bug.
*/
export function isMyCustomAction<T>(action: CompAction, type: string): action is CustomAction<T> {
return !isChildAction(action) && isCustomAction(action, type);
}
export function isCustomAction<T>(action: CompAction, type: string): action is CustomAction<T> {
return action.type === CompActionTypes.CUSTOM && _.get(action.value, "type") === type;
}
/**
* The action of execute query.
* path route to the query exactly.
* RootComp will change the path correctly when queryName is passed.
*/
export function executeQueryAction(props: {
args?: Record<string, unknown>;
afterExecFunc?: () => void;
}): ExecuteQueryAction {
return {
type: CompActionTypes.EXECUTE_QUERY,
path: [],
editDSL: false,
...props,
};
}
export function triggerModuleEventAction(name: string): TriggerModuleEventAction {
return {
type: CompActionTypes.TRIGGER_MODULE_EVENT,
path: [],
editDSL: false,
name,
};
}
/**
* better to use comp.dispatchChangeValueAction to keep type safe
*/
export function changeValueAction(value: JSONValue, editDSL: boolean): ChangeValueAction {
return {
type: CompActionTypes.CHANGE_VALUE,
path: [],
editDSL,
value: value,
};
}
export function isBroadcastAction<T extends CompAction>(
action: CompAction,
type: T["type"]
): action is BroadcastAction<T> {
return action.type === CompActionTypes.BROADCAST && _.get(action.action, "type") === type;
}
export function renameAction(oldName: string, name: string): BroadcastAction<RenameAction> {
const value: RenameAction = {
type: CompActionTypes.RENAME,
path: [],
editDSL: true,
oldName: oldName,
name: name,
};
return {
type: CompActionTypes.BROADCAST,
path: [],
editDSL: true,
action: value,
};
}
export function routeByNameAction(name: string, action: CompAction<any>): RouteByNameAction {
return {
type: CompActionTypes.ROUTE_BY_NAME,
path: [],
name: name,
editDSL: action.editDSL,
action: action,
};
}
export function multiChangeAction(changes: Record<string, CompAction>): MultiChangeAction {
const editDSL = Object.values(changes).some((action) => !!action.editDSL);
console.assert(
Object.values(changes).every(
(action) => !_.isNil(action.editDSL) && action.editDSL === editDSL
),
`multiChangeAction should wrap actions with the same editDSL value in property. editDSL: ${editDSL}\nchanges:`,
changes
);
return {
type: CompActionTypes.MULTI_CHANGE,
path: [],
editDSL,
changes: changes,
};
}
export function deleteCompAction(): SimpleCompAction {
return {
type: CompActionTypes.DELETE_COMP,
path: [],
editDSL: true,
};
}
export function replaceCompAction(compFactory: CompConstructor): ReplaceCompAction {
return {
type: CompActionTypes.REPLACE_COMP,
path: [],
editDSL: false,
compFactory: compFactory,
};
}
export function onlyEvalAction(): SimpleCompAction {
return {
type: CompActionTypes.ONLY_EVAL,
path: [],
editDSL: false,
};
}
export function wrapChildAction(childName: string, action: CompAction): CompAction {
return {
...action,
path: [childName, ...action.path],
};
}
export function isChildAction(action: CompAction): boolean {
return (action?.path?.length ?? 0) > 0;
}
export function unwrapChildAction(action: CompAction): [string, CompAction] {
return [action.path[0], { ...action, path: action.path.slice(1) }];
}
export function changeChildAction(
childName: string,
value: JSONValue,
editDSL: boolean
): CompAction {
return wrapChildAction(childName, changeValueAction(value, editDSL));
}
export function updateNodesV2Action(value: any): UpdateNodesV2Action {
return {
type: CompActionTypes.UPDATE_NODES_V2,
path: [],
editDSL: false,
value: value,
};
}
export function wrapActionExtraInfo<T extends CompAction>(
action: T,
extraInfos: ActionExtraInfo
): T {
return { ...action, extraInfo: { ...action.extraInfo, ...extraInfos } };
}
export function deferAction<T extends CompAction>(action: T): T {
return { ...action, priority: "defer" };
}
export function changeEditDSLAction<T extends CompAction>(action: T, editDSL: boolean): T {
return { ...action, editDSL };
}

View File

@@ -0,0 +1,2 @@
export * from "./actions";
export * from "./actionTypes";

View File

@@ -0,0 +1,116 @@
import { changeValueAction, ChangeValueAction, CompAction, CompActionTypes } from "actions";
import { Node } from "eval";
import { ReactNode } from "react";
import { memo } from "util/cacheUtils";
import { JSONValue } from "util/jsonTypes";
import { setFieldsNoTypeCheck } from "util/objectUtils";
export type OptionalNodeType = Node<unknown> | undefined;
export type DispatchType = (action: CompAction) => void;
/**
*/
export interface Comp<
ViewReturn = any,
DataType extends JSONValue = JSONValue,
NodeType extends OptionalNodeType = OptionalNodeType
> {
dispatch: DispatchType;
getView(): ViewReturn;
getPropertyView(): ReactNode;
reduce(action: CompAction): this;
node(): NodeType;
toJsonValue(): DataType;
/**
* change current comp's dispatch function.
* used when the comp is moved across the tree structure.
*/
changeDispatch(dispatch: DispatchType): this;
changeValueAction(value: DataType): ChangeValueAction;
}
export abstract class AbstractComp<
ViewReturn = any,
DataType extends JSONValue = JSONValue,
NodeType extends OptionalNodeType = OptionalNodeType
> implements Comp<ViewReturn, DataType, NodeType>
{
dispatch: DispatchType;
constructor(params: CompParams) {
this.dispatch = params.dispatch ?? ((_action: CompAction) => {});
}
abstract getView(): ViewReturn;
abstract getPropertyView(): ReactNode;
abstract toJsonValue(): DataType;
abstract reduce(_action: CompAction): this;
abstract nodeWithoutCache(): NodeType;
changeDispatch(dispatch: DispatchType): this {
return setFieldsNoTypeCheck(this, { dispatch: dispatch }, { keepCacheKeys: ["node"] });
}
/**
* trigger changeValueAction, type safe
*/
dispatchChangeValueAction(value: DataType) {
this.dispatch(this.changeValueAction(value));
}
changeValueAction(value: DataType): ChangeValueAction {
return changeValueAction(value, true);
}
/**
* don't override the function, override nodeWithout function instead
* FIXME: node reference mustn't be changed if this object is changed
*/
@memo
node(): NodeType {
return this.nodeWithoutCache();
}
}
export type OptionalComp<T = any> = Comp<T> | undefined;
export type CompConstructor<
ViewReturn = any,
DataType extends JSONValue = any,
NodeType extends OptionalNodeType = OptionalNodeType
> = new (params: CompParams<DataType>) => Comp<ViewReturn, DataType, NodeType>;
/**
* extract constructor's generic type
*/
export type ConstructorToView<T> = T extends CompConstructor<infer ViewReturn> ? ViewReturn : never;
export type ConstructorToComp<T> = T extends new (params: CompParams<any>) => infer X ? X : never;
export type ConstructorToDataType<T> = T extends new (params: CompParams<infer DataType>) => any
? DataType
: never;
export type ConstructorToNodeType<T> = ConstructorToComp<T> extends Comp<any, any, infer NodeType>
? NodeType
: never;
export type RecordConstructorToComp<T> = {
[K in keyof T]: ConstructorToComp<T[K]>;
};
export type RecordConstructorToView<T> = {
[K in keyof T]: ConstructorToView<T[K]>;
};
export interface CompParams<DataType extends JSONValue = JSONValue> {
dispatch?: (action: CompAction) => void;
value?: DataType;
}

View File

@@ -0,0 +1,3 @@
export * from "./comp";
export * from "./multiBaseComp";
export * from "./simpleComp";

View File

@@ -0,0 +1,301 @@
import {
CompAction,
CompActionTypes,
isChildAction,
unwrapChildAction,
updateNodesV2Action,
wrapChildAction,
} from "actions";
import { fromRecord, Node } from "eval";
import _ from "lodash";
import log from "loglevel";
import { CACHE_PREFIX } from "util/cacheUtils";
import { JSONValue } from "util/jsonTypes";
import { containFields, setFieldsNoTypeCheck, shallowEqual } from "util/objectUtils";
import {
AbstractComp,
Comp,
CompParams,
ConstructorToDataType,
DispatchType,
OptionalNodeType,
} from "./comp";
/**
* MultiBaseCompConstructor with abstract function implemented
*/
export type MultiCompConstructor = new (params: CompParams<any>) => MultiBaseComp<any, any, any> &
Comp<any, any, any>;
/**
* wrap a dispatch as a child dispatch
*
* @param dispatch input dispatch
* @param childName the key of the child dispatch
* @returns a wrapped dispatch with the child dispatch
*/
export function wrapDispatch(dispatch: DispatchType | undefined, childName: string): DispatchType {
return (action: CompAction): void => {
if (dispatch) {
dispatch(wrapChildAction(childName, action));
}
};
}
export type ExtraNodeType = {
node: Record<string, Node<any>>;
updateNodeFields: (value: any) => Record<string, any>;
};
/**
* the core class of multi function
* build the tree structure of comps
* @remarks
* functions can be cached if needed.
**/
export abstract class MultiBaseComp<
ChildrenType extends Record<string, Comp<unknown>> = Record<string, Comp<unknown>>,
DataType extends JSONValue = JSONValue,
NodeType extends OptionalNodeType = OptionalNodeType
> extends AbstractComp<any, DataType, NodeType> {
readonly children: ChildrenType;
constructor(params: CompParams<DataType>) {
super(params);
this.children = this.parseChildrenFromValue(params);
}
abstract parseChildrenFromValue(params: CompParams<DataType>): ChildrenType;
override reduce(action: CompAction): this {
const comp = this.reduceOrUndefined(action);
if (!comp) {
console.warn(
"not supported action, should not happen, action:",
action,
"\ncurrent comp:",
this
);
return this;
}
return comp;
}
// if the base class can't handle this action, just return undefined
protected reduceOrUndefined(action: CompAction): this | undefined {
// log.debug("reduceOrUndefined. action: ", action, " this: ", this);
// must handle DELETE in the parent level
if (action.type === CompActionTypes.DELETE_COMP && action.path.length === 1) {
return this.setChildren(_.omit(this.children, action.path[0]));
}
if (action.type === CompActionTypes.REPLACE_COMP && action.path.length === 1) {
const NextComp = action.compFactory;
if (!NextComp) {
return this;
}
const compName = action.path[0];
const currentComp = this.children[compName];
const value = currentComp.toJsonValue();
const nextComp = new NextComp({
value,
dispatch: wrapDispatch(this.dispatch, compName),
});
return this.setChildren({
...this.children,
[compName]: nextComp,
});
}
if (isChildAction(action)) {
const [childName, childAction] = unwrapChildAction(action);
const child = this.children[childName];
if (!child) {
log.error("found bad action path ", childName);
return this;
}
const newChild = child.reduce(childAction);
return this.setChild(childName, newChild);
}
// key, value
switch (action.type) {
case CompActionTypes.MULTI_CHANGE: {
const { changes } = action;
// handle DELETE in the parent level
let mcChildren = _.omitBy(this.children, (comp, childName) => {
const innerAction = changes[childName];
return (
innerAction &&
innerAction.type === CompActionTypes.DELETE_COMP &&
innerAction.path.length === 0
);
});
// CHANGE
mcChildren = _.mapValues(mcChildren, (comp, childName) => {
const innerAction = changes[childName];
if (innerAction) {
return comp.reduce(innerAction);
}
return comp;
});
return this.setChildren(mcChildren);
}
case CompActionTypes.UPDATE_NODES_V2: {
const { value } = action;
if (value === undefined) {
return this;
}
const cacheKey = CACHE_PREFIX + "REDUCE_UPDATE_NODE";
// if constructed by the value, just return
if ((this as any)[cacheKey] === value) {
// console.info("inside: UPDATE_NODE_V2 cache hit. action: ", action, "\nvalue: ", value, "\nthis: ", this);
return this;
}
const children = _.mapValues(this.children, (comp, childName) => {
if (value.hasOwnProperty(childName)) {
return comp.reduce(updateNodesV2Action(value[childName]));
}
return comp;
});
const extraFields = this.extraNode()?.updateNodeFields(value);
if (shallowEqual(children, this.children) && containFields(this, extraFields)) {
return this;
}
return setFieldsNoTypeCheck(
this,
{
children: children,
[cacheKey]: value,
...extraFields,
},
{ keepCacheKeys: ["node"] }
);
}
case CompActionTypes.CHANGE_VALUE: {
return this.setChildren(
this.parseChildrenFromValue({
dispatch: this.dispatch,
value: action.value as DataType,
})
);
}
case CompActionTypes.BROADCAST: {
return this.setChildren(
_.mapValues(this.children, (comp) => {
return comp.reduce(action);
})
);
}
case CompActionTypes.ONLY_EVAL: {
return this;
}
}
}
setChild(childName: keyof ChildrenType, newChild: Comp): this {
if (this.children[childName] === newChild) {
return this;
}
return this.setChildren({
...this.children,
[childName]: newChild,
});
}
protected setChildren(
children: Record<string, Comp>,
params?: { keepCacheKeys?: string[] }
): this {
if (shallowEqual(children, this.children)) {
return this;
}
return setFieldsNoTypeCheck(this, { children: children }, params);
}
/**
* extended interface.
*
* @return node for additional node, updateNodeFields for handling UPDATE_NODE event
* FIXME: make type safe
*/
protected extraNode(): ExtraNodeType | undefined {
return undefined;
}
protected childrenNode() {
const result: { [key: string]: Node<unknown> } = {};
Object.keys(this.children).forEach((key) => {
const node = this.children[key].node();
if (node !== undefined) {
result[key] = node;
}
});
return result;
}
override nodeWithoutCache(): NodeType {
return fromRecord({
...this.childrenNode(),
...this.extraNode()?.node,
}) as unknown as NodeType;
}
override changeDispatch(dispatch: DispatchType): this {
const newChildren = _.mapValues(this.children, (comp, childName) => {
return comp.changeDispatch(wrapDispatch(dispatch, childName));
});
return super.changeDispatch(dispatch).setChildren(newChildren, { keepCacheKeys: ["node"] });
}
protected ignoreChildDefaultValue() {
return false;
}
readonly IGNORABLE_DEFAULT_VALUE = {};
override toJsonValue(): DataType {
const result: Record<string, any> = {};
const ignore = this.ignoreChildDefaultValue();
Object.keys(this.children).forEach((key) => {
const comp = this.children[key];
// FIXME: this implementation is a little tricky, better choose a encapsulated implementation
if (comp.hasOwnProperty("NO_PERSISTENCE")) {
return;
}
const value = comp.toJsonValue();
if (ignore && _.isEqual(value, (comp as any)["IGNORABLE_DEFAULT_VALUE"])) {
return;
}
result[key] = value;
});
return result as DataType;
}
// FIXME: autoHeight should be encapsulated in UIComp/UICompBuilder
autoHeight(): boolean {
return true;
}
changeChildAction(
childName: string & keyof ChildrenType,
value: ConstructorToDataType<new (...params: any) => ChildrenType[typeof childName]>
) {
return wrapChildAction(childName, this.children[childName].changeValueAction(value));
}
}
export function mergeExtra(e1: ExtraNodeType | undefined, e2: ExtraNodeType): ExtraNodeType {
if (e1 === undefined) {
return e2;
}
return {
node: {
...e1.node,
...e2.node,
},
updateNodeFields: (value: any) => {
return {
...e1.updateNodeFields(value),
...e2.updateNodeFields(value),
};
},
};
}

View File

@@ -0,0 +1,60 @@
import { JSONValue } from "util/jsonTypes";
import { fromValue, Node } from "eval";
import { setFieldsNoTypeCheck } from "util/objectUtils";
import { CompAction, CompActionTypes } from "actions";
import { AbstractComp, CompParams } from "./comp";
/**
* maintainer a JSONValue, nothing else
*/
export abstract class SimpleAbstractComp<ViewReturn extends JSONValue> extends AbstractComp<
any,
ViewReturn,
Node<ViewReturn>
> {
value: ViewReturn;
constructor(params: CompParams<ViewReturn>) {
super(params);
this.value = this.oldValueToNew(params.value) ?? this.getDefaultValue();
}
protected abstract getDefaultValue(): ViewReturn;
/**
* may override this to implement compatibility
*/
protected oldValueToNew(value?: ViewReturn): ViewReturn | undefined {
return value;
}
override reduce(action: CompAction): this {
if (action.type === CompActionTypes.CHANGE_VALUE) {
if (this.value === action.value) {
return this;
}
return setFieldsNoTypeCheck(this, { value: action.value });
}
return this;
}
override nodeWithoutCache() {
return fromValue(this.value);
}
exposingNode() {
return this.node();
}
// may be used in defaultValue
override toJsonValue(): ViewReturn {
return this.value;
}
}
export abstract class SimpleComp<
ViewReturn extends JSONValue
> extends SimpleAbstractComp<ViewReturn> {
override getView(): ViewReturn {
return this.value;
}
}

View File

@@ -0,0 +1 @@
declare module "really-relaxed-json";

View File

@@ -0,0 +1,60 @@
import { evalNodeOrMinor } from "./cachedNode";
import { fromUnevaledValue } from "./codeNode";
import { fromValue } from "./simpleNode";
import { wrapContext } from "./wrapContextNode";
describe("CachedNode", () => {
it("notCached test", () => {
const x1 = fromUnevaledValue("1");
const x2 = fromValue("2");
expect(evalNodeOrMinor(x1, x2).evaluate()).toEqual("1");
expect(evalNodeOrMinor(x1, x2).evaluate()).toEqual("2");
expect(evalNodeOrMinor(x1, x2).evaluate()).toEqual("2");
});
it("isCached test", () => {
const x1 = fromUnevaledValue("1");
const x2 = fromValue("2");
const x = evalNodeOrMinor(x1, x2);
expect(x.evaluate()).toEqual("1");
expect(x.evaluate()).toEqual("1");
});
it("isCached multi level test", () => {
const exposingNodes = {
e1: fromValue("e1V"),
e2: evalNodeOrMinor(fromUnevaledValue("e21 {{e1}}"), fromValue("e22")),
};
const x1 = fromUnevaledValue("1 {{e1}} {{e2}}");
const x2 = fromValue("2");
const x = evalNodeOrMinor(x1, x2);
expect(x.evaluate(exposingNodes)).toEqual("1 e1V e21 e1V");
const newExposingNodes = {
...exposingNodes,
e1: fromValue("e1Vn"),
};
expect(x.evaluate(newExposingNodes)).toEqual("1 e1Vn e21 e1Vn");
});
it("isCached multi inference test", () => {
const x1 = fromUnevaledValue("1");
const x2 = fromValue("2");
expect(
fromUnevaledValue("1 {{e1}} {{e2}}").evaluate({
e1: evalNodeOrMinor(x1, x2),
e2: evalNodeOrMinor(x1, x2),
})
).toEqual("1 1 1");
});
it("wrapContext: evalNodeOrMinor", () => {
const x1 = fromUnevaledValue("{{i}}");
const x2 = fromValue("0");
expect(wrapContext(evalNodeOrMinor(x1, x2)).evaluate()({ i: 2 })).toStrictEqual(2);
expect(wrapContext(evalNodeOrMinor(x1, x2)).evaluate()({ i: 2 })).toStrictEqual("0");
expect(wrapContext(evalNodeOrMinor(x1, x2)).evaluate()({ i: 2 })).toStrictEqual("0");
expect(wrapContext(evalNodeOrMinor(x1, x2)).evaluate()({ i: 1 })).toStrictEqual(1);
expect(wrapContext(evalNodeOrMinor(x1, x2)).evaluate()({ i: 1 })).toStrictEqual("0");
expect(wrapContext(evalNodeOrMinor(x1, x2)).evaluate()({ i: 1 })).toStrictEqual("0");
expect(wrapContext(evalNodeOrMinor(x1, x2)).evaluate()({ i: 3 })).toStrictEqual(3);
});
});

View File

@@ -0,0 +1,71 @@
import { memoized } from "util/memoize";
import { FunctionNode, withFunction } from "./functionNode";
import { AbstractNode, Node } from "./node";
import { RecordNode } from "./recordNode";
import { EvalMethods } from "./types/evalTypes";
interface CachedValue<T> {
value: T;
isCached: boolean;
}
export class CachedNode<T> extends AbstractNode<CachedValue<T>> {
type: string = "cached";
child: AbstractNode<T>;
constructor(child: AbstractNode<T>) {
super();
this.child = withEvalCache(child);
}
@memoized()
override filterNodes(exposingNodes: Record<string, Node<unknown>>) {
return this.child.filterNodes(exposingNodes);
}
override justEval(
exposingNodes: Record<string, Node<unknown>>,
methods?: EvalMethods
): CachedValue<T> {
const isCached = this.child.isHitEvalCache(exposingNodes); // isCached must be set before evaluate() call
const value = this.child.evaluate(exposingNodes, methods);
return { value, isCached };
}
override getChildren(): Node<unknown>[] {
return [this.child];
}
override dependValues(): Record<string, unknown> {
return this.child.dependValues();
}
override fetchInfo(exposingNodes: Record<string, Node<unknown>>) {
return this.child.fetchInfo(exposingNodes);
}
}
function withEvalCache<T>(node: AbstractNode<T>): FunctionNode<T, T> {
const newNode = withFunction(node, (x) => x);
newNode.evalCache = { ...node.evalCache };
return newNode;
}
/**
* return a new node with two input nodes.
* - if mainNode is never evaled, then (new node).evaluate equals to mainNode.evaluate
* - if mainNode is evaled, then (new node).evaluate equals to minorNode.evaluate
*
* @remarks
* encapsulation logic: 2 nodes -> CachedNode(mainNode)+minorNode -> RecordNode({main, minor}) -> FunctionNode
*
* @warn improper use may cause unexpected behaviour, be careful.
* @param mainNode mainNode
* @param minorNode minorNode
* @returns the new node
*/
export function evalNodeOrMinor<T>(mainNode: AbstractNode<T>, minorNode: Node<T>): Node<T> {
const nodeRecord = { main: new CachedNode(mainNode), minor: minorNode };
return new FunctionNode(new RecordNode(nodeRecord), (record) => {
const mainCachedValue = record.main;
if (!mainCachedValue.isCached) {
return mainCachedValue.value;
}
return record.minor;
});
}

View File

@@ -0,0 +1,106 @@
import { CodeNode, fromUnevaledValue } from "./codeNode";
import { ValueAndMsg } from "./types/valueAndMsg";
import { FunctionNode } from "./functionNode";
import { fromRecord } from "./recordNode";
import { fromValue } from "./simpleNode";
describe("simple evaluation", () => {
it("two adjacent expressions", () => {
const node = fromUnevaledValue("{{1}}{{1}}");
expect(node.evaluate()).toStrictEqual("11");
});
});
describe("depends evaluation", () => {
const exposingNodes = {
n1: fromValue(1),
n2: fromValue(2),
s1: fromValue("a"),
s2: fromValue("b"),
o1: fromRecord({ n1: fromValue(1), s1: fromValue("a") }),
o2: fromRecord({ n2: fromValue(2), s2: fromValue("b") }),
};
// evaluation1: add number
const e1 = fromUnevaledValue("{{n1+n2}}");
it("eval1: add 1 and 2 should return 3", () => {
expect(e1.evaluate(exposingNodes)).toStrictEqual(3);
});
// evaluation2: add string
const e2 = fromUnevaledValue("hello {{s1+s2}}");
it("eval2: add 'a' and 'b' should return 'ab'", () => {
expect(e2.evaluate(exposingNodes)).toStrictEqual("hello ab");
});
// evaluation3: extend object
const e3 = fromUnevaledValue("{{{...o1, ...o2}}}");
it("eval3: mix two objects into one object should work well", () => {
expect(e3.evaluate(exposingNodes)).toStrictEqual({ n1: 1, n2: 2, s1: "a", s2: "b" });
});
// evaluation4: eval after eval
const e4 = fromUnevaledValue("hello {{e1}} and {{e2}}");
it("eval4: eval with two variables should work well", () => {
const nodes = { ...exposingNodes, e1, e2 };
const tmp = e4.evaluate(nodes);
expect(tmp).toStrictEqual("hello 3 and hello ab");
});
// evaluation5: cyclic dependency
const e5Code = new CodeNode("{{n1+e6}}");
const e6Code = new CodeNode("{{n1+e5}}");
const e5 = new FunctionNode(e5Code, (valueAndMsg) => valueAndMsg.value);
const e6 = new FunctionNode(e6Code, (valueAndMsg) => valueAndMsg.value);
it("eval5: additional node causing cyclic dependency should not be evaled", () => {
const nodes = { e6, e5, ...exposingNodes };
expect(e5Code.evaluate(nodes)).toStrictEqual(
new ValueAndMsg(
"11",
`DependencyError: "${e5Code.unevaledValue}" caused a cyclic dependency.`,
{ segments: [{ value: "{{n1+e6}}", success: false }] }
)
);
});
// it("eval6: eval error should be returned as errorMessage", () => {
// expect(e6Code.evaluate(exposingNodes)).toStrictEqual(
// new ValueAndMsg("", "ReferenceError: e5 not exist", {
// segments: [{ value: "{{n1+e5}}", success: false }],
// })
// );
// });
});
describe("additional evaluation", () => {
it("additional test", () => {
const result = new CodeNode("{{input1.x}}").evaluate({
input1: fromRecord({
disabled: fromValue(false),
}),
});
expect(result).toStrictEqual(
new ValueAndMsg(undefined, undefined, {
segments: [{ value: "{{input1.x}}", success: true }],
})
);
});
});
describe("function evaluation", () => {
const exposingNodes = {
n1: fromValue(1),
n2: fromValue(2),
};
it("eval1", async () => {
const e1 = new CodeNode("var tmp = 10; return tmp+n1+n2;", { codeType: "Function" });
const ret = await (e1.evaluate(exposingNodes).value as Function)();
expect(ret).toStrictEqual(13);
});
});

View File

@@ -0,0 +1,227 @@
import _ from "lodash";
import { memoized } from "util/memoize";
import { FunctionNode, withFunction } from "./functionNode";
import { AbstractNode, FetchInfo, FetchInfoOptions, Node } from "./node";
import { fromRecord } from "./recordNode";
import { CodeType, EvalMethods } from "./types/evalTypes";
import { ValueAndMsg, ValueExtra } from "./types/valueAndMsg";
import { addDepend, addDepends } from "./utils/dependMap";
import { filterDepends, hasCycle } from "./utils/evaluate";
import { dependsErrorMessage, mergeNodesWithSameName, nodeIsRecord } from "./utils/nodeUtils";
import { string2Fn } from "./utils/string2Fn";
export interface CodeNodeOptions {
codeType?: CodeType;
// whether to support comp methods?
evalWithMethods?: boolean;
}
const IS_FETCHING_FIELD = "isFetching";
const LATEST_END_TIME_FIELD = "latestEndTime";
const TRIGGER_TYPE_FIELD = "triggerType";
/**
* user input node
*
* @remarks
* CodeNode should resolve the cyclic dependency problem
* we may assume cyclic dependency only imported by CodeNode
*
* FIXME(libin): distinguish Json CodeNodesince wrapContext may cause problems.
*/
export class CodeNode extends AbstractNode<ValueAndMsg<unknown>> {
readonly type = "input";
private readonly codeType?: CodeType;
private readonly evalWithMethods: boolean;
private directDepends = new Map<Node<unknown>, Set<string>>();
constructor(readonly unevaledValue: string, readonly options?: CodeNodeOptions) {
super();
this.codeType = options?.codeType;
this.evalWithMethods = options?.evalWithMethods ?? true;
}
// FIXME: optimize later
private convertedValue(): string {
if (this.codeType === "Function") {
return `{{function(){${this.unevaledValue}}}}`;
}
return this.unevaledValue;
}
@memoized()
override filterNodes(exposingNodes: Record<string, Node<unknown>>) {
if (!!this.evalCache.inFilterNodes) {
return new Map<Node<unknown>, Set<string>>();
}
this.evalCache.inFilterNodes = true;
try {
const filteredDepends = this.filterDirectDepends(exposingNodes);
// log.log("unevaledValue: ", this.unevaledValue, "\nfilteredDepends:", filteredDepends);
const result = addDepends(new Map(), filteredDepends);
filteredDepends.forEach((paths, node) => {
addDepends(result, node.filterNodes(exposingNodes));
});
// Add isFetching & latestEndTime node for FetchCheck
const topDepends = filterDepends(this.convertedValue(), exposingNodes, 1);
topDepends.forEach((paths, depend) => {
if (nodeIsRecord(depend)) {
for (const field of [IS_FETCHING_FIELD, LATEST_END_TIME_FIELD]) {
const node = depend.children[field];
if (node) {
addDepend(
result,
node,
Array.from(paths).map((p) => p + "." + field)
);
}
}
}
});
return result;
} finally {
this.evalCache.inFilterNodes = false;
}
}
// only includes direct depends, exlucdes depends of dependencies
@memoized()
private filterDirectDepends(exposingNodes: Record<string, Node<unknown>>) {
return filterDepends(this.convertedValue(), exposingNodes);
}
override justEval(
exposingNodes: Record<string, Node<unknown>>,
methods?: EvalMethods
): ValueAndMsg<unknown> {
// log.log("justEval: ", this, "\nexposingNodes: ", exposingNodes);
if (!!this.evalCache.inEval) {
// found cyclic eval
this.evalCache.cyclic = true;
return new ValueAndMsg<unknown>("");
}
this.evalCache.inEval = true;
try {
const dependingNodeMap = this.filterDirectDepends(exposingNodes);
this.directDepends = dependingNodeMap;
const dependingNodes = mergeNodesWithSameName(dependingNodeMap);
const fn = string2Fn(this.unevaledValue, this.codeType, this.evalWithMethods ? methods : {});
const evalNode = withFunction(fromRecord(dependingNodes), fn);
let valueAndMsg = evalNode.evaluate(exposingNodes);
// log.log("unevaledValue: ", this.unevaledValue, "\ndependingNodes: ", dependingNodes, "\nvalueAndMsg: ", valueAndMsg);
if (this.evalCache.cyclic) {
valueAndMsg = new ValueAndMsg<unknown>(
valueAndMsg.value,
(valueAndMsg.msg ? valueAndMsg.msg + "\n" : "") + dependsErrorMessage(this),
fixCyclic(valueAndMsg.extra, exposingNodes)
);
}
return valueAndMsg;
} finally {
this.evalCache.inEval = false;
}
}
override getChildren(): Node<unknown>[] {
if (this.directDepends) {
return Array.from(this.directDepends.keys());
}
return [];
}
override dependValues(): Record<string, unknown> {
let ret: Record<string, unknown> = {};
this.directDepends.forEach((paths, node) => {
if (node instanceof AbstractNode) {
paths.forEach((path) => {
ret[path] = node.evalCache.value;
});
}
});
return ret;
}
@memoized()
override fetchInfo(
exposingNodes: Record<string, Node<unknown>>,
options?: FetchInfoOptions
): FetchInfo {
if (!!this.evalCache.inIsFetching) {
return {
isFetching: false,
ready: true,
};
}
this.evalCache.inIsFetching = true;
try {
const topDepends = filterDepends(this.convertedValue(), exposingNodes, 1);
let isFetching = false;
let ready = true;
topDepends.forEach((paths, depend) => {
const pathsArr = Array.from(paths);
const value = depend.evaluate(exposingNodes) as any;
if (
options?.ignoreManualDepReadyStatus &&
_.has(value, TRIGGER_TYPE_FIELD) &&
value.triggerType === "manual"
) {
return;
}
// if query is dependent on itself, mark as ready
if (pathsArr?.[0] === options?.queryName) return;
// TODO: check if this is needed after removing lazy load
// wait for lazy loaded comps to load before executing query on page load
// if (value && !Object.keys(value).length && paths.size) {
// isFetching = true;
// ready = false;
// }
if (_.has(value, IS_FETCHING_FIELD)) {
isFetching = isFetching || value.isFetching === true;
}
if (_.has(value, LATEST_END_TIME_FIELD)) {
ready = ready && value.latestEndTime > 0;
}
});
const dependingNodeMap = this.filterNodes(exposingNodes);
dependingNodeMap.forEach((paths, depend) => {
const fi = depend.fetchInfo(exposingNodes, options);
isFetching = isFetching || fi.isFetching;
ready = ready && fi.ready;
});
return {
isFetching,
ready: ready,
};
} finally {
this.evalCache.inIsFetching = false;
}
}
}
/**
* generate node for unevaledValue
*/
export function fromUnevaledValue(unevaledValue: string) {
return new FunctionNode(new CodeNode(unevaledValue), (valueAndMsg) => valueAndMsg.value);
}
function fixCyclic(
extra: ValueExtra | undefined,
exposingNodes: Record<string, Node<unknown>>
): ValueExtra | undefined {
extra?.segments?.forEach((segment) => {
if (segment.success) {
segment.success = !hasCycle(segment.value, exposingNodes);
}
});
return extra;
}

View File

@@ -0,0 +1,29 @@
import { fromUnevaledValue } from "./codeNode";
import { FetchCheckNode } from "./fetchCheckNode";
import { fromRecord } from "./recordNode";
import { fromValue } from "./simpleNode";
it("simple test", () => {
const x = fromRecord({
x1: new FetchCheckNode(fromUnevaledValue("{{hasFetching.x}}")),
x2: new FetchCheckNode(fromUnevaledValue("{{noFetching}}")),
x3: new FetchCheckNode(fromUnevaledValue("{{nestedFetching}}")),
x4: new FetchCheckNode(fromUnevaledValue("{{notReady}}")),
});
const exposingNodes = {
hasFetching: fromRecord({
isFetching: fromValue(true),
x: fromValue(1),
}),
noFetching: fromRecord({}),
nestedFetching: fromUnevaledValue("{{hasFetching}}"),
notReady: fromRecord({
latestEndTime: fromValue(0),
}),
};
const value = x.evaluate(exposingNodes);
expect(value.x1).toEqual({ isFetching: true, ready: true });
expect(value.x2).toEqual({ isFetching: false, ready: true });
expect(value.x3).toEqual({ isFetching: true, ready: true });
expect(value.x4).toEqual({ isFetching: false, ready: false });
});

View File

@@ -0,0 +1,39 @@
import { memoized } from "util/memoize";
import { AbstractNode, FetchInfo, FetchInfoOptions, Node } from "./node";
/**
* evaluate to get FetchInfo or fetching status
*/
export class FetchCheckNode extends AbstractNode<FetchInfo> {
readonly type = "fetchCheck";
constructor(readonly child: Node<unknown>, readonly options?: FetchInfoOptions) {
super();
}
@memoized()
override filterNodes(exposingNodes: Record<string, Node<unknown>>) {
return this.child.filterNodes(exposingNodes);
}
override justEval(exposingNodes: Record<string, Node<unknown>>) {
return this.fetchInfo(exposingNodes);
}
override getChildren(): Node<unknown>[] {
return [this.child];
}
override dependValues(): Record<string, unknown> {
return this.child.dependValues();
}
@memoized()
override fetchInfo(exposingNodes: Record<string, Node<unknown>>) {
return this.child.fetchInfo(exposingNodes, this.options);
}
}
export function isFetching(node: Node<unknown>): Node<FetchInfo> {
return new FetchCheckNode(node);
}

View File

@@ -0,0 +1,46 @@
import { memoized } from "../util/memoize";
import { AbstractNode, FetchInfoOptions, Node } from "./node";
import { EvalMethods } from "./types/evalTypes";
import { evalPerfUtil } from "./utils/perfUtils";
/**
* return a new node, evaluating to a function result with the input node value as the function's input
*/
export class FunctionNode<T, OutputType> extends AbstractNode<OutputType> {
readonly type = "function";
constructor(readonly child: Node<T>, readonly func: (params: T) => OutputType) {
super();
}
@memoized()
override filterNodes(exposingNodes: Record<string, Node<unknown>>) {
return evalPerfUtil.perf(this, "filterNodes", () => {
return this.child.filterNodes(exposingNodes);
});
}
override justEval(
exposingNodes: Record<string, Node<unknown>>,
methods?: EvalMethods
): OutputType {
return this.func(this.child.evaluate(exposingNodes, methods));
}
override getChildren(): Node<unknown>[] {
return [this.child];
}
override dependValues(): Record<string, unknown> {
return this.child.dependValues();
}
@memoized()
override fetchInfo(exposingNodes: Record<string, Node<unknown>>, options?: FetchInfoOptions) {
return this.child.fetchInfo(exposingNodes, options);
}
}
export function withFunction<T, OutputType>(child: Node<T>, func: (params: T) => OutputType) {
return new FunctionNode(child, func);
}

View File

@@ -0,0 +1,23 @@
export * from "./cachedNode";
export * from "./codeNode";
export * from "./fetchCheckNode";
export * from "./functionNode";
export * from "./node";
export * from "./recordNode";
export * from "./simpleNode";
export * from "./wrapNode";
export * from "./wrapContextNode";
export * from "./wrapContextNodeV2";
export { transformWrapper } from "./utils/codeNodeUtils";
export { evalPerfUtil } from "./utils/perfUtils";
export type { EvalMethods, CodeType, CodeFunction } from "./types/evalTypes";
export { ValueAndMsg } from "./types/valueAndMsg";
export { relaxedJSONToJSON } from "./utils/relaxedJson";
export { getDynamicStringSegments, isDynamicSegment } from "./utils/segmentUtils";
export { clearMockWindow, evalFunc, evalScript } from "./utils/evalScript";
export { clearStyleEval, evalStyle } from "./utils/evalStyle";
export { evalFunctionResult, RelaxedJsonParser } from "./utils/string2Fn";
export { nodeIsRecord } from "./utils/nodeUtils";
export { changeDependName } from "./utils/evaluate";
export { FetchCheckNode } from "./fetchCheckNode";

View File

@@ -0,0 +1,79 @@
import {
dependingNodeMapEquals,
fromRecord,
fromUnevaledValue,
fromValue,
Node,
SimpleNode,
withFunction,
} from "eval";
import _ from "lodash";
it("simple test", () => {
const n1 = fromValue("hi");
const n1Value = n1.evaluate();
expect(n1Value).toStrictEqual("hi");
});
it("fromUnevaledValue test", () => {
const n1 = fromUnevaledValue("hi {{1+2}}");
const n1Value = n1.evaluate();
expect(n1Value).toStrictEqual("hi 3");
});
it("eval cache test", () => {
const x = fromRecord({
x1: fromValue("x1Value"),
x2: fromUnevaledValue("x2 {{v2}}"),
});
const v1 = x.evaluate({});
const v2 = x.evaluate({ x1: fromValue("hi") });
expect(v1).toBe(v2);
});
it("integration test", async () => {
const x = fromRecord({
x1: fromValue("x1Value"),
x2: fromUnevaledValue("x2 {{v2}}"),
});
const exposingNodes = {
v1: fromValue("n1Value"),
v2: fromValue(["l1", "l2"]),
v3: fromUnevaledValue("hi {{1+2}} {{v1}}"),
x: x,
};
const n1 = fromUnevaledValue("{{v1}} {{v2}} {{v3}}");
const n2 = fromRecord({
n1: n1,
x: x,
xfn: withFunction(x, (xValue) => xValue.x1 + xValue.x2),
});
const n1Value = n1.evaluate(exposingNodes);
expect(n1Value).toStrictEqual("n1Value l1,l2 hi 3 n1Value");
const n2Value = n2.evaluate(exposingNodes);
expect(n2Value.n1).toStrictEqual("n1Value l1,l2 hi 3 n1Value");
expect(n2Value.x.x1).toStrictEqual("x1Value");
expect(n2Value.x.x2).toStrictEqual("x2 l1,l2");
expect(n2Value.xfn).toStrictEqual("x1Valuex2 l1,l2");
// expect(n2.maxEvalTime()).toEqual(n1.maxEvalTime());
// expect(n2.maxEvalTime()).toBeGreaterThan(x.maxEvalTime());
// expect(latestNode(x, n2).evaluate()).toEqual(n2.evaluate());
});
it("map deep compare test", () => {
const node1 = new SimpleNode(1);
const node2 = new SimpleNode(1);
const map1 = new Map([[node1, new Set(["n"])]]);
const map2 = new Map([[node2, new Set(["n"])]]);
expect(node1 === node2).toBe(false);
expect(_.isEqual(map1, map2)).toBe(true);
expect(dependingNodeMapEquals(map1, map2)).toBe(false);
expect(dependingNodeMapEquals(map1, map1)).toBe(true);
expect(dependingNodeMapEquals(new Map(), new Map())).toBe(true);
expect(dependingNodeMapEquals(undefined, map1)).toBe(false);
expect(dependingNodeMapEquals(new Map(), map1)).toBe(false);
expect(dependingNodeMapEquals(map1, new Map([[node1, new Set([])]]))).toBe(false);
expect(dependingNodeMapEquals(map1, new Map([[node1, new Set(["b"])]]))).toBe(false);
expect(dependingNodeMapEquals(map1, new Map([[node1, new Set(["n"])]]))).toBe(true);
});

View File

@@ -0,0 +1,187 @@
import { EvalMethods } from "./types/evalTypes";
import { evalPerfUtil } from "./utils/perfUtils";
import { WrapNode } from "./wrapNode";
export type NodeToValue<NodeT> = NodeT extends Node<infer ValueType> ? ValueType : never;
export type FetchInfo = {
/**
* whether any of dependencies' node has executing query
*/
isFetching: boolean;
/**
* whether all dependencies' query have be executed once
*/
ready: boolean;
};
/**
* keyof without optional key
*/
type NonOptionalKeys<T> = {
[k in keyof T]-?: undefined extends T[k] ? never : k;
}[keyof T];
/**
* T extends {[key: string]: Node<any> | undefined}
*/
export type RecordOptionalNodeToValue<T> = {
[K in NonOptionalKeys<T>]: NodeToValue<T[K]>;
};
export interface FetchInfoOptions {
ignoreManualDepReadyStatus?: boolean; // ignore check manual query deps ready status
queryName?: string;
}
/**
* the base structure for evaluate
*/
export interface Node<T> {
readonly type: string;
/**
* calculate evaluate result
* @param exposingNodes other dependent Nodes
*/
evaluate(exposingNodes?: Record<string, Node<unknown>>, methods?: EvalMethods): T;
/**
* whether the current or its dependencies have cyclic dependencies
* this function only can be used after evaluate() has been called
*/
hasCycle(): boolean;
/**
* only available after evaluate
*/
dependNames(): string[];
dependValues(): Record<string, unknown>;
/**
* filter the real dependencies, for boosting the evaluation
* @warn
* the results include direct dependencies and dependencies of dependencies.
* since input node's dependencies don't belong to module in the module feature, the node name may duplicate.
*
* FIXME: this should be a protected function.
*/
filterNodes(exposingNodes: Record<string, Node<unknown>>): Map<Node<unknown>, Set<string>>;
fetchInfo(exposingNodes: Record<string, Node<unknown>>, options?: FetchInfoOptions): FetchInfo;
}
export abstract class AbstractNode<T> implements Node<T> {
readonly type: string = "abstract";
evalCache: EvalCache<T> = {};
constructor() {}
evaluate(exposingNodes?: Record<string, Node<unknown>>, methods?: EvalMethods): T {
return evalPerfUtil.perf(this, "eval", () => {
exposingNodes = exposingNodes ?? {};
const dependingNodeMap = this.filterNodes(exposingNodes);
// use cache when equals to the last dependingNodeMap
if (dependingNodeMapEquals(this.evalCache.dependingNodeMap, dependingNodeMap)) {
return this.evalCache.value as T;
}
// initialize cyclic field
this.evalCache.cyclic = false;
const result = this.justEval(exposingNodes, methods);
// write cache
this.evalCache.dependingNodeMap = dependingNodeMap;
this.evalCache.value = result;
if (!this.evalCache.cyclic) {
// check children cyclic
this.evalCache.cyclic = this.getChildren().some((node) => node.hasCycle());
}
return result;
});
}
hasCycle(): boolean {
return this.evalCache.cyclic ?? false;
}
abstract getChildren(): Node<unknown>[];
dependNames(): string[] {
return Object.keys(this.dependValues());
}
abstract dependValues(): Record<string, unknown>;
isHitEvalCache(exposingNodes?: Record<string, Node<unknown>>): boolean {
exposingNodes = exposingNodes ?? {};
const dependingNodeMap = this.filterNodes(exposingNodes);
return dependingNodeMapEquals(this.evalCache.dependingNodeMap, dependingNodeMap);
}
abstract filterNodes(
exposingNodes: Record<string, Node<unknown>>
): Map<Node<unknown>, Set<string>>;
/**
* evaluate without cache
*/
abstract justEval(exposingNodes: Record<string, Node<unknown>>, methods?: EvalMethods): T;
abstract fetchInfo(exposingNodes: Record<string, Node<unknown>>): FetchInfo;
}
interface EvalCache<T> {
dependingNodeMap?: Map<Node<unknown>, Set<string>>;
value?: T;
inEval?: boolean;
cyclic?: boolean;
inIsFetching?: boolean;
inFilterNodes?: boolean;
}
/**
* transform WrapNode in dependingNodeMap to actual node.
* since WrapNode is dynamically constructed in eval process, its reference always changes.
*/
function unWrapDependingNodeMap(depMap: Map<Node<unknown>, Set<string>>) {
const nextMap = new Map<Node<unknown>, Set<string>>();
depMap.forEach((p, n) => {
if (n.type === "wrap") {
nextMap.set((n as InstanceType<typeof WrapNode>).delegate, p);
} else {
nextMap.set(n, p);
}
});
return nextMap;
}
function setEquals(s1: Set<string>, s2?: Set<string>) {
return s2 !== undefined && s1.size === s2.size && Array.from(s2).every((v) => s1.has(v));
}
/**
* check whether 2 dependingNodeMaps are equal
* - Node use "===" to check
* - string[] use deep compare to check
*
* @param dependingNodeMap1 first dependingNodeMap
* @param dependingNodeMap2 second dependingNodeMap
* @returns whether equals
*/
export function dependingNodeMapEquals(
dependingNodeMap1: Map<Node<unknown>, Set<string>> | undefined,
dependingNodeMap2: Map<Node<unknown>, Set<string>>
): boolean {
if (!dependingNodeMap1 || dependingNodeMap1.size !== dependingNodeMap2.size) {
return false;
}
const map1 = unWrapDependingNodeMap(dependingNodeMap1);
const map2 = unWrapDependingNodeMap(dependingNodeMap2);
let result = true;
map2.forEach((paths, node) => {
result = result && setEquals(paths, map1.get(node));
});
return result;
}

View File

@@ -0,0 +1,75 @@
import _ from "lodash";
import { memoized } from "../util/memoize";
import { AbstractNode, FetchInfoOptions, Node, NodeToValue } from "./node";
import { EvalMethods } from "./types/evalTypes";
import { addDepends } from "./utils/dependMap";
import { evalPerfUtil } from "./utils/perfUtils";
export type RecordNodeToValue<T> = { [K in keyof T]: NodeToValue<T[K]> };
/**
* the evaluated value is the record constructed by the children nodes
*/
export class RecordNode<T extends Record<string, Node<unknown>>> extends AbstractNode<
RecordNodeToValue<T>
> {
readonly type = "record";
constructor(readonly children: T) {
super();
}
@memoized()
override filterNodes(exposingNodes: Record<string, Node<unknown>>) {
return evalPerfUtil.perf(this, `filterNodes`, () => {
const result = new Map<Node<unknown>, Set<string>>();
Object.values(this.children).forEach((node) => {
addDepends(result, node.filterNodes(exposingNodes));
});
return result;
});
}
override justEval(
exposingNodes: Record<string, Node<unknown>>,
methods?: EvalMethods
): RecordNodeToValue<T> {
return _.mapValues(this.children, (v, key) =>
evalPerfUtil.perf(this, `eval-${key}`, () => v.evaluate(exposingNodes, methods))
) as RecordNodeToValue<T>;
}
override getChildren(): Node<unknown>[] {
return Object.values(this.children);
}
override dependValues(): Record<string, unknown> {
const nodes = Object.values(this.children);
if (nodes.length === 1) {
return nodes[0].dependValues();
}
let ret: Record<string, unknown> = {};
nodes.forEach((node) => {
Object.entries(node.dependValues()).forEach(([key, value]) => {
ret[key] = value;
});
});
return ret;
}
@memoized()
override fetchInfo(exposingNodes: Record<string, Node<unknown>>, options?: FetchInfoOptions) {
let isFetching = false;
let ready = true;
Object.entries(this.children).forEach(([name, child]) => {
const fi = child.fetchInfo(exposingNodes, options);
isFetching = fi.isFetching || isFetching;
ready = fi.ready && ready;
});
return { isFetching, ready };
}
}
export function fromRecord<T extends Record<string, Node<unknown>>>(record: T) {
return new RecordNode(record);
}

View File

@@ -0,0 +1,17 @@
import { fromValueWithCache } from "./simpleNode";
describe("simpleNode", () => {
it("fromValue's Cache", () => {
const a1Node = fromValueWithCache("a");
const a2Node = fromValueWithCache("a");
expect(a1Node === a2Node).toBeTruthy();
const o1 = { 1: "a", 2: "b" };
const o2 = { 1: "a", 2: "b" };
const o1Node = fromValueWithCache(o1);
const o2Node = fromValueWithCache(o2);
const e1Node = fromValueWithCache(o1);
expect(o1Node === e1Node).toBeTruthy();
expect(o1Node === o2Node).toBeFalsy();
});
});

View File

@@ -0,0 +1,52 @@
import LRU from "lru-cache";
import { memoized } from "util/memoize";
import { AbstractNode, Node } from "./node";
import { evalPerfUtil } from "./utils/perfUtils";
/**
* directly provide data
*/
export class SimpleNode<T> extends AbstractNode<T> {
readonly type = "simple";
constructor(readonly value: T) {
super();
}
@memoized()
override filterNodes(exposingNodes: Record<string, Node<unknown>>) {
return evalPerfUtil.perf(this, "filterNodes", () => {
return new Map<Node<unknown>, Set<string>>();
});
}
override justEval(exposingNodes: Record<string, Node<unknown>>): T {
return this.value;
}
override getChildren(): Node<unknown>[] {
return [];
}
override dependValues(): Record<string, unknown> {
return {};
}
override fetchInfo(exposingNodes: Record<string, Node<unknown>>) {
return {
isFetching: false,
ready: true,
};
}
}
/**
* provide simple value, don't need to eval
*/
export function fromValue<T>(value: T) {
return new SimpleNode(value);
}
const lru = new LRU<unknown, SimpleNode<unknown>>({ max: 16384 });
export function fromValueWithCache<T>(value: T): SimpleNode<T> {
let res = lru.get(value);
if (res === undefined) {
res = fromValue(value);
lru.set(value, res);
}
return res as SimpleNode<T>;
}

View File

@@ -0,0 +1,5 @@
export type EvalMethods = Record<string, Record<string, Function>>;
export type CodeType = undefined | "JSON" | "Function" | "PureJSON";
export type CodeFunction = (args?: Record<string, unknown>, runInHost?: boolean) => any;

View File

@@ -0,0 +1,27 @@
import { toReadableString } from "util/stringUtils";
export type ValueExtra = {
segments?: { value: string; success: boolean }[];
};
export class ValueAndMsg<T> {
value: T;
msg?: string;
extra?: ValueExtra;
midValue?: any; // a middle value after eval and before transform
constructor(value: T, msg?: string, extra?: ValueExtra, midValue?: any) {
this.value = value;
this.msg = msg;
this.extra = extra;
this.midValue = midValue;
}
hasError(): boolean {
return this.msg !== undefined;
}
getMsg(displayValueFn: (value: T) => string = toReadableString): string {
return (this.hasError() ? this.msg : displayValueFn(this.value)) ?? "";
}
}

View File

@@ -0,0 +1,31 @@
import { ValueAndMsg } from "../types/valueAndMsg";
import { getErrorMessage } from "./nodeUtils";
export function transformWrapper<T>(transformFn: (value: unknown) => T, defaultValue?: T) {
function transformWithMsg(valueAndMsg: ValueAndMsg<unknown>): ValueAndMsg<T> {
let result;
try {
const value = transformFn(valueAndMsg.value);
result = new ValueAndMsg<T>(value, valueAndMsg.msg, valueAndMsg.extra, valueAndMsg.value);
} catch (err) {
let value;
try {
value = defaultValue ?? transformFn("");
} catch (err2) {
value = undefined as any;
}
const errorMsg = valueAndMsg.msg ?? getErrorMessage(err);
result = new ValueAndMsg<T>(value, errorMsg, valueAndMsg.extra, valueAndMsg.value);
}
// log.trace(
// "transformWithMsg. func: ",
// transformFn.name,
// "\nsource: ",
// valueAndMsg,
// "\nresult: ",
// result
// );
return result;
}
return transformWithMsg;
}

View File

@@ -0,0 +1,25 @@
import { Node } from "../node";
export function addDepend(
target: Map<Node<unknown>, Set<string>>,
node: Node<unknown> | undefined,
paths: string[] | Set<string>
) {
if (!node) {
return;
}
let value = target.get(node);
if (value === undefined) {
value = new Set();
target.set(node, value);
}
paths.forEach((p) => value?.add(p));
}
export function addDepends(
target: Map<Node<unknown>, Set<string>>,
source?: Map<Node<unknown>, Set<string>>
) {
source?.forEach((paths, node) => addDepend(target, node, paths));
return target;
}

View File

@@ -0,0 +1,203 @@
import {
clearMockWindow,
createBlackHole,
evalFunc,
evalScript,
SandBoxOption,
} from "./evalScript";
test("evalFunc", () => {
expect(() => evalFunc("return fetch();", {})).not.toThrow();
expect(() =>
evalFunc("return fetch('https://example.com/');", {}, undefined, { disableLimit: true })
).not.toThrow();
expect(() =>
evalFunc("setTimeout(() => {});", {}, undefined, { disableLimit: true })
).not.toThrow();
expect(evalFunc("console.info(window.fetch);return window.fetch;", {}, {})).not.toBe(
window.fetch
);
expect(evalFunc("return window.seTimeout", {}, {}, { scope: "expression" })).not.toBe(
window.setTimeout
);
expect(evalFunc("return window.setTimeout", {}, {}, { scope: "function" })).toBe(
window.setTimeout
);
});
test("evalFuncDisableLimit", async () => {
const st = evalFunc("return window.setTimeout", {}, {}, { disableLimit: true });
let i = 1;
await new Promise((r) => {
st(() => {
i += 1;
r("");
});
});
expect(i).toBe(2);
const stMock = evalFunc("return window.setTimeout", {});
const p = await Promise.race([
new Promise((r) => stMock(r)),
new Promise((r) => setTimeout(() => r(1), 100)),
]);
expect(p).toBe(1);
});
describe("evalScript", () => {
it("get", () => {
expect(evalScript("btoa('hello')", {})).toBe("aGVsbG8=");
expect(evalScript("atob('aGVsbG8=')", {})).toBe("hello");
expect(evalScript("isNaN(3)", {})).toBe(false);
expect(evalScript("NaN", {})).toBe(NaN);
expect(evalScript("undefined", {})).toBe(undefined);
expect(evalScript("123//abc", {})).toBe(123);
let context = { input1: { value: { test: 7, tefe: null } } };
expect(evalScript("input1", context)).toStrictEqual({
value: { test: 7, tefe: null },
});
expect(evalScript("JSON.stringify(input1)", context)).toStrictEqual(
JSON.stringify({
value: { test: 7, tefe: null },
})
);
expect(evalScript("input1.value", context)).toStrictEqual({ test: 7, tefe: null });
expect(evalScript("input1.value.test+4", context)).toBe(11);
expect(evalScript("input1.value.tefe", context)).toBe(null);
expect(evalScript("input1.vvv", context)).toBe(undefined);
expect(evalScript("input1.value.vwfe", context)).toBe(undefined);
expect(evalScript("a[0]", { a: [] })).toBe(undefined);
expect(evalScript("abc", {})).toBeUndefined();
expect(evalScript("window.window === window", {})).toBe(true);
expect(evalScript("window.window", {})).not.toBe(window);
expect(evalScript("window.global", {})).not.toBe(window);
expect(evalScript("setTimeout in window", {})).toBe(true);
expect(evalScript("WebSocket in window", {})).toBe(true);
expect(evalScript("anything in window", {})).toBe(true);
expect(evalScript("window.someThingNotExisted", {})).toBe(undefined);
expect(evalScript("JSON.stringify(1+2)", {})).toBe("3");
// mock window doesn't need to throw
expect(() => evalScript("window", {})).not.toThrow();
expect(() => evalScript("globalThis", {})).not.toThrow();
expect(() => evalScript("self", {})).not.toThrow();
expect(() => evalScript("top", {})).not.toThrow();
expect(() => evalScript("parent", {})).not.toThrow();
expect(() => evalScript("document", {})).not.toThrow();
expect(() => evalScript("location", {})).not.toThrow();
expect(() => evalScript("setTimeout(() => {})", {})).not.toThrow();
});
it("set", () => {
// expect(() => evalScript("isNaN=3;", {})).toThrow();
// expect(() => evalScript("NaN=3", {})).toThrow();
// expect(() => evalScript("undefined=3", {})).toThrow();
let context = { input1: { value: { test: 7 } } };
expect(() => evalScript("input1=3", context)).toThrow();
expect(() => evalScript("input1.value=3", context)).toThrow();
expect(() => evalScript("input1.value.test=3", context)).toThrow();
expect(evalScript("input1.value.test", context)).toBe(7);
// expect(() => evalScript("JSON=3", {})).toThrow();
// expect(() => evalScript("undefined=3", {})).toThrow();
expect(() => evalScript("window=3", {})).toThrow();
expect(() => evalScript("self=3", {})).toThrow();
expect(() => evalScript("globalThis=3", {})).toThrow();
// expect(() => evalScript("top=3", {})).toThrow();
// expect(() => evalScript("parent=3", {})).toThrow();
// expect(() => evalScript("document=3", {})).toThrow();
// expect(() => evalScript("location=3", {})).toThrow();
// expect(() => evalScript("setTimeout=3", {})).toThrow();
// expect(() => evalScript("abc=3", {})).toThrow();
});
it("defineProperty", () => {
expect(() => evalScript("Object.defineProperty(this, 'isNaN', {})", {})).not.toThrow();
expect(() => evalScript("Object.defineProperty(this, 'NaN', {})", {})).not.toThrow();
expect(() => evalScript("Object.defineProperty(this, 'undefined', {})", {})).not.toThrow();
expect(() => evalScript("Object.defineProperty(this, 'setTimeout', {})", {})).not.toThrow();
expect(() => evalScript("Object.defineProperty(this, 'window', {})", {})).toThrow();
let context = { input1: { value: { test: 7 } } };
expect(() => evalScript("Object.defineProperty(this, 'input1', {})", context)).toThrow();
expect(() => evalScript("Object.defineProperty(input1, 'value1', {})", context)).toThrow();
expect(() => evalScript("Object.defineProperty(input1.value, 'test1', {})", context)).toThrow();
});
it("deleteProperty", () => {
// expect(() => evalScript("delete this.isNaN", {})).toThrow();
// expect(() => evalScript("delete isNaN", {})).toThrow();
expect(() => evalScript("delete this.window", {})).toThrow();
expect(() => evalScript("delete window", {})).toThrow();
let context = { input1: { value: { test: 7 } } };
expect(() => evalScript("delete input1", context)).toThrow();
expect(() => evalScript("delete this.input1", context)).toThrow();
expect(() => evalScript("delete input1.value", context)).toThrow();
expect(() => evalScript("delete input1.value.test", context)).toThrow();
});
it("setPrototypeOf", () => {
let context = { input1: { value: { test: 7 } } };
expect(() => evalScript("Object.setPrototypeOf(input1, {})", context)).toThrow();
expect(() => evalScript("Object.setPrototypeOf(input1.value, {})", context)).toThrow();
});
it("mockWindow", () => {
clearMockWindow();
expect(() => evalScript("window.setTimeout", {})).not.toThrow();
expect(evalScript("window.crypto", {})).not.toBeUndefined();
expect(evalScript("this.crypto", {})).not.toBeUndefined();
expect(evalScript("crypto", {})).not.toBeUndefined();
evalFunc("window.a1 = 1;", {});
expect(evalScript("window.a1", {})).toBe(1);
});
it("onSetGlobalVars", () => {
clearMockWindow();
let globalVars: string[] = [];
const onSetGlobalVars = (v: string) => {
if (!globalVars.includes(v)) {
globalVars.push(v);
}
};
const options: SandBoxOption = { onSetGlobalVars, scope: "function" };
evalFunc("a = 1; window.b = 2; this.c = 3;", {}, {}, options);
expect(globalVars).toEqual(["a", "b", "c"]);
globalVars = [];
evalFunc("d = 1; window.e = 2; ; window.window.g = 2; this.f = 3;", {}, {}, options);
expect(globalVars).toEqual(["d", "e", "g", "f"]);
});
it("black hole is everything", () => {
const a = createBlackHole();
[
() => a(),
() => a + "",
() => a.toString(),
() => JSON.stringify(a),
() => a.b.c.e.f.g,
() => a ** 2,
() => a.trim(),
() =>
JSON.stringify(a, (k, v) => {
switch (typeof v) {
case "bigint":
return v.toString();
}
return v;
}),
].forEach((i) => {
expect(i).not.toThrow();
});
});
});

View File

@@ -0,0 +1,232 @@
import { EvalMethods } from "../types/evalTypes";
import log from "loglevel";
// global variables black list, forbidden to use in for jsQuery/jsAction
const functionBlacklist = new Set<PropertyKey>([
"top",
"parent",
"document",
"location",
"chrome",
"fetch",
"XMLHttpRequest",
"importScripts",
"Navigator",
"MutationObserver",
]);
const expressionBlacklist = new Set<PropertyKey>([
...Array.from(functionBlacklist.values()),
"setTimeout",
"setInterval",
"setImmediate",
]);
const globalVarNames = new Set<PropertyKey>(["window", "globalThis", "self", "global"]);
export function createBlackHole(): any {
return new Proxy(
function () {
return createBlackHole();
},
{
get(t, p, r) {
if (p === "toString") {
return function () {
return "";
};
}
if (p === Symbol.toPrimitive) {
return function () {
return "";
};
}
log.log(`[Sandbox] access ${String(p)} on black hole, return mock object`);
return createBlackHole();
},
}
);
}
function createMockWindow(
base?: object,
blacklist: Set<PropertyKey> = expressionBlacklist,
onSet?: (name: string) => void,
disableLimit?: boolean
) {
const win: any = new Proxy(Object.assign({}, base), {
has() {
return true;
},
set(target, p, newValue) {
if (typeof p === "string") {
onSet?.(p);
}
return Reflect.set(target, p, newValue);
},
get(target, p) {
if (p in target) {
return Reflect.get(target, p);
}
if (globalVarNames.has(p)) {
return win;
}
if (typeof p === "string" && blacklist?.has(p) && !disableLimit) {
log.log(`[Sandbox] access ${String(p)} on mock window, return mock object`);
return createBlackHole();
}
return getPropertyFromNativeWindow(p);
},
});
return win;
}
let mockWindow: any;
let currentDisableLimit: boolean = false;
export function clearMockWindow() {
mockWindow = createMockWindow();
}
export type SandboxScope = "function" | "expression";
export interface SandBoxOption {
/**
* disable all limit, like running in host
*/
disableLimit?: boolean;
/**
* the scope this sandbox works in, which will use different blacklist
*/
scope?: SandboxScope;
/**
* handler when set global variables to sandbox, only be called when scope is function
*/
onSetGlobalVars?: (name: string) => void;
}
function isDomElement(obj: any): boolean {
return obj instanceof Element || obj instanceof HTMLCollection;
}
function getPropertyFromNativeWindow(prop: PropertyKey) {
const ret = Reflect.get(window, prop);
if (typeof ret === "function" && !ret.prototype) {
return ret.bind(window);
}
// get DOM element by id, serializing may cause error
if (isDomElement(ret)) {
return undefined;
}
return ret;
}
function proxySandbox(context: any, methods?: EvalMethods, options?: SandBoxOption) {
const { disableLimit = false, scope = "expression", onSetGlobalVars } = options || {};
const isProtectedVar = (key: PropertyKey) => {
return key in context || key in (methods || {}) || globalVarNames.has(key);
};
const cache = {};
const blacklist = scope === "function" ? functionBlacklist : expressionBlacklist;
if (scope === "function" || !mockWindow || disableLimit !== currentDisableLimit) {
mockWindow = createMockWindow(mockWindow, blacklist, onSetGlobalVars, disableLimit);
}
currentDisableLimit = disableLimit;
return new Proxy(mockWindow, {
has(target, p) {
// proxy all variables
return true;
},
get(target, p, receiver) {
if (p === Symbol.unscopables) {
return undefined;
}
if (p === "toJSON") {
return target;
}
if (globalVarNames.has(p)) {
return target;
}
if (p in context) {
if (p in cache) {
return Reflect.get(cache, p);
}
let value = Reflect.get(context, p, receiver);
if (typeof value === "object" && value !== null) {
if (methods && p in methods) {
value = Object.assign({}, value, Reflect.get(methods, p));
}
Object.freeze(value);
Object.values(value).forEach(Object.freeze);
}
Reflect.set(cache, p, value);
return value;
}
if (disableLimit) {
return getPropertyFromNativeWindow(p);
}
return Reflect.get(target, p, receiver);
},
set(target, p, value, receiver) {
if (isProtectedVar(p)) {
throw new Error(p.toString() + " can't be modified");
}
return Reflect.set(target, p, value, receiver);
},
defineProperty(target, p, attributes) {
if (isProtectedVar(p)) {
throw new Error("can't define property:" + p.toString());
}
return Reflect.defineProperty(target, p, attributes);
},
deleteProperty(target, p) {
if (isProtectedVar(p)) {
throw new Error("can't delete property:" + p.toString());
}
return Reflect.deleteProperty(target, p);
},
setPrototypeOf(target, v) {
throw new Error("can't invoke setPrototypeOf");
},
});
}
export function evalScript(script: string, context: any, methods?: EvalMethods) {
return evalFunc(`return (${script}\n);`, context, methods);
}
export function evalFunc(
functionBody: string,
context: any,
methods?: EvalMethods,
options?: SandBoxOption,
isAsync?: boolean
) {
const code = `with(this){
return (${isAsync ? "async " : ""}function() {
'use strict';
${functionBody};
}).call(this);
}`;
// eslint-disable-next-line no-new-func
const vm = new Function(code);
const sandbox = proxySandbox(context, methods, options);
const result = vm.call(sandbox);
return result;
}

View File

@@ -0,0 +1,39 @@
import { compile, serialize, middleware, prefixer, stringify } from "stylis";
function styleNamespace(id: string) {
return `style-for-${id}`;
}
export function evalStyle(id: string, css: string[], globalStyle?: boolean) {
const styleId = styleNamespace(id);
const prefixId = globalStyle ? id : `.${id}`;
let compiledCSS = "";
css.forEach((i) => {
if (!i.trim()) {
return;
}
compiledCSS += serialize(compile(`${prefixId}{${i}}`), middleware([prefixer, stringify]));
});
let styleNode = document.querySelector(`#${styleId}`);
if (!styleNode) {
styleNode = document.createElement("style");
styleNode.setAttribute("type", "text/css");
styleNode.setAttribute("id", styleId);
styleNode.setAttribute("data-style-src", "eval");
document.querySelector("head")?.appendChild(styleNode);
}
styleNode.textContent = compiledCSS;
}
export function clearStyleEval(id?: string) {
const styleId = id && styleNamespace(id);
const styleNode = document.querySelectorAll(`style[data-style-src=eval]`);
if (styleNode) {
styleNode.forEach((i) => {
if (!styleId || styleId === i.id) {
i.remove();
}
});
}
}

View File

@@ -0,0 +1,94 @@
import { fromValue } from "eval/simpleNode";
import _ from "lodash";
import { changeDependName, filterDepends } from "./evaluate";
describe("deps", () => {
test("filterDeps", () => {
const context = {
data: fromValue([]),
i: fromValue(0),
str: fromValue(""),
};
const depsA = filterDepends("{{data[str].b}}", context);
expect(depsA.has(context.data)).toBe(true);
expect(depsA.has(context.str)).toBe(true);
expect(depsA.has(context.i)).toBe(false);
const depsB = filterDepends("{{data[i]['a']}}", context);
expect(depsB.has(context.data)).toBe(true);
expect(depsB.has(context.str)).toBe(false);
expect(depsB.has(context.i)).toBe(true);
const depsC = filterDepends("{{str[data[i].length]}}", context);
expect(depsC.has(context.data)).toBe(true);
expect(depsC.has(context.str)).toBe(true);
expect(depsC.has(context.i)).toBe(true);
const depsD = filterDepends("{{str[data.length]}}", context);
expect(depsD.has(context.data)).toBe(true);
expect(depsD.has(context.str)).toBe(true);
expect(depsD.has(context.i)).toBe(false);
expect(filterDepends("{{new Date().toLocaleString()}}", context)).toStrictEqual(new Map());
});
});
describe("changeDependName", () => {
it("changeDependName", () => {
expect(changeDependName("", "input1", "hello")).toBe("");
expect(changeDependName("input1", "input1", "hello")).toBe("input1");
expect(changeDependName(" abcinput1de ", "input1", "hello")).toBe(" abcinput1de ");
expect(changeDependName(" abc input1 de ", "input1", "hello")).toBe(" abc input1 de ");
expect(changeDependName(" abc input1{{}} de ", "input1", "hello")).toBe(
" abc input1{{}} de "
);
expect(changeDependName(" abc input1{{input1}} de ", "input1", "hello")).toBe(
" abc input1{{hello}} de "
);
expect(changeDependName(" abc input1{{ input1 }} de ", "input1", "hello")).toBe(
" abc input1{{ hello }} de "
);
expect(changeDependName(" abc input1{{input1.va}} de ", "input1", "hello")).toBe(
" abc input1{{hello.va}} de "
);
expect(changeDependName(" abc input1{{input12}} de ", "input1", "hello")).toBe(
" abc input1{{input12}} de "
);
expect(changeDependName(" abc input1{{ainput1}} de ", "input1", "hello")).toBe(
" abc input1{{ainput1}} de "
);
expect(
changeDependName(
" abc input1{{343 + input1.va + input1.de + input12 + input1}} de{{input1.ef.gg}} ",
"input1",
"hello"
)
).toBe(" abc input1{{343 + hello.va + hello.de + input12 + hello}} de{{hello.ef.gg}} ");
expect(changeDependName(" abc input1{{a}} de ", "a", "hello")).toBe(
" abc input1{{hello}} de "
);
expect(changeDependName(" abc input1{{ a }} de ", "a", "hello")).toBe(
" abc input1{{ hello }} de "
);
expect(changeDependName(" abc input1{{a.a}} de ", "a", "hello")).toBe(
" abc input1{{hello.a}} de "
);
expect(changeDependName(" abc input1{{a2}} de ", "a", "hello")).toBe(" abc input1{{a2}} de ");
expect(changeDependName(" abc input1{{aa}} de ", "a", "hello")).toBe(" abc input1{{aa}} de ");
expect(changeDependName(" abc input1{{a[b]}} de ", "b", "hello")).toBe(
" abc input1{{a[hello]}} de "
);
expect(changeDependName(" abc input1{{a[b.length]}} de ", "b", "b2")).toBe(
" abc input1{{a[b2.length]}} de "
);
expect(changeDependName(" abc input1{{a[b[i].length]}} de ", "i", "j")).toBe(
" abc input1{{a[b[j].length]}} de "
);
expect(changeDependName(" abc input1{{a[b[i].length]}} de ", "b", "b2")).toBe(
" abc input1{{a[b2[i].length]}} de "
);
});
});

View File

@@ -0,0 +1,154 @@
import _ from "lodash";
import { Node } from "../node";
import { addDepends, addDepend } from "./dependMap";
import { nodeIsRecord } from "./nodeUtils";
import { getDynamicStringSegments, isDynamicSegment } from "./segmentUtils";
export function filterDepends(
unevaledValue: string,
exposingNodes: Record<string, Node<unknown>>,
maxDepth?: number
) {
const ret = new Map<Node<unknown>, Set<string>>();
for (const segment of getDynamicStringSegments(unevaledValue)) {
if (isDynamicSegment(segment)) {
addDepends(ret, parseDepends(segment.slice(2, -2), exposingNodes, maxDepth));
}
}
return ret;
}
export function hasCycle(segment: string, exposingNodes: Record<string, Node<unknown>>): boolean {
if (!isDynamicSegment(segment)) {
return false;
}
let ret = false;
parseDepends(segment.slice(2, -2), exposingNodes).forEach((paths, node) => {
ret = ret || node.hasCycle();
});
return ret;
}
export function changeDependName(
unevaledValue: string,
oldName: string,
name: string,
isFunction?: boolean
) {
if (!unevaledValue || !oldName || !name) {
return unevaledValue;
}
if (isFunction) {
return rename(unevaledValue, oldName, name);
}
return getDynamicStringSegments(unevaledValue)
.map((segment) => {
if (!isDynamicSegment(segment)) {
return segment;
}
return rename(segment, oldName, name);
})
.join("");
}
function rename(segment: string, oldName: string, name: string) {
const accessors = [".", "["];
const regStrList = ["[a-zA-Z_$][a-zA-Z_$0-9.[\\]]*", "\\[[a-zA-Z_][a-zA-Z_0-9.]*"];
let ret = segment;
for (const regStr of regStrList) {
const reg = new RegExp(regStr, "g");
ret = ret.replace(reg, (s) => {
if (s === oldName) {
return name;
}
let origin = oldName;
let target = name;
let matched = false;
if (s.startsWith(`[${origin}`)) {
origin = `[${origin}`;
target = `[${name}`;
matched = true;
}
for (const accessor of accessors) {
if (s.startsWith(origin + accessor)) {
matched = true;
target = target + accessor + s.substring(origin.length + accessor.length);
break;
}
}
if (matched) {
return target;
}
return s;
});
}
return ret;
}
function getIdentifiers(jsSnippet: string): string[] {
const ret: string[] = [];
const commonReg = /[a-zA-Z_$][a-zA-Z_$0-9.[\]]*/g;
const commonIds = jsSnippet.match(commonReg);
if (commonIds) {
ret.push(...commonIds);
}
const indexIds: string[] = [];
(jsSnippet.match(/\[[a-zA-Z_][a-zA-Z_0-9\[\].]*\]/g) || []).forEach((i) => {
indexIds.push(...getIdentifiers(i.slice(1, -1)));
});
ret.push(...indexIds);
if (ret.length === 0) {
return [jsSnippet];
}
return ret;
}
function parseDepends(
jsSnippet: string,
exposingNodes: Record<string, Node<unknown>>,
maxDepth?: number
) {
const depends = new Map<Node<unknown>, Set<string>>();
const identifiers = getIdentifiers(jsSnippet);
identifiers.forEach((identifier) => {
const subpaths = _.toPath(identifier);
const depend = getDependNode(maxDepth ? subpaths.slice(0, maxDepth) : subpaths, exposingNodes);
if (depend) {
addDepend(depends, depend[0], [depend[1]]);
}
});
return depends;
}
function getDependNode(
subPaths: string[],
exposingNodes: Record<string, Node<unknown>>
): [Node<unknown>, string] | undefined {
if (subPaths.length <= 0) {
return undefined;
}
let nodes = exposingNodes;
let node = undefined;
const path = [];
for (const subPath of subPaths) {
const subNode = nodes[subPath];
if (!nodes.hasOwnProperty(subPath) || !subNode) {
break;
}
node = subNode;
path.push(subPath);
if (!nodeIsRecord(node)) {
break;
}
nodes = node.children;
}
return node ? [node, path.join(".")] : undefined;
}

View File

@@ -0,0 +1,73 @@
import { CodeNode } from "../codeNode";
import { Node } from "../node";
import { fromRecord, RecordNode } from "../recordNode";
export function dependsErrorMessage(node: CodeNode) {
return `DependencyError: "${node.unevaledValue}" caused a cyclic dependency.`;
}
export function getErrorMessage(err: unknown) {
// todo try to use 'err instanceof EvalTypeError' instead
if (err instanceof TypeError && (err as any).hint) {
return (err as any).hint + "\n" + err.name + ": " + err.message;
}
return err instanceof Error
? err.name + ": " + err.message
: "UnknownError: unknown exception during eval";
}
export function mergeNodesWithSameName(
map: Map<Node<unknown>, Set<string>>
): Record<string, Node<unknown>> {
const nameDepMap: Record<string, Node<unknown>> = {};
map.forEach((paths, node) => {
paths.forEach((p) => {
const path = p.split(".");
const dep = genDepends(path, node);
const name = path[0];
const newDep = mergeNode(nameDepMap[name], dep);
nameDepMap[name] = newDep;
});
});
return nameDepMap;
}
function genDepends(path: string[], node: Node<unknown>): Node<unknown> {
if (path.length <= 0) {
throw new Error("path length should not be 0");
}
if (path.length === 1) {
return node;
}
return genDepends(path.slice(0, -1), fromRecord({ [path[path.length - 1]]: node }));
}
// node2 mostly has one path
export function mergeNode(node1: Node<unknown> | undefined, node2: Node<unknown>): Node<unknown> {
if (!node1 || node1 === node2) {
return node2;
}
if (!nodeIsRecord(node1) || !nodeIsRecord(node2)) {
throw new Error("unevaledNode should be type of RecordNode");
}
const record1 = node1.children;
const record2 = node2.children;
const record = { ...record1 };
Object.keys(record2).forEach((name) => {
const subNode1 = record1[name];
const subNode2 = record2[name];
let subNode: Node<unknown> = subNode1 ? mergeNode(subNode1, subNode2) : subNode2;
record[name] = subNode;
});
return fromRecord(record);
}
export function nodeIsRecord(
node: Node<unknown>
): node is RecordNode<Record<string, Node<unknown>>> {
return node.type === "record";
}

View File

@@ -0,0 +1,104 @@
import _ from "lodash";
const SWITCH_PERF_ON = false;
const COST_MS_PRINT_THR = 0;
interface PerfInfo {
obj: any;
name: string;
childrenPerfInfo: PerfInfo[];
costMs: number;
depth: number;
info: Record<string, any>;
}
type Log = (key: string, log: any) => void;
class RecursivePerfUtil {
root = Symbol("root");
record: PerfInfo;
stack: number[] = [];
constructor() {
this.record = this.initRecord();
}
private initRecord = () => {
return { obj: this.root, name: "@root", childrenPerfInfo: [], costMs: 0, depth: 0, info: {} };
};
private getRecordByStack = (stack?: number[]) => {
let curRecord = this.record;
(stack ?? this.stack).forEach((idx) => {
curRecord = curRecord.childrenPerfInfo[idx];
});
return curRecord;
};
log(info: Record<string, any>, key: string, log: any) {
info[key] = log;
}
perf<T>(obj: any, name: string, fn: (log: Log) => T): T {
if (!SWITCH_PERF_ON) {
return fn(_.noop);
}
const curRecord = this.getRecordByStack();
const childrenSize = _.size(curRecord.childrenPerfInfo);
const nextPerfInfo = {
obj,
name,
childrenPerfInfo: [],
costMs: 0,
depth: curRecord.depth + 1,
info: {},
};
curRecord.childrenPerfInfo.push(nextPerfInfo);
this.stack.push(childrenSize);
const startMs = performance.now();
const wrapLog: Log = (key, log) => this.log(nextPerfInfo.info, key, log);
const result = fn(wrapLog);
const costMs = performance.now() - startMs;
this.stack.pop();
curRecord.childrenPerfInfo[childrenSize].costMs = costMs;
curRecord.costMs += costMs;
return result;
}
clear = () => {
this.record = this.initRecord();
};
print = (stack: number[], cost_ms_print_thr: number = COST_MS_PRINT_THR) => {
const record = this.getRecordByStack(stack);
console.info(
`~~ PerfInfo. costMs: ${record.costMs.toFixed(3)}, stack: ${stack}, [name]${
record.name
}, [info]`,
record.info,
`, obj: `,
record.obj,
`, depth: ${record.depth}, size: ${_.size(record.childrenPerfInfo)}`
);
record.childrenPerfInfo.forEach((subRecord, idx) => {
if (subRecord.costMs >= cost_ms_print_thr) {
console.info(
` costMs: ${subRecord.costMs.toFixed(3)} [${idx}]${subRecord.name} [info]`,
subRecord.info,
`. obj: `,
subRecord.obj,
``
);
}
});
};
}
export const evalPerfUtil = new RecursivePerfUtil();
// @ts-ignore
globalThis.evalPerfUtil = evalPerfUtil;

View File

@@ -0,0 +1,8 @@
import { toJson } from "really-relaxed-json";
export function relaxedJSONToJSON(text: string, compact: boolean): string {
if (text.trim().length === 0) {
return "";
}
return toJson(text, compact);
}

View File

@@ -0,0 +1,44 @@
import { getDynamicStringSegments, isDynamicSegment } from "./segmentUtils";
describe("isDynmicSegment", () => {
it("legal dynamic string should return true from isDynamicSegment", () => {
expect(isDynamicSegment("{{}}")).toBe(true);
expect(isDynamicSegment("{{ button }}")).toBe(true);
});
it("illegal dynamic string should not return true from isDynamicSegment", () => {
expect(isDynamicSegment("{{")).toBe(false);
});
});
describe.each([
["", []],
["{{A}}", ["{{A}}"]],
["A {{B}}", ["A ", "{{B}}"]],
[
"Hello {{Customer.Name}}, the status for your order id {{orderId}} is {{status}}",
[
"Hello ",
"{{Customer.Name}}",
", the status for your order id ",
"{{orderId}}",
" is ",
"{{status}}",
],
],
["{{data.map(datum => {return {id: datum}})}}", ["{{data.map(datum => {return {id: datum}})}}"]],
["{{}}{{}}}", ["{{}}", "{{}}", "}"]],
["{{{}}", ["{", "{{}}"]],
["a{{{A}}}b", ["a", "{{{A}}}", "b"]],
["{{{A}} {{B}}}", ["{", "{{A}}", " ", "{{B}}", "}"]],
["#{{'{'}}", ["#", "{{'{'}}"]],
["{{", ["{{"]],
["}}", ["}}"]],
["{{ {{", ["{{ {{"]],
["}} }}", ["}} }}"]],
["}} {{", ["}} {{"]],
])("getDynamicStringSegments(%s, %j)", (dynamicString, expected) => {
test(`returns ${expected}`, () => {
expect(getDynamicStringSegments(dynamicString as string)).toStrictEqual(expected);
});
});

View File

@@ -0,0 +1,49 @@
const DYNAMIC_SEGMENT_REGEX = /{{([\s\S]*?)}}/;
export function isDynamicSegment(segment: string): boolean {
return DYNAMIC_SEGMENT_REGEX.test(segment);
}
export function getDynamicStringSegments(input: string): string[] {
const segments = [];
let position = 0;
let start = input.indexOf("{{");
while (start >= 0) {
let i = start + 2;
while (i < input.length && input[i] === "{") i++;
let end = input.indexOf("}}", i);
if (end < 0) {
break;
}
const nextStart = input.indexOf("{{", end + 2);
const maxIndex = nextStart >= 0 ? nextStart : input.length;
const maxStartOffset = i - start - 2;
let sum = i - start;
let minValue = Number.MAX_VALUE;
let minOffset = Number.MAX_VALUE;
for (; i < maxIndex; i++) {
switch (input[i]) {
case "{":
sum++;
break;
case "}":
sum--;
if (input[i - 1] === "}") {
const offset = Math.min(Math.max(sum, 0), maxStartOffset);
const value = Math.abs(sum - offset);
if (value < minValue || (value === minValue && offset < minOffset)) {
minValue = value;
minOffset = offset;
end = i + 1;
}
}
break;
}
}
segments.push(input.slice(position, start + minOffset), input.slice(start + minOffset, end));
position = end;
start = nextStart;
}
segments.push(input.slice(position));
return segments.filter((t) => t);
}

View File

@@ -0,0 +1,167 @@
import { evalFunction, evalJson } from "./string2Fn";
function test1() {
return 5;
}
function test2() {
return 7;
}
describe.each([
["", ""],
[" ", ""],
[" {{'abc'}}", "abc"],
["{{123}} ", 123],
["{{true}}", true],
["{{false}}", false],
[" ab cef ", "ab cef"],
[" ab cef{{123}} ", "ab cef123"],
["[ab, {{123}}]", ["ab", 123]],
// relaxed json key
['{ "": 666 }', { "": 666 }],
['{ " ": 666 }', { " ": 666 }],
['{ "key": 666 }', { key: 666 }],
['{ "key": "\\{" }', { key: "{" }],
['{ "key": "\\\\{" }', { key: "\\{" }],
['{ " abc key e ": 666 }', { " abc key e ": 666 }],
["{ ' abc key e ': 666 }", { " abc key e ": 666 }],
["{ null: 666 }", { null: 666 }],
["{ key: 666 }", { key: 666 }],
["{ 123: 666 }", { "123": 666 }],
["{ 12.3: 666 }", { "12.3": 666 }],
["[{ 12.3: 666 }]", [{ "12.3": 666 }]],
// relaxed json value
["{ 'kkk': \"\" }", { kkk: "" }],
["{ 'kkk': \" \" }", { kkk: " " }],
["{ 'kkk': \"key\" }", { kkk: "key" }],
["{ 'kkk': \" abc key e \" }", { kkk: " abc key e " }],
["{ 'kkk': ' abc key e ' }", { kkk: " abc key e " }],
["{ 'kkk': null }", { kkk: null }],
["{ 'kkk': key }", { kkk: "key" }],
["{ 'kkk': 123 }", { kkk: 123 }],
["{ 'kkk': 12.3 }", { kkk: 12.3 }],
// relaxed json key, with {{}}
["{ {{}}: 666 }", { "": 666 }],
["{ {{cnull}}: 666 }", { null: 666 }],
["{ {{s0}}: 666 }", { "": 666 }],
["{ {{s1}}: 666 }", { " ": 666 }],
["{ {{s2}}: 666 }", { "abc def": 666 }],
["{ {{s3}}: 666 }", { " fdf fdfd'\" ": 666 }],
["{ {{n1}}: 666 }", { "57": 666 }],
["{ {{n2}}: 666 }", { "4.69": 666 }],
["{ {{ n1 + n2 }} : 666 }", { "61.69": 666 }],
["{ {{ n1 + n2 }}end : 666 }", { "61.69end": 666 }],
["{ st{{ n1 + n2 }}mi{{7.3}}end : 666 }", { "st61.69mi7.3end": 666 }],
// relaxed json value, with {{}}
["{ 'kkk': {{}} }", { kkk: "" }],
["{ 'kkk': {{cnull}} }", { kkk: null }],
["{ 'kkk': {{s0}} }", { kkk: "" }],
["{ 'kkk': {{s1}} }", { kkk: " " }],
["{ 'kkk': {{s2}} }", { kkk: "abc def" }],
["{ 'kkk': {{s3}} }", { kkk: " fdf fdfd'\" " }],
["{ 'kkk': {{n1}} }", { kkk: 57 }],
["{ 'kkk': {{n2}} }", { kkk: 4.69 }],
["{ 'kkk': {{o1}} }", { kkk: { k1: 1, k2: "vv" } }],
["{ 'kkk': {{o2}} }", { kkk: [{ k1: 1, k2: "vv" }] }],
["{ 'kkk': {{ n1 + n2 }} }", { kkk: 61.69 }],
["{ 'kkk': {{ n1 + n2 }}end }", { kkk: "61.69end" }],
["{ 'kkk': st{{ n1 + n2 }}mi{{7.3}}end }", { kkk: "st61.69mi7.3end" }],
// relaxed json key, with {{}}, with quotes
["{ '{{}}': 666 }", { "": 666 }],
["{ '{{cnull}}': 666 }", { null: 666 }],
["{ '{{s0}}': 666 }", { "": 666 }],
["{ '{{s1}}': 666 }", { " ": 666 }],
["{ '{{s2}}': 666 }", { "abc def": 666 }],
["{ '{{s3}}': 666 }", { " fdf fdfd'\" ": 666 }],
["{ '{{n1}}': 666 }", { "57": 666 }],
["{ '{{n2}}': 666 }", { "4.69": 666 }],
["{ '{{ n1 + n2 }}' : 666 }", { "61.69": 666 }],
["{ '{{ n1 + n2 }}end' : 666 }", { "61.69end": 666 }],
["{ 'st{{ n1 + n2 }}mi{{7.3}}end' : 666 }", { "st61.69mi7.3end": 666 }],
['{ "st{{ n1 + n2 }}mi{{7.3}}end" : 666 }', { "st61.69mi7.3end": 666 }],
// relaxed json value, with {{}}, with quotes
["{ 'kkk': '{{}}' }", { kkk: "" }],
["{ 'kkk': '{{cnull}}' }", { kkk: "null" }],
["{ 'kkk': '{{s0}}' }", { kkk: "" }],
["{ 'kkk': '{{s1}}' }", { kkk: " " }],
["{ 'kkk': '{{s2}}' }", { kkk: "abc def" }],
["{ 'kkk': '{{s3}}' }", { kkk: " fdf fdfd'\" " }],
["{ 'kkk': '{{n1}}' }", { kkk: "57" }],
["{ 'kkk': '{{n2}}' }", { kkk: "4.69" }],
["{ 'kkk': '{{ n1 + n2 }}' }", { kkk: "61.69" }],
["{ 'kkk': '{{ n1 + n2 }}end' }", { kkk: "61.69end" }],
["{ 'kkk': 'st{{ n1 + n2 }}mi{{7.3}}end' }", { kkk: "st61.69mi7.3end" }],
["{ 'kkk': \"st{{ n1 + n2 }}mi{{7.3}}end\" }", { kkk: "st61.69mi7.3end" }],
["[{ 'kkk': \"st{{ n1 + n2 }}mi{{7.3}}end\" }]", [{ kkk: "st61.69mi7.3end" }]],
// relaxed json with function
["{a: {{func1}}, b: {c: {{func2}}}}", { a: test1, b: { c: test2 } }],
["{{{'a': func1, 'b': {'c': func2 }}}}", { a: test1, b: { c: test2 } }],
])("evalJson(%s)", (value, expected) => {
test(`returns ${expected}`, () => {
const context = {
cnull: null,
s0: "",
s1: " ",
s2: "abc def",
s3: " fdf fdfd'\" ",
n1: 57,
n2: 4.69,
o1: { k1: 1, k2: "vv" },
o2: [{ k1: 1, k2: "vv" }],
func1: test1,
func2: test2,
};
expect(evalJson(value as string, context).value).toStrictEqual(expected);
});
});
test("evalFunction", async () => {
let context = { input1: { value: 123 } };
const methods = {
input1: {
setValue: async (v: number) => {
await new Promise((resolve) =>
setTimeout(() => {
context.input1.value = v;
resolve(undefined);
}, 20)
);
},
},
};
const ret = await evalFunction(
"input1.setValue(456); return input1.value//abc",
context,
methods
).value();
expect(ret).toBe(123);
expect(context.input1.value).toBe(123);
});
test("evalFunction return promise", async () => {
let context = { input1: { value: 123 } };
const methods = {
input1: {
setValue: async (v: number) => {
await new Promise((resolve) =>
setTimeout(() => {
context.input1.value = v;
resolve(undefined);
}, 20)
);
},
},
};
const ret = await evalFunction("return input1.setValue(456)//abc", context, methods).value();
expect(context.input1.value).toBe(456);
});

View File

@@ -0,0 +1,212 @@
import { ValueAndMsg } from "../types/valueAndMsg";
import _ from "lodash";
import { getErrorMessage } from "./nodeUtils";
import { evalFunc, evalScript, SandboxScope } from "./evalScript";
import { getDynamicStringSegments, isDynamicSegment } from "./segmentUtils";
import { CodeFunction, CodeType, EvalMethods } from "../types/evalTypes";
import { relaxedJSONToJSON } from "./relaxedJson";
export type Fn = (context: Record<string, unknown>) => ValueAndMsg<unknown>;
function call(
content: string,
context: Record<string, unknown>,
segment: string
): ValueAndMsg<unknown> {
if (!content) {
return new ValueAndMsg("", undefined, { segments: [{ value: segment, success: true }] });
}
try {
const value = evalScript(content, context);
return new ValueAndMsg(value, undefined, { segments: [{ value: segment, success: true }] });
} catch (err) {
return new ValueAndMsg("", getErrorMessage(err), {
segments: [{ value: segment, success: false }],
});
}
}
export function evalDefault(unevaledValue: string, context: Record<string, unknown>) {
return new DefaultParser(unevaledValue, context).parse();
}
class DefaultParser {
protected readonly segments: string[];
private readonly valueAndMsgs: ValueAndMsg<unknown>[] = [];
constructor(unevaledValue: string, readonly context: Record<string, unknown>) {
this.segments = getDynamicStringSegments(unevaledValue.trim());
}
parse() {
try {
const object = this.parseObject();
if (this.valueAndMsgs.length === 0) {
return new ValueAndMsg(object);
}
return new ValueAndMsg(object, _.find(this.valueAndMsgs, "msg")?.msg, {
segments: this.valueAndMsgs.flatMap((v) => v?.extra?.segments ?? []),
});
} catch (err) {
// return null, the later transform will determine the default value
return new ValueAndMsg("", getErrorMessage(err));
}
}
parseObject() {
const values = this.segments.map((segment) =>
isDynamicSegment(segment) ? this.evalDynamicSegment(segment) : segment
);
return values.length === 1 ? values[0] : values.join("");
}
evalDynamicSegment(segment: string) {
const valueAndMsg = call(segment.slice(2, -2).trim(), this.context, segment);
this.valueAndMsgs.push(valueAndMsg);
return valueAndMsg.value;
}
}
export function evalJson(unevaledValue: string, context: Record<string, unknown>) {
return new RelaxedJsonParser(unevaledValue, context).parse();
}
// this will also be used in node-service
export class RelaxedJsonParser extends DefaultParser {
constructor(unevaledValue: string, context: Record<string, unknown>) {
super(unevaledValue, context);
this.evalIndexedObject = this.evalIndexedObject.bind(this);
}
override parseObject() {
try {
return this.parseRelaxedJson();
} catch (e) {
return super.parseObject();
}
}
parseRelaxedJson() {
// replace the original {{...}} as relaxed-json adaptive \{\{ + ${index} + \}\}
const indexedRelaxedJsonString = this.segments
.map((s, i) => (isDynamicSegment(s) ? "\\{\\{" + i + "\\}\\}" : s))
.join("");
if (indexedRelaxedJsonString.length === 0) {
// return empty, let the later transform determines the default value
return "";
}
// transform to standard JSON strings with RELAXED JSON
// here is a trick: if "\{\{ \}\}" is in quotes, keep it unchanged; otherwise transform to "{{ }}"
const indexedJsonString = relaxedJSONToJSON(indexedRelaxedJsonString, true);
// here use eval instead of JSON.parse, in order to support escaping like JavaScript. JSON.parse will cause error when escaping non-spicial char
// since eval support escaping, replace "\{\{ + ${index} + \}\}" as "\\{\\{ + ${index} + \\}\\}"
const indexedJsonObject = evalScript(
indexedJsonString.replace(
/\\{\\{\d+\\}\\}/g,
(s) => "\\\\{\\\\{" + s.slice(4, -4) + "\\\\}\\\\}"
),
{}
);
return this.evalIndexedObject(indexedJsonObject);
}
evalIndexedObject(obj: any): any {
if (typeof obj === "string") {
return this.evalIndexedStringToObject(obj);
}
if (typeof obj !== "object" || obj === null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(this.evalIndexedObject);
}
const ret: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
ret[this.evalIndexedStringToString(key)] = this.evalIndexedObject(value);
}
return ret;
}
evalIndexedStringToObject(indexedString: string) {
// if the whole string is "{{ + ${index} + }}", it indicates that the original "{{...}}" is not in quotes, as a standalone JSON value.
if (indexedString.match(/^{{\d+}}$/)) {
return this.evalIndexedSnippet(indexedString);
}
return this.evalIndexedStringToString(indexedString);
}
evalIndexedStringToString(indexedString: string) {
// replace all {{ + ${index} + }} and \{\{ + ${index} \}\}
return indexedString.replace(
/({{\d+}})|(\\{\\{\d+\\}\\})/g,
(s) => this.evalIndexedSnippet(s) + ""
);
}
// eval {{ + ${index} + }} or \{\{ + ${index} + \}\}
evalIndexedSnippet(snippet: string) {
const index = parseInt(snippet.startsWith("{{") ? snippet.slice(2, -2) : snippet.slice(4, -4));
if (index >= 0 && index < this.segments.length) {
const segment = this.segments[index];
if (isDynamicSegment(segment)) {
return this.evalDynamicSegment(segment);
}
}
return snippet;
}
}
export function evalFunction(
unevaledValue: string,
context: Record<string, unknown>,
methods?: EvalMethods,
isAsync?: boolean
): ValueAndMsg<CodeFunction> {
try {
return new ValueAndMsg(
(
args?: Record<string, unknown>,
runInHost: boolean = false,
scope: SandboxScope = "function"
) =>
evalFunc(
unevaledValue.startsWith("return")
? unevaledValue + "\n"
: `return ${isAsync ? "async " : ""}function(){'use strict'; ${unevaledValue}\n}()`,
args ? { ...context, ...args } : context,
methods,
{ disableLimit: runInHost, scope },
isAsync
)
);
} catch (err) {
return new ValueAndMsg(() => {}, getErrorMessage(err));
}
}
export async function evalFunctionResult(
unevaledValue: string,
context: Record<string, unknown>,
methods?: EvalMethods
): Promise<ValueAndMsg<unknown>> {
const valueAndMsg = evalFunction(unevaledValue, context, methods, true);
if (valueAndMsg.hasError()) {
return new ValueAndMsg("", valueAndMsg.msg);
}
try {
return new ValueAndMsg(await valueAndMsg.value());
} catch (err) {
return new ValueAndMsg("", getErrorMessage(err));
}
}
export function string2Fn(unevaledValue: string, type?: CodeType, methods?: EvalMethods): Fn {
if (type) {
switch (type) {
case "JSON":
return (context) => evalJson(unevaledValue, context);
case "Function":
return (context) => evalFunction(unevaledValue, context, methods);
}
}
return (context) => evalDefault(unevaledValue, context);
}

View File

@@ -0,0 +1,28 @@
import { fromUnevaledValue } from "./codeNode";
import { fromRecord } from "./recordNode";
import { wrapContext } from "./wrapContextNode";
it("context test", () => {
const v1 = fromRecord({
xx: fromUnevaledValue("11 {{v2 + v1 + 10}}"),
});
const c1 = wrapContext(v1);
const c1Value = c1.evaluate();
expect(c1Value({ v1: 10, v2: 10 }).xx).toStrictEqual("11 30");
expect(c1Value({ v1: 11, v2: 11 }).xx).toStrictEqual("11 32");
expect(c1Value({ v1: 11 }).xx).toStrictEqual("11 NaN");
expect(c1Value().xx).toStrictEqual("11 NaN");
});
it("context test 2", () => {
const v1 = fromRecord({
xx: fromUnevaledValue("{{i}}"),
yy: fromUnevaledValue("a{{i}}"),
});
const c1 = wrapContext(v1);
const c1Value = c1.evaluate();
expect(c1Value({ i: 1 }).xx).toStrictEqual(1);
expect(c1Value({ i: 1 }).yy).toStrictEqual("a1");
expect(c1Value({}).yy).toStrictEqual("a");
expect(c1Value().yy).toStrictEqual("a");
});

View File

@@ -0,0 +1,47 @@
import { memoized } from "util/memoize";
import { EvalMethods } from "./types/evalTypes";
import { Node, AbstractNode } from "./node";
import { fromValueWithCache } from "./simpleNode";
export type WrapContextFn<T> = (params?: Record<string, unknown>) => T;
class WrapContextNode<T> extends AbstractNode<WrapContextFn<T>> {
readonly type = "wrapContext";
constructor(readonly child: Node<T>) {
super();
}
@memoized()
override filterNodes(exposingNodes: Record<string, Node<unknown>>) {
return this.child.filterNodes(exposingNodes);
}
override justEval(
exposingNodes: Record<string, Node<unknown>>,
methods?: EvalMethods
): WrapContextFn<T> {
return (params?: Record<string, unknown>) => {
let nodes: Record<string, Node<unknown>>;
if (params) {
nodes = { ...exposingNodes };
Object.entries(params).forEach(([key, value]) => {
nodes[key] = fromValueWithCache(value);
});
} else {
nodes = exposingNodes;
}
return this.child.evaluate(nodes, methods);
};
}
override getChildren(): Node<unknown>[] {
return [this.child];
}
override dependValues(): Record<string, unknown> {
return this.child.dependValues();
}
override fetchInfo(exposingNodes: Record<string, Node<unknown>>) {
return this.child.fetchInfo(exposingNodes);
}
}
export function wrapContext<T>(node: Node<T>): Node<WrapContextFn<T>> {
return new WrapContextNode(node);
}

View File

@@ -0,0 +1,26 @@
import { fromUnevaledValue } from "./codeNode";
import { fromRecord } from "./recordNode";
import { fromValue } from "./simpleNode";
import { WrapContextNodeV2 } from "./wrapContextNodeV2";
const testNode = fromRecord({
v1: fromUnevaledValue("v1: {{a0}} {{b0}} {{c0}}"),
v2: fromUnevaledValue("v2: {{a1}} {{b0}} {{c0}}"),
});
describe("wrapContextNodeV2", () => {
it("normal", () => {
const contextNode = {
a0: fromValue("a0"),
b0: fromValue("b0"),
c0: fromUnevaledValue("{{a0+b0}}"),
};
const exposingNodes = { a0: fromValue("a000"), a1: fromUnevaledValue("{{b0+b0}}") };
const value = new WrapContextNodeV2(testNode, contextNode).evaluate(exposingNodes);
const expectedValue = {
v1: "v1: a0 b0 a0b0",
v2: "v2: b0b0 b0 a0b0",
};
expect(value).toEqual(expectedValue);
});
});

View File

@@ -0,0 +1,33 @@
import { memoized } from "util/memoize";
import { AbstractNode, Node } from "./node";
import { EvalMethods } from "./types/evalTypes";
/**
* build a new node by setting new dependent nodes in child node
*/
export class WrapContextNodeV2<T> extends AbstractNode<T> {
readonly type = "wrapContextV2";
constructor(readonly child: Node<T>, readonly paramNodes: Record<string, Node<unknown>>) {
super();
}
@memoized()
override filterNodes(exposingNodes: Record<string, Node<unknown>>) {
return this.child.filterNodes(exposingNodes);
}
override justEval(exposingNodes: Record<string, Node<unknown>>, methods?: EvalMethods): T {
return this.child.evaluate(this.wrap(exposingNodes), methods);
}
override getChildren(): Node<unknown>[] {
return [this.child];
}
override dependValues(): Record<string, unknown> {
return this.child.dependValues();
}
override fetchInfo(exposingNodes: Record<string, Node<unknown>>) {
return this.child.fetchInfo(this.wrap(exposingNodes));
}
@memoized()
private wrap(exposingNodes: Record<string, Node<unknown>>) {
return { ...exposingNodes, ...this.paramNodes };
}
}

View File

@@ -0,0 +1,42 @@
import { CodeNode } from "./codeNode";
import { fromValue } from "./simpleNode";
import { WrapNode } from "./wrapNode";
it("test wrap node", () => {
const exposingNodes = {
n1: fromValue(5),
n2: fromValue(7),
s1: fromValue("c"),
s2: fromValue("d"),
hello: new CodeNode("{{n1+n2}}"),
};
const inputNodes = {
input1: new CodeNode("{{n1+n2}}hello{{s1+s2}}"),
input2: fromValue("ccc"),
input3: "hello",
};
const moduleExposingNodes = {
n1: fromValue(11),
n2: fromValue(22),
s1: fromValue("aaa"),
s2: fromValue("bbb"),
s3: new CodeNode("{{n1}}{{s1}}"),
};
const f1 = (v: string) =>
new WrapNode(new CodeNode(v), moduleExposingNodes).evaluate(exposingNodes).value;
expect(f1("{{n1+n2}}")).toStrictEqual(33);
expect(f1("{{s1+s2}}")).toStrictEqual("aaabbb");
const f2 = (v: string) => {
const w = new WrapNode(new CodeNode(v), moduleExposingNodes, {}, inputNodes);
return w.evaluate(exposingNodes).value;
};
expect(f2("{{input1.value}}")).toStrictEqual("12hellocd");
expect(f2("{{input2}}")).toStrictEqual("ccc");
expect(f2("{{n1+n2}}")).toStrictEqual(33);
expect(f2("{{s1+s2}}")).toStrictEqual("aaabbb");
expect(f2("{{input1.value}}yes{{input2}}gof{{s1+s2}}{{s3.value}}")).toStrictEqual(
"12hellocdyescccgofaaabbb11aaa"
);
expect(f2("{{input3.value}}")).toStrictEqual(12);
});

View File

@@ -0,0 +1,62 @@
import { memoized } from "util/memoize";
import { EvalMethods } from "./types/evalTypes";
import { AbstractNode, Node } from "./node";
// encapsulate module node, use specified exposing nodes and input nodes
export class WrapNode<T> extends AbstractNode<T> {
readonly type = "wrap";
constructor(
readonly delegate: Node<T>,
readonly moduleExposingNodes: Record<string, Node<unknown>>,
readonly moduleExposingMethods?: EvalMethods,
readonly inputNodes?: Record<string, Node<unknown> | string>
) {
super();
}
private wrap(exposingNodes: Record<string, Node<unknown>>, exposingMethods: EvalMethods) {
if (!this.inputNodes) {
return this.moduleExposingNodes;
}
const inputNodeEntries = Object.entries(this.inputNodes);
if (inputNodeEntries.length === 0) {
return this.moduleExposingNodes;
}
const inputNodes: Record<string, Node<unknown>> = {};
inputNodeEntries.forEach(([name, node]) => {
let targetNode: Node<unknown> = typeof node === "string" ? exposingNodes[node] : node;
if (!targetNode) {
return;
}
inputNodes[name] = new WrapNode(targetNode, exposingNodes, exposingMethods);
});
return {
...this.moduleExposingNodes,
...inputNodes,
};
}
@memoized()
override filterNodes(exposingNodes: Record<string, Node<unknown>>) {
return this.delegate.filterNodes(this.wrap(exposingNodes, {}));
}
override justEval(exposingNodes: Record<string, Node<unknown>>, methods: EvalMethods): T {
return this.delegate.evaluate(this.wrap(exposingNodes, methods), this.moduleExposingMethods);
}
@memoized()
override fetchInfo(exposingNodes: Record<string, Node<unknown>>) {
return this.delegate.fetchInfo(this.wrap(exposingNodes, {}));
}
override getChildren(): Node<unknown>[] {
return [this.delegate];
}
override dependValues(): Record<string, unknown> {
return {};
}
}

View File

@@ -0,0 +1,203 @@
import * as localeData from "./locales";
import IntlMessageFormat from "intl-messageformat";
import log from "loglevel";
import { Fragment } from "react";
// this is a copy of the translator from ../../lib/index.js
// TODO: check if this file is used at all
const defaultLocale = "en";
let locales = [defaultLocale];
// Falk - Adapted the central translator to check if a localStorage key is existing.
const uiLanguage = localStorage.getItem('lowcoder_uiLanguage');
if (globalThis.navigator) {
if (uiLanguage) {
locales = [uiLanguage];
}
else if (navigator.languages && navigator.languages.length > 0) {
locales = [...navigator.languages];
} else {
locales = [navigator.language || ((navigator as any).userLanguage as string) || defaultLocale];
}
}
interface LocaleInfo {
locale: string; // e.g. "en-US", "zh-Hans-CN"
language: string; // e.g. "zh"
region?: string; // e.g. "CN"
}
function parseLocale(s: string): LocaleInfo | undefined {
const locale = s.trim();
if (!locale) {
return;
}
try {
if (Intl.Locale) {
const { language, region } = new Intl.Locale(locale);
return { locale, language, region };
}
const parts = locale.split("-");
const r = parts.slice(1, 3).find((t) => t.length === 2);
return { locale, language: parts[0].toLowerCase(), region: r?.toUpperCase() };
} catch (e) {
log.error(`Parse locale:${locale} failed.`, e);
}
}
function parseLocales(list: string[]): LocaleInfo[] {
return list.map(parseLocale).filter((t) => t) as LocaleInfo[];
}
const fallbackLocaleInfos = parseLocales(
locales.includes(defaultLocale) ? locales : [...locales, defaultLocale]
);
export const i18n = {
locales, // all locales
...fallbackLocaleInfos[0],
};
export function getValueByLocale<T>(defaultValue: T, func: (info: LocaleInfo) => T | undefined) {
for (const info of fallbackLocaleInfos) {
const t = func(info);
if (t !== undefined) {
return t;
}
}
return defaultValue;
}
function getDataByLocale<T>(
fileData: any,
suffix: "" | "Obj",
filterLocales?: string,
targetLocales?: string[]
) {
let localeInfos = [...fallbackLocaleInfos];
const targetLocaleInfo = parseLocales(targetLocales || []);
if (targetLocaleInfo.length > 0) {
localeInfos = [...targetLocaleInfo, ...localeInfos];
}
const filterNames = parseLocales((filterLocales ?? "").split(","))
.map((l) => l.language + (l.region ?? ""))
.filter((s) => fileData[s + suffix] !== undefined);
const names = [
...localeInfos
.flatMap(({ language, region }) => [
region ? language + region : undefined,
language,
filterNames.find((n) => n.startsWith(language)),
])
.filter((s) => s && (!filterLocales || filterNames.includes(s))),
...filterNames,
].map((s) => s + suffix);
for (const name of names) {
const data = fileData[name];
if (data !== undefined) {
return { data: data as T, language: name.slice(0, 2) };
}
}
console.error(`Not found ${names}`);
// return fallback data for en language
return { data: fileData['en'], language: 'en'};
// throw new Error(`Not found ${names}`);
}
type AddDot<T extends string> = T extends "" ? "" : `.${T}`;
type ValidKey<T> = Exclude<keyof T, symbol>;
// nested leaf keys
type NestedKey<T> = (
T extends object ? { [K in ValidKey<T>]: `${K}${AddDot<NestedKey<T[K]>>}` }[ValidKey<T>] : ""
) extends infer D
? Extract<D, string>
: never;
type AddPrefix<T, P extends string> = {
[K in keyof T as K extends string ? `${P}${K}` : never]: T[K];
};
const globalMessageKeyPrefix = "@";
const globalMessages = Object.fromEntries(
Object.entries(getDataByLocale<typeof localeData.en>(localeData, "").data).map(([k, v]) => [
globalMessageKeyPrefix + k,
v,
])
) as AddPrefix<typeof localeData.en, typeof globalMessageKeyPrefix>;
type GlobalMessageKey = NestedKey<typeof globalMessages>;
type VariableValue = string | number | boolean | Date | React.ReactNode;
export class Translator<Messages extends object> {
private readonly messages: Messages & typeof globalMessages;
// language of Translator, can be different from i18n.language
readonly language: string;
constructor(fileData: object, filterLocales?: string, locales?: string[]) {
const { data, language } = getDataByLocale<Messages>(fileData, "", filterLocales, locales);
this.messages = Object.assign({}, data, globalMessages);
this.language = language;
this.trans = this.trans.bind(this);
this.transToNode = this.transToNode.bind(this);
}
trans(
key: NestedKey<Messages> | GlobalMessageKey,
variables?: Record<string, VariableValue>
): string {
return this.transToNode(key, variables).toString();
}
transToNode(
key: NestedKey<Messages> | GlobalMessageKey,
variables?: Record<string, VariableValue>
) {
const message = this.getMessage(key);
const node = new IntlMessageFormat(message, i18n.locale).format(variables);
if (Array.isArray(node)) {
return node.map((n, i) => <Fragment key={i}>{n}</Fragment>);
}
return node;
}
private getMessage(key: NestedKey<Messages> | GlobalMessageKey) {
let message = this.getNestedMessage(this.messages, key);
// Fallback to English if the message is not found
if (message === undefined) {
message = this.getNestedMessage(localeData.en, key); // Assuming localeData.en contains English translations
}
// If still not found, return a default message or the key itself
if (message === undefined) {
console.warn(`Translation missing for key: ${key}`);
message = `oups! ${key}`;
}
return message;
}
private getNestedMessage(obj: any, key: string) {
for (const k of key.split(".")) {
if (obj !== undefined) {
obj = obj[k];
}
}
return obj;
}
}
export function getI18nObjects<I18nObjects>(fileData: object, filterLocales?: string) {
return getDataByLocale<I18nObjects>(fileData, "Obj", filterLocales)?.data;
}

View File

@@ -0,0 +1,3 @@
import { en } from "./en";
export const de: typeof en = {};

View File

@@ -0,0 +1 @@
export const en = {};

View File

@@ -0,0 +1,6 @@
// file examples: en, enGB, zh, zhHK
// fallback example: current locale is zh-HK, fallback order is zhHK => zh => en
export * from "./en";
export * from "./zh";
export * from "./de";
export * from "./pt";

View File

@@ -0,0 +1,3 @@
import { en } from "./en";
export const pt: typeof en = {};

View File

@@ -0,0 +1,3 @@
import { en } from "./en";
export const zh: typeof en = {};

View File

@@ -0,0 +1,4 @@
export * from "eval";
export * from "actions";
export * from "baseComps";
export * from "i18n";

View File

@@ -0,0 +1,26 @@
import { memo } from "./cacheUtils";
class A {
cnt = 0;
@memo
getV() {
return this.getWithoutMemo();
}
getWithoutMemo() {
this.cnt += 1;
return 1 + this.cnt;
}
}
test("test memo", () => {
let a = new A();
expect(a.getWithoutMemo()).toEqual(2);
expect(a.getWithoutMemo()).toEqual(3);
expect(a.getWithoutMemo()).toEqual(4);
a = new A();
expect(a.getV()).toEqual(2);
expect(a.getV()).toEqual(2);
expect(a.getV()).toEqual(2);
});

View File

@@ -0,0 +1,39 @@
import log from "loglevel";
export const CACHE_PREFIX = "__cache__";
/**
* a decorator for caching function's result ignoring params.
*
* @remarks
* caches are stored in `__cache__xxx` fields.
* `ObjectUtils.setFields` will not save this cache.
*
*/
export function memo(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
const cachePropertyKey = CACHE_PREFIX + propertyKey;
descriptor.value = function (...args: any[]) {
const thisObj = this as any;
if (!thisObj[cachePropertyKey]) {
// put the result into array, for representing `undefined`
thisObj[cachePropertyKey] = [originalMethod.apply(this, args)];
}
return thisObj[cachePropertyKey][0];
};
}
/**
* add for debug
*/
export const profilerCallback = (
id: string,
phase: "mount" | "update",
actualDuration: number,
baseDuration: number,
startTime: number,
commitTime: number
) => {
if (actualDuration > 20) {
log.warn(id, phase, actualDuration, baseDuration, startTime, commitTime);
}
};

View File

@@ -0,0 +1,7 @@
export type JSONValue = string | number | boolean | JSONObject | JSONArray | null;
export interface JSONObject {
[x: string]: JSONValue | undefined;
}
export type JSONArray = Array<JSONValue>;

View File

@@ -0,0 +1 @@
export * from "./memoized";

View File

@@ -0,0 +1,47 @@
import { shallowEqual } from "util/objectUtils";
import { memoized } from ".";
describe("memoized", () => {
it("shallowEqual", () => {
class Test {
data: Record<string, number> = {};
@memoized([shallowEqual])
test(record: Record<string, Record<string, number>>) {
const key = Object.values(record)
.flatMap((t) => Object.values(t))
.join("");
const num = this.data[key] ?? 0;
this.data[key] = num + 1;
return key + ":" + num;
}
}
const test = new Test();
const sub1 = { a: 1 },
sub2 = { b: 2 },
sub3 = { c: 3 };
const rec1 = { a: sub1, b: sub2 };
const rec2 = { a: sub1, b: sub2 };
const rec3 = { c: sub3 };
expect(test.test(rec1)).toEqual("12:0");
expect(test.test(rec2)).toEqual("12:0");
expect(test.test(rec3)).toEqual("3:0");
expect(test.test(rec2)).toEqual("12:1");
});
it("cyclic", () => {
class Test {
cyclic: boolean = false;
@memoized()
test(): any {
if (this.cyclic) {
return 5;
}
this.cyclic = true;
const ret = this.test();
this.cyclic = false;
return ret + ":3";
}
}
const test = new Test();
expect(test.test()).toEqual("5:3");
});
});

View File

@@ -0,0 +1,72 @@
type Cache = {
id: Symbol;
args: any[];
time: number;
result?: { value: any };
};
function isEqualArgs(
args: any[],
cacheArgs?: any[],
equals?: Array<(i1: any, i2: any) => boolean>
) {
if (!cacheArgs) {
return false;
}
if (args.length === 0 && cacheArgs.length === 0) {
return true;
}
return (
args.length === cacheArgs.length &&
cacheArgs.every(
(arg: any, index: number) => equals?.[index]?.(arg, args[index]) ?? arg === args[index]
)
);
}
function getCacheResult(
thisObj: any,
fnName: string,
args: any[],
equals?: Array<(i1: any, i2: any) => boolean>
) {
const cache: Cache | undefined = thisObj?.__cache?.[fnName];
if (cache && isEqualArgs(args, cache.args, equals)) {
return cache.result;
}
}
function cache(
fn: (...args: any[]) => any,
args: any[],
thisObj: any,
fnName: string,
equals?: Array<(i1: any, i2: any) => boolean>
) {
const result = getCacheResult(thisObj, fnName, args, equals);
if (result) {
return result.value;
}
const cache: Cache = {
id: Symbol("id"),
args: args,
time: Date.now(),
};
if (!thisObj.__cache) {
thisObj.__cache = {};
}
thisObj.__cache[fnName] = cache;
const value = fn.apply(thisObj, args);
cache.result = { value };
return value;
}
export function memoized(equals?: Array<(i1: any, i2: any) => boolean>) {
return function (target: any, fnName: string, descriptor: PropertyDescriptor) {
const fn = descriptor.value;
descriptor.value = function (...args: any[]) {
return cache(fn, args, this, fnName, equals);
};
return descriptor;
};
}

View File

@@ -0,0 +1,59 @@
import { limitExecutor, setFields, setFieldsNoTypeCheck } from "./objectUtils";
class A {
v1: string;
private v2: number;
constructor(v1: string, v2: number) {
this.v1 = v1;
this.v2 = v2;
}
getV2Add() {
return this.v2 + 10;
}
}
test("test bool control value", () => {
const a = new A("XX", 10);
let a2 = setFields(a, {});
expect(a2.v1).toEqual("XX");
expect(a2.getV2Add()).toEqual(20);
a2 = setFields(a, { v1: "X2" });
expect(a2.v1).toEqual("X2");
a2 = setFieldsNoTypeCheck(a, { v1: "YY", v2: 13 });
expect(a2.v1).toEqual("YY");
expect(a2.getV2Add()).toEqual(23);
});
test("test limit executors", async () => {
const target = {};
let cnt = 0;
limitExecutor(
target,
"test",
"throttle",
200
)(() => {
cnt += 1;
});
await new Promise((r) => setTimeout(r, 10));
limitExecutor(
target,
"test",
"throttle",
200
)(() => {
throw new Error("fail");
});
expect(cnt).toEqual(1);
// delay changed, so add one on
limitExecutor(
target,
"test",
"throttle",
300
)(() => {
cnt += 1;
});
expect(cnt).toEqual(2);
});

View File

@@ -0,0 +1,156 @@
import _ from "lodash";
import { CACHE_PREFIX } from "./cacheUtils";
import log from "loglevel";
/**
* return cached value if the result equals the last result.
* This is to keep the reference not changed with the equal result.
*
* @param target the cache will be stored in the target
* @param key the cache will be stored with the key
* @param result this result
* @param isEqual self-defined isEqual function
* @returns result or the equal cached value
*/
export function lastValueIfEqual<T>(
target: any,
key: string,
result: T,
isEqual: (a: T, b: T) => boolean
): T {
const cacheKey = "__lvif__" + key;
if (target[cacheKey] && isEqual(target[cacheKey], result)) {
return target[cacheKey];
}
target[cacheKey] = result;
return result;
}
/**
* return an executor to debounce or throttle
* 1. the counter will be stored in the target
* 2. the change of mode or delay will cause the debounce and throttle re-count
*
* @param target the object to store the cache
* @param key the key of the cache
* @param mode debounce or throttle
* @param delay waiting time
*/
export function limitExecutor(
target: any,
key: string,
mode: "debounce" | "throttle",
delay?: number
) {
return lastValueIfEqual(
target,
key,
{
delay: delay,
mode: mode,
func: (mode === "throttle" ? _.throttle : _.debounce)((x) => x(), delay),
},
(a, b) => {
return a.delay === b.delay && a.mode === b.mode;
}
).func;
}
/**
* compare keys and values
*/
export function shallowEqual(obj1: Record<string, any>, obj2: Record<string, any>): boolean {
if (obj1 === obj2) {
return true;
}
return (
Object.keys(obj1).length === Object.keys(obj2).length &&
Object.keys(obj1).every((key) => obj2.hasOwnProperty(key) && obj1[key] === obj2[key])
);
}
export function containFields(obj: Record<string, any>, fields?: Record<string, any>): boolean {
if (fields === undefined) {
return true;
}
const notEqualIndex = Object.keys(fields).findIndex((key) => {
return obj[key] !== fields[key];
});
return notEqualIndex === -1;
}
/**
* return a new object based on obj added with fields.
*
* @remarks
* The implementation directly copy the original object without calling the constructor.
* If the original object has some function with `bind`, there will be a bug.
*
* Typescript now only supports public fields from the second parameter
* https://stackoverflow.com/questions/57066049/list-private-property-names-of-the-class
*/
export function setFields<T>(obj: T, fields: Partial<T>) {
return setFieldsNoTypeCheck(obj, fields);
}
/**
* type unsafe, users should keep safe by self.
* pros: this function can support private fields.
*/
export function setFieldsNoTypeCheck<T>(
obj: T,
fields: Record<string, any>,
params?: { keepCacheKeys?: string[] }
) {
const res = Object.assign(Object.create(Object.getPrototypeOf(obj)), obj);
Object.keys(res).forEach((key) => {
if (key.startsWith(CACHE_PREFIX)) {
const propertyKey = key.slice(CACHE_PREFIX.length);
if (!params?.keepCacheKeys || !params?.keepCacheKeys.includes(propertyKey)) {
delete res[key];
}
}
});
return Object.assign(res, fields) as T;
}
const TYPES: Record<string, string> = {
Number: "number",
Boolean: "boolean",
String: "string",
Object: "object",
};
/**
* get type of object
*/
export function toType(obj: unknown): string {
let type: string = ({} as any).toString.call(obj).match(/\s([a-zA-Z]+)/)[1];
if (TYPES.hasOwnProperty(type)) {
type = TYPES[type];
}
return type;
}
export function safeJSONStringify(obj: any): string {
try {
return JSON.stringify(obj);
} catch (e) {
log.error(e);
return "";
}
}
export const getObjectId = (function () {
let objectCurrentId = 0;
const objectMap = new WeakMap();
return (obj: object | undefined) => {
if (_.isNil(obj)) return 0;
if (objectMap.has(obj)) {
return objectMap.get(obj);
}
const id = ++objectCurrentId;
objectMap.set(obj, id);
return id;
};
})();

View File

@@ -0,0 +1,17 @@
import { isNumeric } from "util/stringUtils";
test("test is isNumeric", () => {
expect(isNumeric(true)).toEqual(false);
expect(isNumeric(0)).toEqual(true);
expect(isNumeric(-4)).toEqual(true);
expect(isNumeric(1.2)).toEqual(true);
expect(isNumeric(Number(3))).toEqual(true);
expect(isNumeric("13.3")).toEqual(true);
expect(isNumeric("-13.3")).toEqual(true);
expect(isNumeric("")).toEqual(false);
expect(isNumeric(undefined)).toEqual(false);
expect(isNumeric(null)).toEqual(false);
expect(isNumeric({})).toEqual(false);
expect(isNumeric([])).toEqual(false);
expect(isNumeric([3])).toEqual(false);
});

View File

@@ -0,0 +1,164 @@
// process CJK character
import { JSONValue } from "util/jsonTypes";
const CHINESE_PATTERN = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff66-\uff9f]/;
export const DEFAULT_IMG_URL =
"data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMjAwcHgiIGhlaWdodD0iMjAwcHgiIHZpZXdCb3g9IjAgMCAyMDAgMjAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPgogICAgPGRlZnM+CiAgICAgICAgPGxpbmVhckdyYWRpZW50IHgxPSIwLjQ1ODA2MzM3NyUiIHkxPSIwLjQ1NTg2MjA4OCUiIHgyPSI5Ni4zNzkyMTM4JSIgeTI9Ijk2LjMzNjEwNDclIiBpZD0ibGluZWFyR3JhZGllbnQtMSI+CiAgICAgICAgICAgIDxzdG9wIHN0b3AtY29sb3I9IiNEMEQ2RTIiIG9mZnNldD0iMCUiPjwvc3RvcD4KICAgICAgICAgICAgPHN0b3Agc3RvcC1jb2xvcj0iI0E4QjNDOSIgb2Zmc2V0PSIxMDAlIj48L3N0b3A+CiAgICAgICAgPC9saW5lYXJHcmFkaWVudD4KICAgIDwvZGVmcz4KICAgIDxnIGlkPSLlm77niYfnu4Tku7bpu5jorqTlm74iIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxnIGlkPSLnvJbnu4QtNSI+CiAgICAgICAgICAgIDxyZWN0IGlkPSLnn6nlvaIiIGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQtMSkiIHg9IjAiIHk9IjAiIHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIiByeD0iNCI+PC9yZWN0PgogICAgICAgICAgICA8ZyBpZD0i5Zu+54mHIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg3OC4wMDAwMDAsIDgwLjAwMDAwMCkiIGZpbGw9IiNGRkZGRkYiPgogICAgICAgICAgICAgICAgPHBhdGggZD0iTTQxLDAgQzQyLjY1Njg1NDIsLTMuMDQzNTkxODhlLTE2IDQ0LDEuMzQzMTQ1NzUgNDQsMyBMNDQsMzcgQzQ0LDM4LjY1Njg1NDIgNDIuNjU2ODU0Miw0MCA0MSw0MCBMMyw0MCBDMS4zNDMxNDU3NSw0MCAyLjAyOTA2MTI1ZS0xNiwzOC42NTY4NTQyIDAsMzcgTDAsMyBDLTIuMDI5MDYxMjVlLTE2LDEuMzQzMTQ1NzUgMS4zNDMxNDU3NSw3LjQ4NDQ4Mzk4ZS0xNiAzLDAgTDQxLDAgWiBNMjkuMjIxMTY1OCwxOC45NDE5Njc4IEMyOC43ODE4MjE0LDE4LjYwNzMxMTQgMjguMTU0MzY5NywxOC42OTIxNzc4IDI3LjgxOTcxMzMsMTkuMTMxNTIyMiBMMjcuODE5NzEzMywxOS4xMzE1MjIyIEwyMC44ODI2NzkyLDI4LjIzODYxNTYgQzIwLjgzNTA4ODMsMjguMzAxMDk0IDIwLjc4MDMwMjYsMjguMzU3NzQ4MyAyMC43MTk0NTQ5LDI4LjQwNzQwNzIgQzIwLjI5MTU3NzUsMjguNzU2NjA1MyAxOS42NjE2MzMyLDI4LjY5MjgyMzIgMTkuMzEyNDM1MSwyOC4yNjQ5NDU3IEwxOS4zMTI0MzUxLDI4LjI2NDk0NTcgTDE0LjgzOTI5MywyMi43ODM5NDEgQzE0Ljc4NjQ2MDIsMjIuNzE5MjA0MyAxNC43MjU3MjE4LDIyLjY2MTM0NDcgMTQuNjU4NDk4LDIyLjYxMTcxNSBDMTQuMjE0MTgyMSwyMi4yODM2ODc5IDEzLjU4ODA3NDMsMjIuMzc3OTU4NiAxMy4yNjAwNDcyLDIyLjgyMjI3NDUgTDEzLjI2MDA0NzIsMjIuODIyMjc0NSBMNi42NzY3Njk1MiwzMS43MzkzODc3IEM2LjU0OTc5MDM0LDMxLjkxMTM4MjIgNi40ODEyNzQ2NywzMi4xMTk1NDQxIDYuNDgxMjc0NjcsMzIuMzMzMzMzMyBDNi40ODEyNzQ2NywzMi44ODU2MTgxIDYuOTI4OTg5OTIsMzMuMzMzMzMzMyA3LjQ4MTI3NDY3LDMzLjMzMzMzMzMgTDcuNDgxMjc0NjcsMzMuMzMzMzMzMyBMMzcuNjUxODM5NSwzMy4zMzMzMzMzIEMzNy44NjA5NzIsMzMuMzMzMzMzMyAzOC4wNjQ4NDE4LDMzLjI2Nzc2NjYgMzguMjM0NzY3LDMzLjE0NTg1NzUgQzM4LjY4MzUxMTcsMzIuODIzOTE1NSAzOC43ODYzMDU2LDMyLjE5OTE1MDUgMzguNDY0MzYzNywzMS43NTA0MDU4IEwzOC40NjQzNjM3LDMxLjc1MDQwNTggTDI5LjQyNzc0MDksMTkuMTU0NTQzOCBDMjkuMzY5ODI1LDE5LjA3MzgxNjYgMjkuMzAwMjAyLDE5LjAwMjE3MSAyOS4yMjExNjU4LDE4Ljk0MTk2NzggWiBNMTEsNS41NTU1NTU1NiBDOC41Njk5MjYyOCw1LjU1NTU1NTU2IDYuNiw3LjU0NTM4MDE0IDYuNiwxMCBDNi42LDEyLjQ1NDYxOTkgOC41Njk5MjYzMywxNC40NDQ0NDQ0IDExLDE0LjQ0NDQ0NDQgQzEzLjQzMDA3MzcsMTQuNDQ0NDQ0NCAxNS40LDEyLjQ1NDYxOTkgMTUuNCwxMCBDMTUuNCw3LjU0NTM4MDE0IDEzLjQzMDA3MzcsNS41NTU1NTU1NiAxMSw1LjU1NTU1NTU2IFoiIGlkPSLlvaLnirbnu5PlkIgiPjwvcGF0aD4KICAgICAgICAgICAgPC9nPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+";
/**
* truncate the string and generate a corresponding color
*
* @param fullName the string
* @param fromTail truncate from the tail
* @return [the truncated string, the color code]
*/
export const getInitialsAndColorCode = (
fullName: string | undefined,
wordTail?: boolean
): string[] => {
if (!fullName) {
return [""];
}
let inits = "";
if (CHINESE_PATTERN.test(fullName)) {
// pick the first two in Chinese
inits = wordTail ? fullName.slice(-2) : fullName.slice(0, 2);
} else {
// CamelCase to space: TacoDev => taco dev
const str = fullName ? fullName.replace(/([a-z])([A-Z])/g, "$1 $2") : "";
// if name contains space. eg: "Full Name"
const namesArr = str.split(" ");
let initials = namesArr
.map((name: string) => name.charAt(0))
.join("")
.toUpperCase();
inits = wordTail ? initials.slice(-2) : initials.slice(0, 2);
}
const colorCode = getColorCode(inits);
return [inits, colorCode];
};
export const getColorCode = (initials: string): string => {
let asciiSum = 0;
for (let i = 0; i < initials.length; i++) {
asciiSum += initials[i].charCodeAt(0);
}
return COLOR_PALETTE[asciiSum % COLOR_PALETTE.length];
};
export const COLOR_PALETTE = [
"#FA9C3F",
"#FFD400",
"#A040FF",
"#079968",
"#2440B3",
"#2693FF",
"#4965F2",
"#3377FF",
] as const;
export const getNextEntityName = (
prefix: string,
existingNames: string[],
startWithoutIndex?: boolean
) => {
const regex = new RegExp(`^${prefix}(\\d+)$`);
const usedIndices: number[] = existingNames.map((name) => {
if (name && regex.test(name)) {
const matches = name.match(regex);
const ind = matches && Array.isArray(matches) ? parseInt(matches[1], 10) : 0;
return Number.isNaN(ind) ? 0 : ind;
}
return 0;
}) as number[];
const lastIndex = Math.max(...usedIndices, ...[0]);
if (startWithoutIndex && lastIndex === 0) {
const exactMatchFound = existingNames.some((name) => prefix && name.trim() === prefix.trim());
if (!exactMatchFound) {
return prefix.trim();
}
}
return prefix + (lastIndex + 1);
};
export function replaceMiddleWithStar(text: string, n?: number) {
if (!n) {
n = Math.max(Math.floor(text.length / 2 - 1), 1);
}
if (!text) {
return "";
}
const startPos = Math.floor((text.length - n) / 2);
return text.slice(0, startPos) + "*".repeat(n) + text.slice(startPos + n);
}
export function toReadableString(value: unknown): string {
if (value instanceof RegExp) {
return value.toString();
}
// fix undefined NaN Infinity -Infinity
if (value === undefined || typeof value === "number") {
return value + "";
}
if (typeof value === "string") {
// without escaping char
return '"' + value + '"';
}
// FIXME: correctly show `undefined NaN Infinity -Infinity` inside Object, they are within quotes currently
return JSON.stringify(value, function (key, val) {
switch (typeof val) {
case "function":
case "bigint":
case "symbol":
case "undefined":
return val + "";
case "number":
if (!isFinite(val)) {
return val + "";
}
}
return val;
});
}
export function isNumeric(obj: JSONValue | undefined | null) {
if (obj instanceof Number || typeof obj === "number") {
return true;
}
if (obj instanceof String || typeof obj === "string") {
return obj !== "" && !isNaN(Number(obj));
}
return false;
}
export function hashToNum(str: string) {
var hash = 0;
if (!str) {
return hash;
}
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return hash;
}
/**
*
* @param str "xxx {0} xxx{1}"
* @param val "a", "b"
* @return "xxx a xxx b"
*/
export function formatString(str: string, ...val: string[]) {
if (!str) {
return val.toString();
}
return str.replace(/{(\d+)}/g, function (match, number) {
return typeof val[number] != "undefined" ? val[number] : match;
});
}