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

Commit

Permalink
refactor!: deep simplification of the migrateSituation code
Browse files Browse the repository at this point in the history
  • Loading branch information
EmileRolley committed May 30, 2024
1 parent c717888 commit 7a5efb5
Show file tree
Hide file tree
Showing 11 changed files with 250 additions and 484 deletions.
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)
}
59 changes: 30 additions & 29 deletions src/migration/index.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,46 @@
/** @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
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.
For instance, we have a simple set of rules:
This is where the sitation migration comes in.
```yaml
age:
question: "Quel est votre âge ?"
````
### Usage
and the following situation:
```json
{
age: 25
}
```
{@link migrateSituation | `migrateSituation`} allows to migrate a siuation from
an old version of a model to a new version according to the provided _migration
instructions_.
If I change my model because I want to fix the accent to:
```yaml
âge:
question: "Quel est votre âge ?"
```
```typescript
import { migrateSituation } from '@publicodes/tools/migration'
I don't want to lose the previous answer, so I can use `migrateSituation` with the following migration instructions:
const oldSituation = {
"age": 25
"job": "developer",
}
```yaml
keysToMigrate:
age: âge
```
// In the new model version, the rule `age` has been renamed to `âge` and the
// value `developer` has been translated to `développeur`.
const migrationInstructions = {
keysToMigrate: { age: 'âge' }
valuesToMigrate: {
job: { developer: 'développeur' }
}
}
Then, calling `migrateSituation` with the situation and the migration instructions will return:
console.log(migrateSituation(oldSituation, migrationInstructions))
```json
{
âge: 25
}
// Output:
// {
// "âge": 25,
// "job": "développeur"
// }
```
*/

Expand Down
245 changes: 155 additions & 90 deletions src/migration/migrateSituation.ts
Original file line number Diff line number Diff line change
@@ -1,113 +1,178 @@
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 { getValueWithoutQuotes, RuleName } from '../commons'

export type NodeValue = Evaluation

export type Situation = {
[key: string]: NodeValue
}
/**
* A situation object containing all answers for a given simulation.
*/
export type Situation = Record<RuleName, Evaluation>

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 = {
rulesToMigrate: 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 {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:
* @param situation - The situation object containing all answers for a given simulation.
* @param instructions - The migration instructions object.
*
* @example
* ```
* {
* keysToMigrate: {
* oldKey: newKey
* }
* valuesToMigrate: {
* key: {
* oldValue: newValue
* }
* }
* ```
* 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).
* @returns The migrated situation (and foldedSteps if specified).
*
* TODO: exemple of instructions (empty string for deletion, new key name for renaming, new value for updating)
*
* 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]

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

// 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,
})

situationMigrated = result.situationMigrated
foldedStepsMigrated = result.foldedStepsMigrated
}
export function migrateSituation(
situation: Situation,
instructions: Migration,
): Situation {
let newSituation = { ...situation }
const currentRules = Object.keys(situation)
const valueKeysToMigrate = Object.keys(instructions.valuesToMigrate)

const matchingValueToMigrateObject =
migrationInstructions.valuesToMigrate[
Object.keys(migrationInstructions.valuesToMigrate).find((key) =>
ruleName.includes(key),
) as any
]
Object.entries(situation).map(([rule, value]) => {
handleSpecialCases(rule, value, newSituation)

if (currentRules.includes(rule)) {
updateKey(rule, value, newSituation, instructions.rulesToMigrate[rule])
}

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

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,
)
oldValuesName.includes(formattedValue)
) {
const result = handleSituationValuesMigration({
ruleName,
nodeValue: formattedNodeValue,
situation: situationMigrated,
foldedSteps: foldedStepsMigrated,
migrationInstructions,
})

situationMigrated = result.situationMigrated
foldedStepsMigrated = result.foldedStepsMigrated
updateValue(rule, valuesMigration[formattedValue], newSituation)
}
})

return { situationMigrated, foldedStepsMigrated }
return newSituation
}

// Handle migration of old value format : an object { valeur: number, unité: string }
/**
* Handles special cases during the migration of old value formats.
*
* @example
* ````
{ valeur: number, unité: string }
```
*
* @param rule - The name of the rule.
* @param oldValue - The node value.
* @param situation - The situation object.
* @returns - The updated situation object.
*/
function handleSpecialCases(
rule: RuleName,
oldValue: Evaluation,
situation: Situation,
): 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)
}

// 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)
}
}

/**
*/
function updateKey(
rule: RuleName,
oldValue: Evaluation,
situation: Situation,
ruleToMigrate: RuleName | undefined,
): void {
if (ruleToMigrate === undefined) {
return
}

delete situation[rule]

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

/**
*/
export function updateValue(
rule: RuleName,
value: string,
situation: Situation,
): 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] = getMigratedValue(value)
}
}

function getMigratedValue(value: string): string {
if (typeof value === 'string' && value !== 'oui' && value !== 'non') {
return `'${value}'`
}

// FIXME: I'm not sure if it's necessary to check if the value is a number,
// as valuesToMigrate is a ValueMigration object (Record<string, string>).
// Is it possible to have objects in valuesToMigrate?
// if (
// (
// value as unknown as {
// valeur: number
// }
// )?.valeur !== undefined
// ) {
// return (
// value as unknown as {
// valeur: number
// }
// ).valeur as unknown as string
// }

return value
}
Loading

0 comments on commit 7a5efb5

Please sign in to comment.