Improved support for CQL Decimal - #376
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #376 +/- ##
==========================================
+ Coverage 88.70% 89.23% +0.52%
==========================================
Files 59 60 +1
Lines 4933 5119 +186
Branches 1429 1469 +40
==========================================
+ Hits 4376 4568 +192
+ Misses 322 316 -6
Partials 235 235 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
cmoesel
left a comment
There was a problem hiding this comment.
Wow. This is a pretty intense PR! It's great to finally have more reasonable support for decimals and decimal-based operations. I've left some comments about some mostly small things. I think the biggest thing is I would like for us to support retention of decimal precision -- but I'm totally fine with doing that as a follow-on to this PR (rather than trying to fit it into this PR).
The other big question -- which is definitely a "for later" thing -- is if we might gain some simplicity by modeling all numeric types as CQL classes (e.g., Integer, Long, and Decimal). I think they could all be backed by decimal.js, and if they had a common base class and/or implemented a common interface it might simplify things a lot. This is just a half-baked idea, but I think it may be worth exploring -- especially if we're going to do a major release w/ breaking changes anyway.
One other thing to note: I'm guessing we may need to update cql-exec-fhir to account for the new Decimal class. Maybe after we review and merge this PR, we should do a 4.0.0-beta.1 release so we can try integrating it w/ a cql-exec-fhir beta release as well (which both will probably be needed to update / test fqm-execution). I know I said I wanted to target Connectathon for a release of this stuff, but I think a beta release would be fine (I feel ok reporting against a beta release if it's on the main branch).
…o get a couple more passing
…f passing in index from Array.map
- Represent numeric intervals as Ranges - Fix range conversion bug for boundaries whose value is 0 - Add default unit '1' to numeric range quantities - Update dependencies
…r to truncation. add new flag to MathUtil.divide to specify truncated division
…lity semantics (ignore trailing zeros)
|
Just pushed a series of commits based on some further testing:
I'm going to continue poking at this but as of now I'm calling it officially ready for re-review. |
cmoesel
left a comment
There was a problem hiding this comment.
This is an impressive PR. Who knew decimals would affect so much! Thanks for working through it. I think we're super close.
I've only left a few small comments (some of them just to vent about things that annoy me in CQL). I also noticed that if I run npm run check:all some of the package-lock.json files change (based on the introduction of the decimal.js library).
Let me know if you want to discuss any of this!
| // For decimals, equivalent means the values are the same | ||
| // with the comparison done on values rounded to | ||
| // the precision of the least precise operand; | ||
| // trailing zeroes after the decimal are ignored in determining precision | ||
| // for equivalent comparison. |
There was a problem hiding this comment.
Not a big deal, but I can't figure out how you decide what your line length / line break strategy is on some of these comments. You don't have to change it, but I'm curious how you decide (for example, why break at column 53 on line 174, but column 78 on line 176?
There was a problem hiding this comment.
Not a super well-defined strategy. I wouldn't bother for, say, typesetting a whole page of text, but for comments like this that are just a few lines I try to keep a whole clause or whole idea on one line. (This sentence comes from the spec, if I wrote it myself I'd try to fit one full idea per line. I might add quotes around it now to indicate it's from the spec.) Here I probably added an intentional line break at the semicolon, then tried to figure out how I could make the parts before and after fit within our line length
| it('should reject invalid values', () => { | ||
| (() => Decimal.from('not a number')).should.throw(); | ||
| (() => Decimal.from('NaN')).should.throw(); | ||
| (() => Decimal.from('Infinity')).should.throw(); | ||
| (() => Decimal.from(Number.NaN)).should.throw(); | ||
| (() => Decimal.from(Number.POSITIVE_INFINITY)).should.throw(); | ||
| }); |
There was a problem hiding this comment.
Should we also test for invalid scale (e.g., a negative number as the scale)?
There was a problem hiding this comment.
The only places that a caller can pass in arbitrary scale are round, withScale, and withMinimumScale - Decimal.from() doesn't take one, and the constructor is private so it's dependent on the other methods to pass in correct values. (I think the logic on the others should be safe but if you see something definitely call it out). I have a couple invalid scale examples for withScale but I'll add some for the others too
| describe('equivalent', () => { | ||
| it('should compare at the least precise operand precision, ignoring trailing zeros', () => { | ||
| Decimal.from('1.2').equivalent('1.24').should.be.true(); | ||
| Decimal.from('1.20').equivalent('1.24').should.be.true(); |
There was a problem hiding this comment.
This is correct (based on "trailing zeroes after the decimal are ignored in determining precision for equivalent comparison."), but just for the record, I don't like it! The spec generally says that precision matters, and I feel like if an input says 1.20 rather than 1.2, that means they measured to the hundredths -- and based on that, the fact that the hundredths place holds a 0 should not matter.
For example, 1.00000000 ~ 1.499999999 is true? That's ridiculous. If someone is using instruments to measure to the hundred-millionths, then obviously those small differences matter. We should not ignore them because the value happens to be even at 0s. I get why 1.00 ~ 1.00499999, because one number was measured at a different scale to begin with, but... 1.00000000 is a different story.
OK. Getting off my soapbox now. Again, there is nothing to do here. I just needed to vent.
| CqlIntervalOperatorsTest.Except.DecimalIntervalExcept1to3 # Wrong output: Interval Except should be precision-aware (based on interval Start/End). | ||
| CqlIntervalOperatorsTest.Except.QuantityIntervalExcept1to4 # Wrong output: Interval Except should be precision-aware (based on interval Start/End). Unrelated second issue: the ELM representation of Quantity is a plain number which does not preserve the value scale |
There was a problem hiding this comment.
I'm not sure about this one. The description of Except does not indicate anything about precision, nor does it define itself in terms of successor, predecessor, start, or end. We implemented it using some of those concepts, but that doesn't mean that was the correct way to implement it (and notably had different results since predecessor/successor always used .00000001 on decimals).
How did you determine that precision of the input intervals should be used here? I think we should request that this be clarified in the spec.
| "CqlArithmeticFunctionsTest.Truncated Divide.TruncatedDivide10d1ByNeg3D1Quantity" Wrong output: The resulting Quantity should have an appropriate unit; 'g' / 'g' should be '1', not 'g'. See https://github.com/cqframework/cql-tests/pull/148 | ||
| "CqlArithmeticFunctionsTest.Truncated Divide.TruncatedDivide10By5DQuantity" Wrong output: The resulting Quantity should have an appropriate unit | ||
| "CqlArithmeticFunctionsTest.Truncated Divide.TruncatedDivide414By206DQuantity" Wrong output: The resulting Quantity should have an appropriate unit | ||
| "CqlStringOperatorsTest.toString tests.QuantityToString" Wrong output: Spec says Quantity and Decimal ToString must always contain a decimal point and at least 1 digit on each side |
There was a problem hiding this comment.
I agree that this is wrong based on the current spec, but it is also wrong for the string representation to introduce a precision that is not there in the input. I know you already have raised that elsewhere; this is just another case where it matters.
For quantities, it's actually a little easier because the context itself tells you that the number is a decimal (since quantity values are always decimals), so 1 'cm' is unambiguously a decimal with scale 0 (as opposed to a bare number like 1).
| ValueLiteralsAndSelectors.Decimal.DecimalPos10Pow28ToZeroOneStepDecimalMaxValue Wrong answer (null vs big number); intermediate value exceeds max Decimal | ||
|
|
||
| "CqlDateTimeOperatorsTest.Uncertainty tests.DateTimeDurationBetweenUncertainInterval" Wrong answer: [17, 44] vs [16, 44] | ||
| "CqlDateTimeOperatorsTest.Uncertainty tests.TimeDurationBetweenHourDiffPrecision2" Wrong answer: 1 vs uncertainty [0, 1] |
There was a problem hiding this comment.
I disagree with the expected answer in this test. I think our answer [0, 1] is correct because @T06 represents an imprecise time between @T06:00:00.0 and @T06:59:59.999, so hours between @T06 and @T07:00:00 is only 1 when @T06 is @T06:00:00.0, but it is 0 for all other potential values of @T06. So... I think this should be moved to the section of the skip list for wrong expected outputs.
There was a problem hiding this comment.
Sounds good. Sorry about these, I'm pretty sure these were existing tests that were unintentionally not being tested because earlier tests in the file had (intentional) parse errors. Skipping those parse error tests revealed these, and once it was apparent they weren't related to Decimal I didn't look much closer and just skipped them.
|
|
||
| "CqlDateTimeOperatorsTest.Uncertainty tests.DateTimeDurationBetweenUncertainInterval" Wrong answer: [17, 44] vs [16, 44] | ||
| "CqlDateTimeOperatorsTest.Uncertainty tests.TimeDurationBetweenHourDiffPrecision2" Wrong answer: 1 vs uncertainty [0, 1] | ||
| CqlDateTimeOperatorsTest.Subtract.DateTimeSubtract1YearInSeconds Wrong answer: Date math evaluates to 2015-06 vs expected 2015-05 |
There was a problem hiding this comment.
I hate this one. The test is:
DateTime(2016,5) - 31535999 seconds = DateTime(2015, 5)
and they expect true.
But... let's do the math: 60*60*24*365 = 31,536,000. The CQL above uses 31535999, which is one second short of a full year. This is why we say 2015-06. Never mind the fact that 2016 was a leap year, so really there is a whole extra day of seconds (so 31535999 is actually 86401 seconds short of that particular year span).
Unfortunately, they arrive at their answer because they convert 31535999 seconds to months and use (the very imprecise) 60*60*24*30 to determine how many seconds are in a month -- so 31535999 / 60*60*24*30 = 12.16666628, and hence they arrive at 2015-05.
I've argued that if we need a consistent conversion factor for months it should be 60*60*24*365/12 (which corrects this example so that 31535999 / 60*60*24*30 = 11.99999962), but based on the language in Equal, I've lost that argument.
Anyway, we're right to skip it for now, and we should probably fix the engine to use 30-day months, but I really don't want to.
| CqlListOperatorsTest.Sort.simpleSortAsc Wrong output: Queries return distinct lists by default; need to use "all" to retain duplicates | ||
| CqlListOperatorsTest.Sort.simpleSortDesc Wrong output: Queries return distinct lists by default; need to use "all" to retain duplicates | ||
| CqlIntervalOperatorsTest.PointFrom.TestPointFromNull Wrong output: Interval[null, null] is not a unit interval, nor is it null | ||
| "CqlArithmeticFunctionsTest.Truncated Divide.TruncatedDivide10d1ByNeg3D1Quantity" Wrong output: The resulting Quantity should have an appropriate unit; 'g' / 'g' should be '1', not 'g'. See https://github.com/cqframework/cql-tests/pull/148 |
There was a problem hiding this comment.
Not a big deal, but so far we have only used " when the test name has a space in it.
There was a problem hiding this comment.
This does have a space in the test group name (Truncated Divide)
There was a problem hiding this comment.
Doh. I looked at that like 5 times!
This PR migrates Decimals from being represented by plain JS
numberto being represented by aDecimalclass. (CQL Integers remain represented by plain JS numbers.) Our newDecimalclass is a wrapper around the decimal.js library. All interactions with the library are limited to one file so if we decide that's the wrong library, it should be straightforward to change.Where possible, I've tried to make the changes developer-friendly, for instance, because
Quantity.valueis always a Decimal, theQuantityconstructor accepts anything that can be converted to a Decimal, eg, a number, bigint, or string. This is primarily relevant to the unit tests where we have a lot of "result should equal(new Quantity(3, 'g')" style test expectations.This change means that now all CQL types are represented 1:1 by their respective JS types, so passing a "type" argument around became unnecessary in several places.
Decimals need to be normalized to a max of 8 digits after the decimal point, and have a maximum and minimum value, and so my philosophy was to try to normalize and bounds check as few times as possible, and hence as late as possible: only in the ELM layer. There are some remaining instances of checking for overflow in the datatype layer that I could have removed, but that would require even more refactoring so I left them for now.
Per the spec, the string representation of a Decimal must always contain at least one digit on either side of the decimal point. (eg,
1.0not1or1., and0.1not.1, and not exponential notation like1e8)Changes here mostly fall into 3 categories:
Note this PR does not implement the CQL Precision operator, and the Decimal class doesn't keep track of significant figures. (JS numbers didn't either, so this isn't a regression) This means that trailing zeros after the decimal point will not be preserved. eg:
decimal.jsdoesn't support this natively, so a future effort will have to add a second internal state field to track scale.The two unit test failures are expected at this point (I removed the part of the
expand Intervallogic that covers those 2 specific tests) but I'm waiting for more direction on #cql > Interval Expand example before doing anything more on that front.Notable Boundaries
There are a couple instances where interactions with plain JS numbers are forced:
luxon, specifically the timezoneOffset fieldnumberanywayAlso note that the Decimal constructor accepts JS numbers that do not need to represent integers, but there is the risk of loss of precision if the literal used cannot be represented precisely as a js number. To be safe, consumers of this library constructing a Decimal instance should generally use the string constructor which guarantees round-trip safety. Eg:
Pull requests into cql-execution require the following.
Submitter and reviewer should ✔ when done.
For items that are not-applicable, mark "N/A" and ✔.
Submitter:
npm run checkto run tests, lint, and prettier)Reviewer:
Name: