diff --git a/common/changes/@itwin/core-quantity/nam-parser-label-fix_2025-09-30-14-41.json b/common/changes/@itwin/core-quantity/nam-parser-label-fix_2025-09-30-14-41.json new file mode 100644 index 000000000000..16caad61dd1e --- /dev/null +++ b/common/changes/@itwin/core-quantity/nam-parser-label-fix_2025-09-30-14-41.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/core-quantity", + "comment": "Return error result when unit labels aren't matched during quantity parsing", + "type": "none" + } + ], + "packageName": "@itwin/core-quantity" +} \ No newline at end of file diff --git a/core/quantity/src/Parser.ts b/core/quantity/src/Parser.ts index 508555abe0a9..f592f88016dc 100644 --- a/core/quantity/src/Parser.ts +++ b/core/quantity/src/Parser.ts @@ -556,6 +556,8 @@ export class Parser { if (unitConversion !== undefined) return unitConversion; } + // if there were unique unit labels but not matched to any units, throw an error + if (uniqueUnitLabels.length > 0) throw new QuantityError(QuantityStatus.UnitLabelSuppliedButNotMatched, `The unit label(s) ${uniqueUnitLabels.join(", ")} could not be matched to a known unit.`); } return unitConversion; } @@ -613,7 +615,13 @@ export class Parser { } const defaultUnit = format.units && format.units.length > 0 ? format.units[0][0] : undefined; - defaultUnitConversion = defaultUnitConversion ? defaultUnitConversion : Parser.getDefaultUnitConversion(tokens, unitsConversions, defaultUnit); + try { + defaultUnitConversion = defaultUnitConversion ? defaultUnitConversion : Parser.getDefaultUnitConversion(tokens, unitsConversions, defaultUnit); + } catch (e) { + // If we failed to get the default unit conversion, we need to return an error. + if (e instanceof QuantityError && e.errorNumber === QuantityStatus.UnitLabelSuppliedButNotMatched) + return { ok: false, error: ParseError.UnitLabelSuppliedButNotMatched }; + } if (format.type === FormatType.Bearing && format.units !== undefined && format.units.length > 0) { const units = format.units; diff --git a/core/quantity/src/test/Parsing.test.ts b/core/quantity/src/test/Parsing.test.ts index 95859d402691..8fef9aea39f3 100644 --- a/core/quantity/src/test/Parsing.test.ts +++ b/core/quantity/src/test/Parsing.test.ts @@ -986,6 +986,34 @@ describe("Synchronous Parsing tests:", async () => { } }); + it("parse returns a bad value with ParseError.UnitLabelSuppliedButNotMatched", async () => { + const formatDataUnitless = { + formatTraits: ["keepSingleZero", "showUnitLabel"], + precision: 8, + type: "Fractional", + uomSeparator: "", + allowMathematicOperations: true, + }; + const formatUnitless = new Format("test"); + await formatUnitless.fromJSON(unitsProvider, formatDataUnitless); + const unitlessParserSpec = await ParserSpec.create(formatUnitless, unitsProvider, outUnit, unitsProvider); + + const testData = [ + "100 INVALIDUNIT", + "50 BADLABEL", + "25.5 UNKNOWNUNIT", + "1.5 NOTFOUND", + "1metera + 123 + 1.65" + ]; + + for (const testEntry of testData) { + const parseResult = Parser.parseQuantityString(testEntry, unitlessParserSpec); + expect(Parser.isParseError(parseResult)).to.be.true; + if (Parser.isParseError(parseResult)) { + expect(parseResult.error).toEqual(ParseError.UnitLabelSuppliedButNotMatched); + } + } + }); it("Parse into length values using custom parse labels", () => { const testData = [ // if no quantity is provided then the format unit is used to determine unit diff --git a/docs/changehistory/NextVersion.md b/docs/changehistory/NextVersion.md index 7745b914edd4..ef341277ae5a 100644 --- a/docs/changehistory/NextVersion.md +++ b/docs/changehistory/NextVersion.md @@ -3,3 +3,12 @@ publish: false --- # NextVersion +- [NextVersion](#nextversion) + - [@itwin/core-quantity](#itwincore-quantity) + - [Changes](#changes) + +## @itwin/core-quantity + +### Changes + +- Fixed a bug in [Parser]($quantity) where invalid unit labels were silently ignored during parsing when no format units were specified, leading to incorrect results. Previously, when using a unitless format with input like "12 im" (a typo "in" for inches), the parser would successfully parse it as "12 meters" when the persistence unit was set to meters. The parser now correctly returns `ParseError.UnitLabelSuppliedButNotMatched` when a unitless format is used and a unit label is provided but cannot be matched to any known unit. This fix ensures parsing errors are noticed and properly handled by the caller, preventing silent failures and ensuring data integrity. diff --git a/docs/learning/quantity/index.md b/docs/learning/quantity/index.md index c31baadb95ad..0bb46eaa87db 100644 --- a/docs/learning/quantity/index.md +++ b/docs/learning/quantity/index.md @@ -12,6 +12,7 @@ - [Formats Provider](#formats-provider) - [Units Provider](#units-provider) - [Unit Conversion](#unit-conversion) + - [Parser Behavior](#parser-behavior) - [Persistence](#persistence) - [FormatSet](#formatset) - [Using KindOfQuantities to Retrieve Formats](#using-kindofquantities-to-retrieve-formats) @@ -108,6 +109,27 @@ The [AlternateUnitLabelsProvider]($quantity) interface allows users to specify a Unit conversion is performed through a [UnitConversionSpec]($quantity). These objects are generated by a `UnitsProvider`, with the implementation determined by each specific provider. During initialization, a `ParserSpec` or `FormatterSpec` can ask for `UnitConversionSpec` objects provided via the `UnitsProvider`. During parsing and formatting, the specification will retrieve the `UnitConversionSpec` between the source and destination units to apply the unit conversion. +#### Parser Behavior + +The [Parser]($quantity) converts text strings into numeric quantity values by tokenizing the input and matching unit labels to known units. The parsing process follows these steps: + +1. __Tokenization__: The input string is broken down into tokens representing numbers, unit labels, and mathematical operators (if enabled). + +2. __Unit Label Matching__: For each unit label token found, the parser attempts to match it against: + - Units explicitly defined in the format specification + - Units from the same phenomenon (unit family) provided by the `UnitsProvider` + - Alternate labels defined through the `AlternateUnitLabelsProvider` + +3. __Error Handling__: The parser's behavior when encountering unrecognized unit labels depends on the format configuration: + - __Unitless Format__ (no units defined in format): If a unit label is provided but cannot be matched to any known unit, the parser returns `ParseError.UnitLabelSuppliedButNotMatched`. This prevents silent failures where typos like "12 im" (instead of "12 in") would incorrectly parse as "12 meters" when the persistence unit is meters. + - __Format with Units__ (units explicitly defined): If an unrecognized unit label is provided (e.g., "12 ABCDEF"), the parser falls back to the format's default unit for backward compatibility. For example, with a feet format, "12 ABCDEF" would parse as "12 feet". + +4. __Default Unit Behavior__: If no unit label is provided in the input (e.g., just "12"), the parser uses the default unit specified in the format. For unitless formats, if the input contains multiple unit labels, the first successfully matched unit becomes the default for subsequent unitless values in the same expression. + +5. __Unit Conversion__: Once units are matched, the parser applies the appropriate unit conversions to produce a value in the persistence unit specified by the `ParserSpec`. + +This error handling ensures that parsing errors are caught in unitless format contexts, preventing data corruption from unrecognized or mistyped unit labels, while maintaining backward compatibility for formats with explicitly defined units. + ## Persistence We expose APIs and interfaces to support persistence of formats. Different from [KindOfQuantity](../../bis/ec/kindofquantity.md), which enables persistence of formats at the schema level, this section covers persistence at the application level.