diff --git a/README.md b/README.md index adb643b..229d545 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ by the nosgestesclimat.fr team. :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 diff --git a/package.json b/package.json index c7c6315..f50c6df 100644 --- a/package.json +++ b/package.json @@ -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": [ @@ -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", diff --git a/source/migration/index.ts b/source/migration/index.ts new file mode 100644 index 0000000..1392a39 --- /dev/null +++ b/source/migration/index.ts @@ -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' diff --git a/source/migration/migrateSituation.ts b/source/migration/migrateSituation.ts new file mode 100644 index 0000000..837131b --- /dev/null +++ b/source/migration/migrateSituation.ts @@ -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 + valuesToMigrate: Record> +} + +/** + * 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 } +} diff --git a/source/migration/migrateSituation/deleteKeyFromSituationAndFoldedSteps.ts b/source/migration/migrateSituation/deleteKeyFromSituationAndFoldedSteps.ts new file mode 100644 index 0000000..393779d --- /dev/null +++ b/source/migration/migrateSituation/deleteKeyFromSituationAndFoldedSteps.ts @@ -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) + } +} diff --git a/source/migration/migrateSituation/getValueWithoutQuotes.ts b/source/migration/migrateSituation/getValueWithoutQuotes.ts new file mode 100644 index 0000000..9cd8ef8 --- /dev/null +++ b/source/migration/migrateSituation/getValueWithoutQuotes.ts @@ -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) +} diff --git a/source/migration/migrateSituation/handleSituationKeysMigration.ts b/source/migration/migrateSituation/handleSituationKeysMigration.ts new file mode 100644 index 0000000..9e77d3f --- /dev/null +++ b/source/migration/migrateSituation/handleSituationKeysMigration.ts @@ -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 } +} diff --git a/source/migration/migrateSituation/handleSituationValuesMigration.ts b/source/migration/migrateSituation/handleSituationValuesMigration.ts new file mode 100644 index 0000000..4bf9df5 --- /dev/null +++ b/source/migration/migrateSituation/handleSituationValuesMigration.ts @@ -0,0 +1,110 @@ +import { + DottedName, + MigrationType, + NodeValue, + Situation, +} from '../migrateSituation' +import { deleteKeyFromSituationAndFoldedSteps } from './deleteKeyFromSituationAndFoldedSteps' + +type Props = { + ruleName: DottedName + nodeValue: NodeValue + situation: Situation + foldedSteps: DottedName[] + migrationInstructions: MigrationType +} + +/** + * Get the migrated value. + * + * @param ruleName - The name of the rule to update. + * @param nodeValue - The new value for the rule. + * @param migrationInstructions - The migration instructions. + */ +function getMigratedValue({ + ruleName, + nodeValue, + migrationInstructions, +}: { + ruleName: DottedName + nodeValue: NodeValue + migrationInstructions: MigrationType +}): NodeValue { + if ( + typeof migrationInstructions.valuesToMigrate[ruleName][ + nodeValue as string + ] === 'string' && + migrationInstructions.valuesToMigrate[ruleName][nodeValue as string] !== + 'oui' && + migrationInstructions.valuesToMigrate[ruleName][nodeValue as string] !== + 'non' + ) { + return `'${migrationInstructions.valuesToMigrate[ruleName][nodeValue as string]}'` + } + + if ( + ( + migrationInstructions.valuesToMigrate[ruleName][nodeValue as string] as { + valeur: number + } + )?.valeur !== undefined + ) { + return ( + migrationInstructions.valuesToMigrate[ruleName][nodeValue as string] as { + valeur: number + } + ).valeur + } + + return migrationInstructions.valuesToMigrate[ruleName][ + nodeValue as string + ] as string | number +} + +/** + * Handles the migration of situation values based on the provided migration instructions. + * + * @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 handleSituationValuesMigration({ + ruleName, + nodeValue, + situation, + foldedSteps, + migrationInstructions, +}: Props): { situationMigrated: Situation; foldedStepsMigrated: DottedName[] } { + if (!migrationInstructions.valuesToMigrate[ruleName]) { + return + } + + const situationMigrated = { ...situation } + const foldedStepsMigrated = [...foldedSteps] + + // The value is not a value to migrate and the key has to be deleted + if ( + migrationInstructions.valuesToMigrate[ruleName][nodeValue as string] === '' + ) { + deleteKeyFromSituationAndFoldedSteps({ + ruleName, + situation: situationMigrated, + foldedSteps: foldedStepsMigrated, + }) + + return { situationMigrated, foldedStepsMigrated } + } + + // The value is renamed and needs to be migrated + situationMigrated[ruleName] = getMigratedValue({ + ruleName, + nodeValue, + migrationInstructions, + }) + + return { situationMigrated, foldedStepsMigrated } +} diff --git a/source/migration/migrateSituation/handleSpecialCases.ts b/source/migration/migrateSituation/handleSpecialCases.ts new file mode 100644 index 0000000..0c9ad87 --- /dev/null +++ b/source/migration/migrateSituation/handleSpecialCases.ts @@ -0,0 +1,55 @@ +import { DottedName, NodeValue, Situation } from '../migrateSituation' + +type Props = { + ruleName: DottedName + nodeValue: NodeValue + situation: Situation +} + +// 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 ruleName - The name of the rule. + * @param nodeValue - The node value. + * @param situation - The situation object. + * @returns - The updated situation object. + */ +export function handleSpecialCases({ ruleName, nodeValue, situation }: Props) { + const situationUpdated = { ...situation } + + // Special case, number store as a string, we have to convert it to a number + if ( + nodeValue && + typeof nodeValue === 'string' && + !isNaN(parseFloat(nodeValue)) + ) { + situationUpdated[ruleName] = parseFloat(nodeValue) + } + + // Special case : wrong value format, legacy from previous publicodes version + // handle the case where valeur is a string "2.33" + if (nodeValue && nodeValue['valeur'] !== undefined) { + situationUpdated[ruleName] = + typeof nodeValue['valeur'] === 'string' && + !isNaN(parseFloat(nodeValue['valeur'])) + ? parseFloat(nodeValue['valeur']) + : (nodeValue['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 (nodeValue && nodeValue['valeur'] !== undefined) { + situationUpdated[ruleName] = + typeof nodeValue['valeur'] === 'string' && + !isNaN(parseFloat(nodeValue['valeur'])) + ? parseFloat(nodeValue['valeur']) + : (nodeValue['valeur'] as number) + } + + return situationUpdated +} diff --git a/test/migration/migrateSituation.test.ts b/test/migration/migrateSituation.test.ts new file mode 100644 index 0000000..67c26d7 --- /dev/null +++ b/test/migration/migrateSituation.test.ts @@ -0,0 +1,66 @@ +import { migrateSituation } from '../../source/migration/migrateSituation' + +const migrationInstructions = { + keysToMigrate: { age: 'âge', 'année de naissance': '' }, + valuesToMigrate: { prénom: { jean: 'Jean avec un J', michel: '' } }, +} + +describe('migrateSituation', () => { + it('should migrate key', () => { + expect( + migrateSituation({ + situation: { age: 27 }, + foldedSteps: ['age'], + migrationInstructions, + }), + ).toEqual({ situationMigrated: { âge: 27 }, foldedStepsMigrated: ['âge'] }) + }), + it('should migrate value', () => { + expect( + migrateSituation({ + situation: { prénom: 'jean' }, + foldedSteps: ['prénom'], + migrationInstructions, + }), + ).toEqual({ + situationMigrated: { prénom: "'Jean avec un J'" }, + foldedStepsMigrated: ['prénom'], + }) + }), + it('should delete key', () => { + expect( + migrateSituation({ + situation: { 'année de naissance': 1997 }, + foldedSteps: ['année de naissance'], + migrationInstructions, + }), + ).toEqual({ + foldedStepsMigrated: [], + situationMigrated: {}, + }) + }), + it('should delete value', () => { + expect( + migrateSituation({ + situation: { prénom: 'michel' }, + foldedSteps: ['prénom'], + migrationInstructions, + }), + ).toEqual({ + foldedStepsMigrated: [], + situationMigrated: {}, + }) + }), + it('should support old situations', () => { + expect( + migrateSituation({ + situation: { âge: { valeur: 27, unité: 'an' } }, + foldedSteps: ['âge'], + migrationInstructions, + }), + ).toEqual({ + foldedStepsMigrated: ['âge'], + situationMigrated: { âge: 27 }, + }) + }) +}) diff --git a/typedoc.json b/typedoc.json index 74fd0d4..9905c4c 100644 --- a/typedoc.json +++ b/typedoc.json @@ -1,7 +1,12 @@ { "$schema": "https://typedoc.org/schema.json", "name": "@publicodes/tools API", - "entryPoints": ["./source", "./source/optims/", "./source/compilation/"], + "entryPoints": [ + "./source", + "./source/optims/", + "./source/compilation/", + "./source/migration" + ], "navigationLinks": { "GitHub": "https://github.com/publicodes/tools" }, diff --git a/yarn.lock b/yarn.lock index 784d6a0..f6c68d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2578,7 +2578,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -2596,7 +2605,14 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -2869,7 +2885,16 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==