Updates
This commit is contained in:
@@ -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;
|
||||
219
lowcoder/client/packages/lowcoder-core/src/actions/actions.tsx
Normal file
219
lowcoder/client/packages/lowcoder-core/src/actions/actions.tsx
Normal 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 };
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./actions";
|
||||
export * from "./actionTypes";
|
||||
116
lowcoder/client/packages/lowcoder-core/src/baseComps/comp.tsx
Normal file
116
lowcoder/client/packages/lowcoder-core/src/baseComps/comp.tsx
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./comp";
|
||||
export * from "./multiBaseComp";
|
||||
export * from "./simpleComp";
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
1
lowcoder/client/packages/lowcoder-core/src/core.d.ts
vendored
Normal file
1
lowcoder/client/packages/lowcoder-core/src/core.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "really-relaxed-json";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
227
lowcoder/client/packages/lowcoder-core/src/eval/codeNode.tsx
Normal file
227
lowcoder/client/packages/lowcoder-core/src/eval/codeNode.tsx
Normal 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 CodeNode,since 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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
23
lowcoder/client/packages/lowcoder-core/src/eval/index.tsx
Normal file
23
lowcoder/client/packages/lowcoder-core/src/eval/index.tsx
Normal 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";
|
||||
@@ -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);
|
||||
});
|
||||
187
lowcoder/client/packages/lowcoder-core/src/eval/node.tsx
Normal file
187
lowcoder/client/packages/lowcoder-core/src/eval/node.tsx
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)) ?? "";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 "
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
62
lowcoder/client/packages/lowcoder-core/src/eval/wrapNode.tsx
Normal file
62
lowcoder/client/packages/lowcoder-core/src/eval/wrapNode.tsx
Normal 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 {};
|
||||
}
|
||||
}
|
||||
203
lowcoder/client/packages/lowcoder-core/src/i18n/index.tsx
Normal file
203
lowcoder/client/packages/lowcoder-core/src/i18n/index.tsx
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { en } from "./en";
|
||||
|
||||
export const de: typeof en = {};
|
||||
@@ -0,0 +1 @@
|
||||
export const en = {};
|
||||
@@ -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";
|
||||
@@ -0,0 +1,3 @@
|
||||
import { en } from "./en";
|
||||
|
||||
export const pt: typeof en = {};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { en } from "./en";
|
||||
|
||||
export const zh: typeof en = {};
|
||||
4
lowcoder/client/packages/lowcoder-core/src/index.ts
Normal file
4
lowcoder/client/packages/lowcoder-core/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "eval";
|
||||
export * from "actions";
|
||||
export * from "baseComps";
|
||||
export * from "i18n";
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./memoized";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
156
lowcoder/client/packages/lowcoder-core/src/util/objectUtils.ts
Normal file
156
lowcoder/client/packages/lowcoder-core/src/util/objectUtils.ts
Normal 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;
|
||||
};
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
164
lowcoder/client/packages/lowcoder-core/src/util/stringUtils.ts
Normal file
164
lowcoder/client/packages/lowcoder-core/src/util/stringUtils.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user