Skip to content

Commit

Permalink
Min/max count: use expressions (#229)
Browse files Browse the repository at this point in the history
* min max count: added survey dependencies (WIP)

* added children count getters and setters

* min/max count: added evaluation (WIP)

* count validator

* code cleanup; added surveys helper function

* code cleanup

* node def: store counts as array of expressions

* fixed tests

* fixed count evaluator

* code cleanup

* fixed husky warning

* code cleanup

* code cleanup

---------

Co-authored-by: Stefano Ricci <[email protected]>
  • Loading branch information
SteRiccio and SteRiccio authored Jan 15, 2025
1 parent 5ba672b commit 230c524
Show file tree
Hide file tree
Showing 22 changed files with 294 additions and 76 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"build": "tsc",
"lint:fix": "npm run lint -- --fix",
"lint": "eslint 'src/**/*.{ts,tsx}'",
"prepare": "yarn build && husky install",
"prepare": "yarn build && husky",
"test": "jest"
},
"files": [
Expand Down
2 changes: 2 additions & 0 deletions src/node/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export enum NodeMetaKeys {

export interface NodeMeta {
childApplicability?: { [uuid: string]: boolean }
childrenMaxCount?: { [uuid: string]: number }
childrenMinCount?: { [uuid: string]: number }
h?: string[]
hCode?: string[]
defaultValueApplied?: boolean
Expand Down
91 changes: 79 additions & 12 deletions src/node/nodes.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,47 @@
import { NodeDef, NodeDefCountType, NodeDefs } from '../nodeDef'
import { Dates, Objects } from '../utils'
import { Node } from './node'

const isRoot = (node: Node): boolean => !node.parentUuid

const areEqual = (nodeA: Node, nodeB: Node): boolean => nodeA.uuid === nodeB.uuid
const isChildApplicable = (node: Node, nodeDefUuid: string) => {

const isChildApplicable = (node: Node, nodeDefUuid: string): boolean => {
// if child applicability is not defined for a node definition, consider it applicable
return node.meta?.childApplicability?.[nodeDefUuid] !== false
}
const assocChildApplicability = (node: Node, nodeDefUuid: string, applicable: boolean) => {
const getChildrenCount = (params: { parentNode: Node; nodeDef: NodeDef<any>; countType: NodeDefCountType }): number => {
const { parentNode, nodeDef, countType } = params
const countIndex =
countType === NodeDefCountType.max ? parentNode.meta?.childrenMaxCount : parentNode.meta?.childrenMinCount

const count = countIndex?.[nodeDef.uuid]
if (!Objects.isEmpty(count)) return count!

// count can be a constant value, specified in the node def min/max count prop
const nodeDefCount =
countType === NodeDefCountType.max ? NodeDefs.getMaxCount(nodeDef) : NodeDefs.getMinCount(nodeDef)
return nodeDefCount ? Number(nodeDefCount) : NaN
}

const getChildrenMaxCount = (params: { parentNode: Node; nodeDef: NodeDef<any> }): number =>
getChildrenCount({ ...params, countType: NodeDefCountType.max })

const getChildrenMinCount = (params: { parentNode: Node; nodeDef: NodeDef<any> }): number =>
getChildrenCount({ ...params, countType: NodeDefCountType.min })

const getHierarchy = (node: Node): string[] => [...(node.meta?.h ?? [])]

const getHierarchyCode = (node: Node): string[] => [...(node.meta?.hCode ?? [])]

const mergeNodes = (target: Node, ...sources: Node[] | object[]): Node =>
Objects.deepMerge(target, ...sources) as unknown as Node

const isDefaultValueApplied = (node: Node): boolean => node?.meta?.defaultValueApplied ?? false

const isValueBlank = (node: Node): boolean => Objects.isEmpty(node.value)

const assocChildApplicability = (node: Node, nodeDefUuid: string, applicable: boolean): Node => {
const childApplicability = { ...(node.meta?.childApplicability ?? {}) }
if (!applicable) {
childApplicability[nodeDefUuid] = applicable
Expand All @@ -21,6 +55,7 @@ const assocChildApplicability = (node: Node, nodeDefUuid: string, applicable: bo
dateModified: Dates.nowFormattedForStorage(),
}
}

const dissocChildApplicability = (node: Node, nodeDefUuid: string) => {
const childApplicability = { ...(node.meta?.childApplicability ?? {}) }
delete childApplicability[nodeDefUuid]
Expand All @@ -29,16 +64,41 @@ const dissocChildApplicability = (node: Node, nodeDefUuid: string) => {
meta: { ...node.meta, childApplicability },
}
}
const getHierarchy = (node: Node) => [...(node.meta?.h ?? [])]

const getHierarchyCode = (node: Node) => [...(node.meta?.hCode ?? [])]

const mergeNodes = (target: Node, ...sources: Node[] | object[]): Node =>
Objects.deepMerge(target, ...sources) as unknown as Node
const assocChildrenCount = (params: {
node: Node
nodeDefUuid: string
count: number
countType: NodeDefCountType
}): Node => {
const { node, nodeDefUuid, count, countType } = params
const countIndex = {
...((countType === NodeDefCountType.max ? node.meta?.childrenMaxCount : node.meta?.childrenMinCount) ?? {}),
}
if (isNaN(count)) {
delete countIndex[nodeDefUuid]
} else {
countIndex[nodeDefUuid] = count
}
const metaUpdated = { ...node.meta }
if (countType === NodeDefCountType.max) {
metaUpdated.childrenMaxCount = countIndex
} else {
metaUpdated.childrenMinCount = countIndex
}
return {
...node,
meta: metaUpdated,
updated: true,
dateModified: Dates.nowFormattedForStorage(),
}
}

const isDefaultValueApplied = (node: Node): boolean => node?.meta?.defaultValueApplied ?? false
const assocChildrenMaxCount = (params: { node: Node; nodeDefUuid: string; count: number }): Node =>
assocChildrenCount({ ...params, countType: NodeDefCountType.max })

const isValueBlank = (node: Node): boolean => Objects.isEmpty(node.value)
const assocChildrenMinCount = (params: { node: Node; nodeDefUuid: string; count: number }): Node =>
assocChildrenCount({ ...params, countType: NodeDefCountType.min })

const removeStatusFlags = (node: Node): Node => {
delete node['created']
Expand All @@ -51,12 +111,19 @@ export const Nodes = {
isRoot,
areEqual,
isChildApplicable,
assocChildApplicability,
dissocChildApplicability,
mergeNodes,
getChildrenCount,
getChildrenMaxCount,
getChildrenMinCount,
getHierarchy,
getHierarchyCode,
isDefaultValueApplied,
isValueBlank,
// update
assocChildApplicability,
dissocChildApplicability,
assocChildrenCount,
assocChildrenMaxCount,
assocChildrenMinCount,
mergeNodes,
removeStatusFlags,
}
2 changes: 1 addition & 1 deletion src/nodeDef/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export type {
NodeDefValidations,
NodeDefMap,
} from './nodeDef'
export { NodeDefType } from './nodeDef'
export { NodeDefCountType, NodeDefType } from './nodeDef'
export { NodeDefs } from './nodeDefs'

export type { NodeDefService } from './service'
Expand Down
30 changes: 19 additions & 11 deletions src/nodeDef/nodeDef.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { UUIDs } from '../utils'
import { Objects, UUIDs } from '../utils'
import { ArenaObject, Factory } from '../common'
import { Labels } from '../language'
import { ValidationSeverity } from '../validation'
Expand All @@ -20,12 +20,17 @@ export enum NodeDefType {
formHeader = 'formHeader',
}

export enum NodeDefCountType {
max = 'max',
min = 'min',
}

export interface NodeDefMeta {
h: Array<string>
h: string[]
}

export interface NodeDefProps {
cycles?: Array<string>
cycles?: string[]
descriptions?: Labels
key?: boolean
autoIncrementalKey?: boolean
Expand Down Expand Up @@ -70,29 +75,32 @@ export interface NodeDefExpressionFactoryParams {

export const NodeDefExpressionFactory: Factory<NodeDefExpression, NodeDefExpressionFactoryParams> = {
createInstance: (params: NodeDefExpressionFactoryParams): NodeDefExpression => {
const { applyIf, expression, severity } = params
return { applyIf, expression, severity, uuid: UUIDs.v4() }
const result = { uuid: UUIDs.v4() }
Object.assign(result, Objects.deleteEmptyProps({ ...params }))
return result
},
}

export type NodeDefCountExpression = string | NodeDefExpression[]

export interface NodeDefCountValidations {
max?: number
min?: number
max?: NodeDefCountExpression
min?: NodeDefCountExpression
}

export interface NodeDefValidations {
count?: NodeDefCountValidations
expressions?: Array<NodeDefExpression>
expressions?: NodeDefExpression[]
required?: boolean
unique?: boolean
}

export interface NodeDefPropsAdvanced {
applicable?: Array<NodeDefExpression>
defaultValues?: Array<NodeDefExpression>
applicable?: NodeDefExpression[]
defaultValues?: NodeDefExpression[]
defaultValueEvaluatedOneTime?: boolean
excludedInClone?: boolean
formula?: Array<NodeDefExpression>
formula?: NodeDefExpression[]
validations?: NodeDefValidations
// file attribute
fileNameExpression?: string
Expand Down
28 changes: 22 additions & 6 deletions src/nodeDef/nodeDefs.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { LanguageCode } from '../language'
import { valuePropsCoordinate } from '../node/nodeValueProps'
import { defaultCycle } from '../survey'
import { Numbers, Objects, Strings } from '../utils'
import { Objects, Strings } from '../utils'
import {
NodeDef,
NodeDefCountExpression,
NodeDefCountType,
NodeDefExpression,
NodeDefLayout,
NodeDefProps,
Expand Down Expand Up @@ -128,15 +130,27 @@ const getItemsFilter = (nodeDef: NodeDef<any>): string | undefined => nodeDef.pr
const getValidations = (nodeDef: NodeDef<NodeDefType>): NodeDefValidations | undefined =>
nodeDef.propsAdvanced?.validations

const getValidationsExpressions = (nodeDef: NodeDef<NodeDefType>): NodeDefExpression[] =>
getValidations(nodeDef)?.expressions ?? []

const isRequired = (nodeDef: NodeDef<NodeDefType>): boolean => getValidations(nodeDef)?.required ?? false

// // Min max
const getMinCount = (nodeDef: NodeDef<NodeDefType>) => Numbers.toNumber(getValidations(nodeDef)?.count?.min)
// // Min/max count

const getCount = (nodeDef: NodeDef<NodeDefType>, countType: NodeDefCountType): NodeDefCountExpression | undefined => {
const countValidations = getValidations(nodeDef)?.count
if (!countValidations) return undefined
return countType === NodeDefCountType.max ? countValidations.max : countValidations.min
}

const getMinCount = (nodeDef: NodeDef<NodeDefType>): NodeDefCountExpression | undefined =>
getCount(nodeDef, NodeDefCountType.min)

const getMaxCount = (nodeDef: NodeDef<NodeDefType>) => Numbers.toNumber(getValidations(nodeDef)?.count?.max)
const getMaxCount = (nodeDef: NodeDef<NodeDefType>): NodeDefCountExpression | undefined =>
getCount(nodeDef, NodeDefCountType.max)

const hasMinOrMaxCount = (nodeDef: NodeDef<NodeDefType>) =>
!Number.isNaN(getMinCount(nodeDef)) || !Number.isNaN(getMaxCount(nodeDef))
const hasMinOrMaxCount = (nodeDef: NodeDef<NodeDefType>): boolean =>
!Objects.isEmpty(getMinCount(nodeDef)) || !Objects.isEmpty(getMaxCount(nodeDef))

// layout
const getLayoutProps =
Expand Down Expand Up @@ -262,9 +276,11 @@ export const NodeDefs = {
isCodeShown,
// validations
getValidations,
getValidationsExpressions,
isRequired,
// // Min Max
hasMinOrMaxCount,
getCount,
getMaxCount,
getMinCount,
// Analysis
Expand Down
1 change: 0 additions & 1 deletion src/record/_records/recordGetters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,6 @@ export const findEntityByKeyValues = (params: {
return siblingEntities.find((siblingEntity) => {
const siblingEntityKeyValuesByDefUuid = getEntityKeyValuesByDefUuid({
survey,
cycle,
record,
entity: siblingEntity,
keyDefs,
Expand Down
73 changes: 73 additions & 0 deletions src/record/recordNodesUpdater/recordNodeDependentsCountUpdater.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { User } from '../../auth'
import { NodeDefCountType, NodeDefs } from '../../nodeDef'
import { Record } from '../record'
import { Survey, SurveyDependencyType } from '../../survey'
import { Node, NodePointer, Nodes } from '../../node'
import { RecordUpdateResult } from './recordUpdateResult'
import { Records } from '../records'
import { RecordExpressionEvaluator } from '../recordExpressionEvaluator'
import { Objects } from '../../utils'

export const updateDependentsCount = (params: {
user: User
survey: Survey
record: Record
node: Node
countType: NodeDefCountType
sideEffect?: boolean
timezoneOffset?: number
}): RecordUpdateResult => {
const { user, survey, record, node, countType, timezoneOffset, sideEffect = false } = params

const updateResult = new RecordUpdateResult({ record })

// 1. fetch dependent nodes
const dependencyType =
countType === NodeDefCountType.max ? SurveyDependencyType.maxCount : SurveyDependencyType.minCount

const nodePointersToUpdate = Records.getDependentNodePointers({
survey,
record,
node,
dependencyType,
})

// 2. update expr to node and dependent nodes
// NOTE: don't do it in parallel, same nodeCtx metadata could be overwritten
const expressionEvaluator = new RecordExpressionEvaluator()
nodePointersToUpdate.forEach((nodePointer: NodePointer) => {
const { nodeCtx: nodeCtxNodePointer, nodeDef: nodeDefNodePointer } = nodePointer

const expressionsToEvaluate = NodeDefs.getCount(nodeDefNodePointer, countType)
if (Objects.isEmpty(expressionsToEvaluate) || !Array.isArray(expressionsToEvaluate)) return

// 3. evaluate applicable expression
const nodeCtxUuid = nodeCtxNodePointer.uuid
// nodeCtx could have been updated in a previous iteration
const nodeCtx = updateResult.getNodeByUuid(nodeCtxUuid) ?? nodeCtxNodePointer

const countResult = expressionEvaluator.evalApplicableExpression({
user,
survey,
record: updateResult.record,
nodeCtx,
expressions: expressionsToEvaluate,
timezoneOffset,
})

const count = Number(countResult?.value)

// 4. persist updated count if changed, and return updated nodes
const nodeDefUuid = nodeDefNodePointer.uuid

if (Nodes.getChildrenCount({ parentNode: nodeCtx, nodeDef: nodeDefNodePointer, countType }) !== count) {
// count changed

// 5. update node and add it to nodes updated
const nodeCtxUpdated = Nodes.assocChildrenCount({ node: nodeCtx, nodeDefUuid, count, countType })
updateResult.addNode(nodeCtxUpdated, { sideEffect })
}
})

return updateResult
}
11 changes: 6 additions & 5 deletions src/record/recordNodesUpdater/recordNodesCreator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { CategoryItem } from '../../category'
import { SystemError } from '../../error'
import { Node, NodeFactory } from '../../node'
import { Node, NodeFactory, Nodes } from '../../node'
import { NodeValues } from '../../node/nodeValues'
import { NodeDef, NodeDefCode, NodeDefEntity, NodeDefType, NodeDefs } from '../../nodeDef'
import { Survey } from '../../survey'
Expand All @@ -25,10 +25,11 @@ export type NodeCreateParams = NodesUpdateParams & {
createMultipleEntities?: boolean
}

const getNodesToInsertCount = (nodeDef: NodeDef<any>): number => {
if (NodeDefs.isSingle(nodeDef)) return 1
const getNodesToInsertCount = (params: { parentNode: Node | undefined; nodeDef: NodeDef<any> }): number => {
const { nodeDef, parentNode } = params
if (!parentNode || NodeDefs.isSingle(nodeDef)) return 1
if (nodeDef.type === NodeDefType.code) return 0 // never create nodes for multiple code attributes
return NodeDefs.getMinCount(nodeDef) ?? 0
return Nodes.getChildrenMinCount({ parentNode, nodeDef }) ?? 0
}

const getEnumeratingCategoryItems = (params: { survey: Survey; enumerator: NodeDefCode }): CategoryItem[] => {
Expand Down Expand Up @@ -96,7 +97,7 @@ const createChildNodesBasedOnMinCount = (params: NodeCreateParams & { updateResu
}
}

const nodesToInsertCount = getNodesToInsertCount(nodeDef)
const nodesToInsertCount = getNodesToInsertCount({ parentNode, nodeDef })
if (nodesToInsertCount === 0 || (!createMultipleEntities && NodeDefs.isMultipleEntity(nodeDef))) {
return // do nothing
}
Expand Down
Loading

0 comments on commit 230c524

Please sign in to comment.