Skip to content

Commit

Permalink
Merge pull request #43 from publicodes/migration-refactoring
Browse files Browse the repository at this point in the history
refactor!: simplification of the migrateSituation code
  • Loading branch information
EmileRolley authored Jun 3, 2024
2 parents c717888 + af8380e commit 5e76846
Show file tree
Hide file tree
Showing 16 changed files with 322 additions and 515 deletions.
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

0 comments on commit 5e76846

Please sign in to comment.