Skip to content
This repository was archived by the owner on Feb 14, 2025. It is now read-only.

refactor!: simplification of the migrateSituation code #43

Merged
merged 4 commits into from
Jun 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18",
"publicodes": "^1.1.1"
"publicodes": "^1.3.0"
},
"devDependencies": {
"@types/jest": "^29.2.5",
Expand Down
28 changes: 27 additions & 1 deletion src/commons.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { basename } from 'path'
import { Rule, Logger, ExprAST, reduceAST, ASTNode } from 'publicodes'
import {
Rule,
Logger,
ExprAST,
reduceAST,
ASTNode,
Evaluation,
} from 'publicodes'
import yaml from 'yaml'

/**
Expand Down Expand Up @@ -233,3 +240,22 @@ Avec :
${yaml.stringify(secondDef, { indent: 2 })}`,
)
}

/**
* Unquote a string value.
*
* @param value - The value to parse.
*
* @returns The value without quotes if it is a string, null otherwise.
*/
export function getValueWithoutQuotes(value: Evaluation) {
if (
typeof value !== 'string' ||
!value.startsWith("'") ||
value === 'oui' ||
value === 'non'
) {
return null
}
return value.slice(1, -1)
}
62 changes: 32 additions & 30 deletions src/migration/index.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,47 @@
/** @packageDocumentation

## Migrate a situation
## Situation migration

{@link migrateSituation | `migrateSituation`} allows to migrate situation and foldedSteps based on migration instructions. It's useful in forms when a model is updated and we want old answers to be kept and taken into account in the new model.
### Why?

### Usage

For instance, we have a simple set of rules:

```yaml
age:
question: "Quel est votre âge ?"
````
In time, the `publicodes` models evolve. When a model is updated (e.g. a rule
is renamed, a value is changed, a new rule is added, etc.), we want to ensure
that the previous situations (i.e. answers to questions) are still valid.

and the following situation:
```json
{
age: 25
}
```
This is where the sitation migration comes in.

If I change my model because I want to fix the accent to:
### Usage

```yaml
âge:
question: "Quel est votre âge ?"
```
{@link migrateSituation | `migrateSituation`} allows to migrate a situation from
an old version of a model to a new version according to the provided _migration
instructions_.

I don't want to lose the previous answer, so I can use `migrateSituation` with the following migration instructions:

```yaml
keysToMigrate:
age: âge
```
```typescript
import { migrateSituation } from '@publicodes/tools/migration'

Then, calling `migrateSituation` with the situation and the migration instructions will return:
const situation = {
"age": 25,
"job": "developer",
"city": "Paris"
}

```json
{
âge: 25
const instructions = {
keysToMigrate: {
// The rule `age` has been renamed to `âge`.
age: 'âge',
// The rule `city` has been removed.
city: ''
},
valuesToMigrate: {
job: {
// The value `developer` has been translated to `développeur`.
developer: 'développeur'
}
}
}

migrateSituation(situation, instructions) // { "âge": 25, "job": "'développeur'" }
```
*/

Expand Down
230 changes: 139 additions & 91 deletions src/migration/migrateSituation.ts
Original file line number Diff line number Diff line change
@@ -1,113 +1,161 @@
import { getValueWithoutQuotes } from './migrateSituation/getValueWithoutQuotes'
import { handleSituationKeysMigration } from './migrateSituation/handleSituationKeysMigration'
import { handleSituationValuesMigration } from './migrateSituation/handleSituationValuesMigration'
import { handleSpecialCases } from './migrateSituation/handleSpecialCases'
import { Evaluation } from 'publicodes'
import { Evaluation, Situation } from 'publicodes'
import { getValueWithoutQuotes, RuleName } from '../commons'

export type NodeValue = Evaluation

export type Situation = {
[key: string]: NodeValue
}

export type DottedName = string
/**
* Associate a old value to a new value.
*/
export type ValueMigration = Record<string, string>

export type MigrationType = {
keysToMigrate: Record<DottedName, DottedName>
valuesToMigrate: Record<DottedName, Record<string, NodeValue>>
/**
* Migration instructions. It contains the rules and values to migrate.
*/
export type Migration = {
keysToMigrate: Record<RuleName, RuleName>
valuesToMigrate: Record<RuleName, ValueMigration>
}

/**
* Migrate rules and answers from a situation which used to work with an old version of a model to a new version according to the migration instructions.
* Migrate a situation from an old version of a model to a new version
* according to the provided migration instructions.
*
* @param situation - The situation object containing all answers for a given simulation.
* @param instructions - The migration instructions object.
*
* @param {Object} options - The options object.
* @param {Situation} options.situation - The `situation` as Publicodes object containing all answers for a given simulation.
* @param {DottedName[]} [options.foldedSteps=[]] - In case of form app, an array containing answered questions.
* @param {MigrationType} options.migrationInstructions - An object containing keys and values to migrate formatted as follows:
* @returns The migrated situation (and foldedSteps if specified).
*
* @example
* ```
* {
* keysToMigrate: {
* oldKey: newKey
* }
* valuesToMigrate: {
* key: {
* oldValue: newValue
* ```typescript
* import { migrateSituation } from '@publicodes/tools/migration'
*
* const situation = {
* "age": 25
* "job": "developer",
* "city": "Paris"
* }
*
* const instructions = {
* keysToMigrate: {
* // The rule `age` will be renamed to `âge`.
* age: 'âge',
* // The rule `city` will be removed.
* city: ''
* },
* valuesToMigrate: {
* job: {
* // The value `developer` will be translated to `développeur`.
* developer: 'développeur'
* }
* }
* }
*
* migrateSituation(situation, instructions) // { "âge": 25, "job": "'développeur'" }
* ```
* An example can be found in {@link https://github.com/incubateur-ademe/nosgestesclimat/blob/preprod/migration/migration.yaml | nosgestesclimat repository}.
* @returns {Object} The migrated situation (and foldedSteps if specified).
*
* @note An example of instructions can be found {@link https://github.com/incubateur-ademe/nosgestesclimat/blob/preprod/migration/migration.yaml | here}.
*/
export function migrateSituation({
situation,
foldedSteps = [],
migrationInstructions,
}: {
situation: Situation
foldedSteps?: DottedName[]
migrationInstructions: MigrationType
}) {
let situationMigrated = { ...situation }
let foldedStepsMigrated = [...foldedSteps]
export function migrateSituation(
situation: Situation<RuleName>,
instructions: Migration,
): Situation<RuleName> {
let newSituation = { ...situation }
const currentRules = Object.keys(situation)
const valueKeysToMigrate = Object.keys(instructions.valuesToMigrate)

Object.entries(situationMigrated).map(([ruleName, nodeValue]) => {
situationMigrated = handleSpecialCases({
ruleName,
nodeValue,
situation: situationMigrated,
})
Object.entries(situation).map(([rule, value]) => {
handleSpecialCases(rule, value, newSituation)

// We check if the non supported ruleName is a key to migrate.
// Ex: "logement . chauffage . bois . type . bûche . consommation": "xxx" which is now ""logement . chauffage . bois . type . bûches . consommation": "xxx"
if (Object.keys(migrationInstructions.keysToMigrate).includes(ruleName)) {
const result = handleSituationKeysMigration({
ruleName,
nodeValue,
situation: situationMigrated,
foldedSteps: foldedStepsMigrated,
migrationInstructions,
})
if (currentRules.includes(rule)) {
updateKey(rule, value, newSituation, instructions.keysToMigrate[rule])
}

situationMigrated = result.situationMigrated
foldedStepsMigrated = result.foldedStepsMigrated
const formattedValue = getValueWithoutQuotes(value) ?? (value as string)
const valuesMigration =
instructions.valuesToMigrate[
valueKeysToMigrate.find((key) => rule.includes(key))
] ?? {}
const oldValuesName = Object.keys(valuesMigration)

if (oldValuesName.includes(formattedValue)) {
updateValue(rule, valuesMigration[formattedValue], newSituation)
}
})

return newSituation
}

/**
* Handle migration of old value format : an object { valeur: number, unité: string }.
*
* @example
* ```json
* { valeur: number, unité: string }
* ```
*/
function handleSpecialCases(
rule: RuleName,
oldValue: Evaluation,
situation: Situation<RuleName>,
): void {
// Special case, number store as a string, we have to convert it to a number
if (
oldValue &&
typeof oldValue === 'string' &&
!isNaN(parseFloat(oldValue))
) {
situation[rule] = parseFloat(oldValue)
}

const matchingValueToMigrateObject =
migrationInstructions.valuesToMigrate[
Object.keys(migrationInstructions.valuesToMigrate).find((key) =>
ruleName.includes(key),
) as any
]
// Special case : wrong value format, legacy from previous publicodes version
// handle the case where valeur is a string "2.33"
if (oldValue && oldValue['valeur'] !== undefined) {
situation[rule] =
typeof oldValue['valeur'] === 'string' &&
!isNaN(parseFloat(oldValue['valeur']))
? parseFloat(oldValue['valeur'])
: (oldValue['valeur'] as number)
}
// Special case : other wrong value format, legacy from previous publicodes version
// handle the case where nodeValue is a string "2.33"
if (oldValue && oldValue['nodeValue'] !== undefined) {
situation[rule] =
typeof oldValue['nodeValue'] === 'string' &&
!isNaN(parseFloat(oldValue['nodeValue']))
? parseFloat(oldValue['nodeValue'])
: (oldValue['nodeValue'] as number)
}
}

const formattedNodeValue =
getValueWithoutQuotes(nodeValue) || (nodeValue as string)
function updateKey(
rule: RuleName,
oldValue: Evaluation,
situation: Situation<RuleName>,
ruleToMigrate: RuleName | undefined,
): void {
if (ruleToMigrate === undefined) {
return
}

if (
// We check if the value of the non supported ruleName value is a value to migrate.
// Ex: answer "logement . chauffage . bois . type": "bûche" changed to "bûches"
// If a value is specified but empty, we consider it to be deleted (we need to ask the question again)
// Ex: answer "transport . boulot . commun . type": "vélo"
matchingValueToMigrateObject &&
Object.keys(matchingValueToMigrateObject).includes(
// If the string start with a ', we remove it along with the last character
// Ex: "'bûche'" => "bûche"
formattedNodeValue,
)
) {
const result = handleSituationValuesMigration({
ruleName,
nodeValue: formattedNodeValue,
situation: situationMigrated,
foldedSteps: foldedStepsMigrated,
migrationInstructions,
})
delete situation[rule]

situationMigrated = result.situationMigrated
foldedStepsMigrated = result.foldedStepsMigrated
}
})
if (ruleToMigrate !== '') {
situation[ruleToMigrate] =
typeof oldValue === 'object' ? (oldValue as any)?.valeur : oldValue
}
}

return { situationMigrated, foldedStepsMigrated }
function updateValue(
rule: RuleName,
value: string,
situation: Situation<RuleName>,
): void {
// The value is not a value to migrate and the key has to be deleted
if (value === '') {
delete situation[rule]
} else {
// The value is renamed and needs to be migrated
situation[rule] =
typeof value === 'string' && value !== 'oui' && value !== 'non'
? `'${value}'`
: value
}
}
Loading
Loading