diff --git a/src/foam/mlang/Constant.js b/src/foam/mlang/Constant.js index 7583e6a9c4..3787dcc5b3 100644 --- a/src/foam/mlang/Constant.js +++ b/src/foam/mlang/Constant.js @@ -76,9 +76,12 @@ foam.CLASS({ return '\"' + this.value + '\"'; if ( typeof this.value === 'number' && ! Number.isInteger(this.value) && isFinite(this.value) ) { - var s = this.value.toFixed(20); - s = s.replace(/0+$/, ''); - if ( s.endsWith('.') ) s += '0'; + var s = String(this.value); + if ( s.indexOf('e') !== -1 || s.indexOf('E') !== -1 ) { + s = this.value.toFixed(20); + s = s.replace(/0+$/, ''); + if ( s.endsWith('.') ) s += '0'; + } return s; } diff --git a/src/foam/parse/SimpleQueryParser.java b/src/foam/parse/SimpleQueryParser.java index a1d1c03e68..0456f48003 100644 --- a/src/foam/parse/SimpleQueryParser.java +++ b/src/foam/parse/SimpleQueryParser.java @@ -110,17 +110,21 @@ private foam.lib.parse.Grammar buildGrammar() { // ───────── Primitive Symbols ───────── private void buildPrimitiveSymbols(foam.lib.parse.Grammar g) { - // digits: one or more digit chars joined into a string - g.addSymbol("digits", new Join(new Repeat(Range.create('0', '9'), 1))); + // rawDigits: base grammar for one or more digit chars, no action (preserves leading zeros) + g.addSymbol("rawDigits", new Join(new Repeat(Range.create('0', '9'), 1))); - // float: optional negative, digits, optional decimal part → joined string + // digits: delegates to rawDigits, but has a parseInt action (see buildActions) + g.addSymbol("digits", g.sym("rawDigits")); + + // float: optional negative, rawDigits, optional decimal part → joined string + // Uses rawDigits (not digits) to avoid parseInt stripping leading zeros (e.g., "001" → 1) g.addSymbol("float", new Seq1(1, g.sym("ws"), new Join(new Seq( new Optional(Literal.create("-")), - g.sym("digits"), - new Optional(new Join(new Seq(Literal.create("."), new Optional(g.sym("digits"))))))))); + g.sym("rawDigits"), + new Optional(new Join(new Seq(Literal.create("."), new Optional(g.sym("rawDigits"))))))))); // floats: two or more floats separated by comma (for IN RANGE) g.addSymbol("floats", new Repeat(g.sym("float"), Literal.create(","), 2)); diff --git a/src/foam/parse/SimpleQueryParser.js b/src/foam/parse/SimpleQueryParser.js index 91b2b7a638..d502ff5456 100644 --- a/src/foam/parse/SimpleQueryParser.js +++ b/src/foam/parse/SimpleQueryParser.js @@ -189,10 +189,14 @@ foam.CLASS({ 'range float': seq1(1, sym('ws'), sym('floats'), sym('ws'), ')'), - digits: str(repeat(range('0', '9'), null, 1)), + // rawDigits: base grammar for one or more digit chars, no action (preserves leading zeros) + rawDigits: str(repeat(range('0', '9'), null, 1)), + + // digits: delegates to rawDigits, but has a parseInt action (see actions below) + digits: sym('rawDigits'), // TODO replace '.' with an internationalized decimal point, or have the input preprocessed - float: seq1(1, sym('ws'), str(seq(optional('-'), sym('digits'), optional(str(seq('.', optional(sym('digits')))))))), + float: seq1(1, sym('ws'), str(seq(optional('-'), sym('rawDigits'), optional(str(seq('.', optional(sym('rawDigits')))))))), numberArray: seq1(1, sym('ws'), sym('numbers'), sym('ws'), ')'), diff --git a/src/foam/parse/test/SimpleQueryParserJavaTest.js b/src/foam/parse/test/SimpleQueryParserJavaTest.js index ef2c0245d7..45004629f4 100644 --- a/src/foam/parse/test/SimpleQueryParserJavaTest.js +++ b/src/foam/parse/test/SimpleQueryParserJavaTest.js @@ -267,6 +267,35 @@ foam.CLASS({ ft12.toLowerCase().contains("latitude"), "Float Test12: Inner property NOT IN RANGE produces OR — got: " + ft12 ); + + // Float Test13: Small float IN RANGE preserves decimal precision + String ft13 = buildPredicate("address.latitude IN RANGE (-0.0001, 0.0001)"); + test(ft13 != null, "Float Test13: Small float IN RANGE (-0.0001, 0.0001) parses"); + test( + ft13.contains("-1.000001E-4") || ft13.contains("-0.0001000001") || ft13.contains("-1.0001E-4"), + "Float Test13: Small float IN RANGE lower bound preserves magnitude — got: " + ft13 + ); + test( + ! ft13.contains("0.1") || ft13.contains("0.0001"), + "Float Test13: Small float IN RANGE does NOT corrupt to 0.1 — got: " + ft13 + ); + + // Float Test14: Small float equality preserves decimal precision + String ft14 = buildPredicate("address.longitude = 0.001"); + test(ft14 != null, "Float Test14: Small float = 0.001 parses"); + test( + ! ft14.contains("0.1,") && ! ft14.contains(", 0.1"), + "Float Test14: Small float = 0.001 does NOT corrupt to 0.1 — got: " + ft14 + ); + + // Float Test15: Small float NOT IN RANGE preserves precision + String ft15 = buildPredicate("address.latitude NOT IN RANGE (-0.0001, 0.0001)"); + test(ft15 != null, "Float Test15: Small float NOT IN RANGE parses"); + test( + ft15.toLowerCase().contains("or(") && + ft15.toLowerCase().contains("gte(") && ft15.toLowerCase().contains("lt("), + "Float Test15: Small float NOT IN RANGE produces OR(GTE,LT) — got: " + ft15 + ); ` }, diff --git a/src/foam/parse/test/SimpleQueryParserMQLTest.js b/src/foam/parse/test/SimpleQueryParserMQLTest.js index 22b74b6d88..aead61ecd4 100644 --- a/src/foam/parse/test/SimpleQueryParserMQLTest.js +++ b/src/foam/parse/test/SimpleQueryParserMQLTest.js @@ -18,11 +18,14 @@ foam.CLASS({ var EPSILON = 0.0000000001; var f = function(num) { return num - EPSILON; }; var g = function(num) { return num + EPSILON; }; - // Format a float the same way Constant.toMQL does + // Expected float formatting: String() for normal decimals, toFixed for scientific notation var fmt = function(num) { - var s = num.toFixed(20); - s = s.replace(/0+$/, ''); - if ( s.endsWith('.') ) s += '0'; + var s = String(num); + if ( s.indexOf('e') !== -1 || s.indexOf('E') !== -1 ) { + s = num.toFixed(20); + s = s.replace(/0+$/, ''); + if ( s.endsWith('.') ) s += '0'; + } return s; }; // Format a date the same way Constant.toMQL does (truncate at second colon) @@ -120,6 +123,43 @@ foam.CLASS({ '(latitude>=' + fmt(g(8.5)) + ' OR latitude<' + fmt(f(6.5)) + ')'), 'Float MQL 8: NOT IN RANGE'); + // ── Float small-value precision (Constant.toMQL must not corrupt small floats) ── + x.test(this.constantToMQL(0.001) === '0.001', + 'Float MQL constant 1: 0.001 formats correctly, got: ' + this.constantToMQL(0.001)); + x.test(this.constantToMQL(0.0001) === '0.0001', + 'Float MQL constant 2: 0.0001 formats correctly, got: ' + this.constantToMQL(0.0001)); + x.test(this.constantToMQL(-0.0001) === '-0.0001', + 'Float MQL constant 3: -0.0001 formats correctly, got: ' + this.constantToMQL(-0.0001)); + x.test(this.constantToMQL(0.5) === '0.5', + 'Float MQL constant 4: 0.5 formats correctly, got: ' + this.constantToMQL(0.5)); + x.test(this.constantToMQL(0.1) === '0.1', + 'Float MQL constant 5: 0.1 formats correctly, got: ' + this.constantToMQL(0.1)); + x.test(this.constantToMQL(1.23) === '1.23', + 'Float MQL constant 6: 1.23 formats correctly, got: ' + this.constantToMQL(1.23)); + x.test(this.constantToMQL(0.00000001) === '0.00000001', + 'Float MQL constant 7: 0.00000001 formats correctly, got: ' + this.constantToMQL(0.00000001)); + + // ── Float epsilon-adjusted small values (the values that actually appear in MQL) ── + x.test(this.constantToMQL(f(0.0001)) === String(f(0.0001)), + 'Float MQL epsilon 1: f(0.0001) no corruption, got: ' + this.constantToMQL(f(0.0001))); + x.test(this.constantToMQL(g(0.0001)) === String(g(0.0001)), + 'Float MQL epsilon 2: g(0.0001) no corruption, got: ' + this.constantToMQL(g(0.0001))); + x.test(this.constantToMQL(f(-0.0001)) === String(f(-0.0001)), + 'Float MQL epsilon 3: f(-0.0001) no corruption, got: ' + this.constantToMQL(f(-0.0001))); + x.test(this.constantToMQL(g(-0.0001)) === String(g(-0.0001)), + 'Float MQL epsilon 4: g(-0.0001) no corruption, got: ' + this.constantToMQL(g(-0.0001))); + + // ── Float IN RANGE with small values (user-reported bug) ── + x.test(this.isValidMQL('address.latitude IN RANGE (-0.0001, 0.0001)', + 'latitude>=' + String(f(-0.0001)) + ' AND latitude<' + String(g(0.0001))), + 'Float MQL small range 1: IN RANGE (-0.0001, 0.0001)'); + x.test(this.isValidMQL('address.latitude NOT IN RANGE (-0.0001, 0.0001)', + '(latitude>=' + String(g(0.0001)) + ' OR latitude<' + String(f(-0.0001)) + ')'), + 'Float MQL small range 2: NOT IN RANGE (-0.0001, 0.0001)'); + x.test(this.isValidMQL('address.latitude IN RANGE (0.001, 0.01)', + 'latitude>=' + String(f(0.001)) + ' AND latitude<' + String(g(0.01))), + 'Float MQL small range 3: IN RANGE (0.001, 0.01)'); + // ── Float scientific notation guard ── x.test(this.hasNoScientificNotation(this.buildMQL('address.longitude = 0')), 'Float MQL 9: equals zero has no scientific notation'); @@ -235,6 +275,11 @@ foam.CLASS({ return mql === expectedMQL; }, + function constantToMQL(value) { + var c = foam.mlang.Constant.create({ value: value }); + return c.toMQL(); + }, + function hasNoScientificNotation(mql) { if ( mql == null ) return false; console.log('Scientific notation check: ' + mql); diff --git a/src/foam/parse/test/SimpleQueryParserTest.js b/src/foam/parse/test/SimpleQueryParserTest.js index 4353077de7..4e2cf74502 100644 --- a/src/foam/parse/test/SimpleQueryParserTest.js +++ b/src/foam/parse/test/SimpleQueryParserTest.js @@ -75,6 +75,20 @@ foam.CLASS({ x.test(this.isValid("address.latitude IN RANGE (6.5, 8.5)", "AND(GTE(foam.core.auth.User.address.foam.core.auth.Address.latitude, " + testFloatStart(6.5) + "),LT(foam.core.auth.User.address.foam.core.auth.Address.latitude, " + testFloatEnd(8.5) + "))"), "Float Test11: Inner property is within range 6.5 to 8.5"); x.test(this.isValid('address.latitude NOT IN RANGE (6.5, 8.5)', "OR(GTE(foam.core.auth.User.address.foam.core.auth.Address.latitude, " + testFloatEnd(8.5) + "),LT(foam.core.auth.User.address.foam.core.auth.Address.latitude, " + testFloatStart(6.5) + "))"), 'Float Test12: Inner property is not within range 6.5 to 8.5'); + // Float small-value precision tests — parser must preserve leading zeros after decimal + x.test(this.isValidSymbol('float', '0.001', '0.001', true), 'Float Test13: Small float 0.001 preserves precision'); + + // Float small-value IN RANGE — the user-reported bug + x.test(this.isValid("address.latitude IN RANGE (-0.0001, 0.0001)", + "AND(GTE(foam.core.auth.User.address.foam.core.auth.Address.latitude, " + testFloatStart(-0.0001) + "),LT(foam.core.auth.User.address.foam.core.auth.Address.latitude, " + testFloatEnd(0.0001) + "))"), + "Float Test16: Small float IN RANGE (-0.0001, 0.0001)"); + x.test(this.isValid('address.latitude NOT IN RANGE (-0.0001, 0.0001)', + "OR(GTE(foam.core.auth.User.address.foam.core.auth.Address.latitude, " + testFloatEnd(0.0001) + "),LT(foam.core.auth.User.address.foam.core.auth.Address.latitude, " + testFloatStart(-0.0001) + "))"), + 'Float Test17: Small float NOT IN RANGE (-0.0001, 0.0001)'); + x.test(this.isValid("address.longitude = 0.001", + "AND(GTE(foam.core.auth.User.address.foam.core.auth.Address.longitude, " + testFloatStart(0.001) + "),LT(foam.core.auth.User.address.foam.core.auth.Address.longitude, " + testFloatEnd(0.001) + "))"), + "Float Test18: Small float equals 0.001"); + // Date format tests x.test(this.isValidSymbol('date', '2025-01-01', [testDate([2025, 0, 1, 12]), testDate([2025, 0, 2, 12])].toString()), 'Date Test1: ISO date YYYY-MM-DD'); x.test(this.isValidSymbol('date', '25/10/01', [testDate([2025, 9, 1, 12]), testDate([2025, 9, 2, 12])].toString()), 'Date Test2: Short date YY/MM/DD');