Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
  • Loading branch information
Clemog committed May 16, 2024
2 parents a2a629e + 0353d2c commit 04e03ae
Show file tree
Hide file tree
Showing 12 changed files with 572 additions and 5 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ by the nosgestesclimat.fr team.</i> :warning:
- 🏗️ Compiles your set of Publicodes files into a standalone JSON file - [[doc](https://publicodes.github.io/tools/modules/compilation.html#md:compile-a-model-from-a-source)]
- 📦 Resolves import from external Publicodes models, from source and from published [NPM packages](https://www.npmjs.com/package/futureco-data) - [[doc](https://publicodes.github.io/tools/modules/compilation.html#md:import-rules-from-a-npm-package)]
- 🪶 Pre-computes your model at compile time and reduces [the number of rules by ~65%](https://github.com/incubateur-ademe/nosgestesclimat/pull/1697) - [[doc](https://publicodes.github.io/tools/modules/optims.html)]
- ➡️ Use a migration function for user situation to deal with breaking changes in your models - [[doc](https://publicodes.github.io/tools/modules/migration.html)]

## Installation

Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@
"import": "./dist/compilation/index.js",
"require": "./dist/compilation/index.cjs",
"types": "./dist/compilation/index.d.ts"
},
"./migration": {
"import": "./dist/migration/index.js",
"require": "./dist/migration/index.cjs",
"types": "./dist/migration/index.d.ts"
}
},
"files": [
Expand Down Expand Up @@ -70,7 +75,8 @@
"entry": [
"source/index.ts",
"source/optims/index.ts",
"source/compilation/index.ts"
"source/compilation/index.ts",
"source/migration/index.ts"
],
"format": [
"cjs",
Expand Down
46 changes: 46 additions & 0 deletions source/migration/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/** @packageDocumentation
## Migrate a situation
{@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.
### Usage
For instance, we have a simple set of rules:
```yaml
age:
question: "Quel est votre âge ?"
````
and the following situation:
```json
{
age: 25
}
```
If I change my model because I want to fix the accent to:
```yaml
âge:
question: "Quel est votre âge ?"
```
I don't want to lose the previous answer, so I can use `migrateSituation` with the following migration instructions:
```yaml
keysToMigrate:
age: âge
```
Then, calling `migrateSituation` with the situation and the migration instructions will return:
```json
{
âge: 25
}
```
*/

export * from './migrateSituation'
113 changes: 113 additions & 0 deletions source/migration/migrateSituation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { getValueWithoutQuotes } from './migrateSituation/getValueWithoutQuotes'
import { handleSituationKeysMigration } from './migrateSituation/handleSituationKeysMigration'
import { handleSituationValuesMigration } from './migrateSituation/handleSituationValuesMigration'
import { handleSpecialCases } from './migrateSituation/handleSpecialCases'
import { Evaluation } from 'publicodes'

export type NodeValue = Evaluation

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

export type DottedName = string

export type MigrationType = {
keysToMigrate: Record<DottedName, DottedName>
valuesToMigrate: Record<DottedName, Record<string, NodeValue>>
}

/**
* 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.
*
* @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:
*
* @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).
*/
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
}

const matchingValueToMigrateObject =
migrationInstructions.valuesToMigrate[
Object.keys(migrationInstructions.valuesToMigrate).find((key) =>
ruleName.includes(key),
) as any
]

const formattedNodeValue =
getValueWithoutQuotes(nodeValue) || (nodeValue as string)

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

situationMigrated = result.situationMigrated
foldedStepsMigrated = result.foldedStepsMigrated
}
})

return { situationMigrated, foldedStepsMigrated }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { DottedName, Situation } from '../migrateSituation'

/**
* Delete a key from the situation and from the foldedSteps if it exists.
* @param ruleName - The rulename to delete.
* @param situation - The situation object.
* @param foldedSteps - The foldedSteps array.
*/
export function deleteKeyFromSituationAndFoldedSteps({
ruleName,
situation,
foldedSteps,
}: {
ruleName: string
situation: Situation
foldedSteps: DottedName[]
}) {
delete situation[ruleName]
const index = foldedSteps?.indexOf(ruleName)

if (index > -1) {
foldedSteps.splice(index, 1)
}
}
19 changes: 19 additions & 0 deletions source/migration/migrateSituation/getValueWithoutQuotes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NodeValue } from '../migrateSituation'

/**
* Returns the value without quotes if it is a string.
* @param value - The value to parse.
*
* @returns The value without quotes if it is a string, null otherwise.
*/
export function getValueWithoutQuotes(value: NodeValue) {
if (
typeof value !== 'string' ||
!value.startsWith("'") ||
value === 'oui' ||
value === 'non'
) {
return null
}
return value.slice(1, -1)
}
97 changes: 97 additions & 0 deletions source/migration/migrateSituation/handleSituationKeysMigration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import {
DottedName,
MigrationType,
NodeValue,
Situation,
} from '../migrateSituation'
import { deleteKeyFromSituationAndFoldedSteps } from './deleteKeyFromSituationAndFoldedSteps'

type Props = {
ruleName: string
nodeValue: NodeValue
situation: Situation
foldedSteps: DottedName[]
migrationInstructions: MigrationType
}

/**
* Updates a key in the situation object and foldedSteps.
* @param ruleName - The name of the rule to update.
* @param nodeValue - The new value for the rule.
* @param situation - The situation object.
* @param foldedSteps - The array of foldedSteps.
* @param migrationInstructions - The migration instructions.
*/
function updateKeyInSituationAndFoldedSteps({
ruleName,
nodeValue,
situation,
foldedSteps,
migrationInstructions,
}: {
ruleName: string
nodeValue: NodeValue
situation: Situation
foldedSteps: DottedName[]
migrationInstructions: MigrationType
}) {
situation[migrationInstructions.keysToMigrate[ruleName]] =
(nodeValue as any)?.valeur ?? nodeValue

delete situation[ruleName]

const index = foldedSteps?.indexOf(ruleName)

if (index > -1) {
foldedSteps[index] = migrationInstructions.keysToMigrate[ruleName]
}
}

/**
* Updates a key in the situation and foldedSteps based on migration instructions.
* If the key is not a key to migrate but a key to delete, it will be removed from the situation and foldedSteps.
* If the key is renamed and needs to be migrated, it will be updated in the situation and foldedSteps.
*
* @param ruleName - The name of the rule/key to update.
* @param nodeValue - The new value for the rule/key.
* @param situation - The current situation object.
* @param foldedSteps - The current foldedSteps array.
* @param migrationInstructions - The migration instructions object.
*
* @returns An object containing the migrated situation and foldedSteps.
*/
export function handleSituationKeysMigration({
ruleName,
nodeValue,
situation,
foldedSteps,
migrationInstructions,
}: Props): { situationMigrated: Situation; foldedStepsMigrated: DottedName[] } {
const situationMigrated = { ...situation }
const foldedStepsMigrated = [...foldedSteps]

// The key is not a key to migrate but a key to delete
if (migrationInstructions.keysToMigrate[ruleName] === '') {
deleteKeyFromSituationAndFoldedSteps({
ruleName,
situation: situationMigrated,
foldedSteps: foldedStepsMigrated,
})
return { situationMigrated, foldedStepsMigrated }
}

if (!migrationInstructions.keysToMigrate[ruleName]) {
return
}

// The key is renamed and needs to be migrated
updateKeyInSituationAndFoldedSteps({
ruleName,
nodeValue,
situation: situationMigrated,
foldedSteps: foldedStepsMigrated,
migrationInstructions,
})

return { situationMigrated, foldedStepsMigrated }
}
Loading

0 comments on commit 04e03ae

Please sign in to comment.