From 5a90aca40f39d19308c43b972493c8919adfbd1a Mon Sep 17 00:00:00 2001 From: Yarchik Date: Tue, 4 Aug 2026 02:01:41 +0100 Subject: [PATCH 1/2] Fix SUBSTRING with a non-positive start position SUBSTRING/SUBSTR/MID compiled 'SUBSTRING(str, pos, len)' to str.substr(pos - 1, len). When pos was 0 or negative, pos - 1 became a negative index and JavaScript's substr() counts that from the end of the string, so 'SUBSTRING("abcdef", 0, 3)' returned 'f' and 'SUBSTRING("abcdef", -1, 3)' returned 'ef'. Follow MySQL semantics instead: a start of 0 yields an empty string, and a negative start counts from the end (clamped to empty when it reaches past the beginning). Positive start positions are unchanged. --- src/55functions.js | 20 +++++++++++++-- test/test-substring-negative-start.js | 35 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 test/test-substring-negative-start.js diff --git a/src/55functions.js b/src/55functions.js index 9bdc42ff24..c94e00b0c7 100644 --- a/src/55functions.js +++ b/src/55functions.js @@ -186,8 +186,24 @@ stdlib.SUBSTRING = stdlib.SUBSTR = stdlib.MID = function (a, b, c) { - if (arguments.length == 2) return und(a, 'y.substr(' + b + '-1)'); - else if (arguments.length == 3) return und(a, 'y.substr(' + b + '-1,' + c + ')'); + if (arguments.length == 2) + return und( + a, + '(__alasql_tmp=(' + + b + + '),__alasql_tmp==0?"":(__alasql_tmp<0?(y.length+__alasql_tmp<0?"":y.substr(y.length+__alasql_tmp)):y.substr(__alasql_tmp-1)))' + ); + else if (arguments.length == 3) + return und( + a, + '(__alasql_tmp=(' + + b + + '),__alasql_tmp==0?"":(__alasql_tmp<0?(y.length+__alasql_tmp<0?"":y.substr(y.length+__alasql_tmp,' + + c + + ')):y.substr(__alasql_tmp-1,' + + c + + ')))' + ); }; stdfn.REGEXP_LIKE = function (a, b, c) { diff --git a/test/test-substring-negative-start.js b/test/test-substring-negative-start.js new file mode 100644 index 0000000000..a07e86b525 --- /dev/null +++ b/test/test-substring-negative-start.js @@ -0,0 +1,35 @@ +if (typeof exports === 'object') { + var assert = require('assert'); + var alasql = require('..'); +} + +describe('SUBSTRING with non-positive start position', function () { + it('follows MySQL semantics for start = 0', function () { + assert.strictEqual(alasql("SELECT VALUE SUBSTRING('abcdef', 0, 3)"), ''); + assert.strictEqual(alasql("SELECT VALUE SUBSTRING('abcdef', 0)"), ''); + }); + + it('follows MySQL semantics for a negative start (counts from the end)', function () { + assert.strictEqual(alasql("SELECT VALUE SUBSTRING('abcdef', -1, 3)"), 'f'); + assert.strictEqual(alasql("SELECT VALUE SUBSTRING('abcdef', -2, 3)"), 'ef'); + assert.strictEqual(alasql("SELECT VALUE SUBSTRING('abcdef', -2)"), 'ef'); + assert.strictEqual(alasql("SELECT VALUE SUBSTRING('abcdef', -6)"), 'abcdef'); + }); + + it('returns empty when a negative start reaches past the string start', function () { + assert.strictEqual(alasql("SELECT VALUE SUBSTRING('abcdef', -7, 3)"), ''); + assert.strictEqual(alasql("SELECT VALUE SUBSTRING('abcdef', -10, 3)"), ''); + assert.strictEqual(alasql("SELECT VALUE SUBSTRING('abcdef', -7)"), ''); + }); + + it('leaves positive start positions unchanged', function () { + assert.strictEqual(alasql("SELECT VALUE SUBSTRING('abcdef', 1, 3)"), 'abc'); + assert.strictEqual(alasql("SELECT VALUE SUBSTRING('abcdef', 2, 3)"), 'bcd'); + assert.strictEqual(alasql("SELECT VALUE SUBSTRING('abcdef', 4)"), 'def'); + }); + + it('applies the same rules to the SUBSTR and MID aliases', function () { + assert.strictEqual(alasql("SELECT VALUE SUBSTR('abcdef', 0, 3)"), ''); + assert.strictEqual(alasql("SELECT VALUE MID('abcdef', -2, 3)"), 'ef'); + }); +}); From 112d95503f1de7dcfa859abb225cf0cf1199a7c2 Mon Sep 17 00:00:00 2001 From: Yarchik Date: Wed, 19 Aug 2026 14:31:34 +0100 Subject: [PATCH 2/2] refactor: move SUBSTRING non-positive-start logic into a readable alasql.utils.substr helper --- src/15utility.js | 12 ++++++++++++ src/55functions.js | 20 ++------------------ 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/src/15utility.js b/src/15utility.js index 5f2c40559b..b89d6451f9 100755 --- a/src/15utility.js +++ b/src/15utility.js @@ -934,6 +934,18 @@ var arrayIntersectDeep = (utils.arrayIntersectDeep = function (a, b) { return r; }); +/** + SUBSTRING with MySQL semantics for a non-positive start: a start of 0 returns + '', a negative start counts from the end of the string, and a start that + reaches before the string start returns ''. `len` is optional. + */ +var substr = (utils.substr = function substr(str, start, len) { + if (start == 0) return ''; + var from = start < 0 ? str.length + start : start - 1; + if (from < 0) return ''; + return len === undefined ? str.substr(from) : str.substr(from, len); +}); + /** Deep clone objects */ diff --git a/src/55functions.js b/src/55functions.js index c94e00b0c7..627d0cc773 100644 --- a/src/55functions.js +++ b/src/55functions.js @@ -186,24 +186,8 @@ stdlib.SUBSTRING = stdlib.SUBSTR = stdlib.MID = function (a, b, c) { - if (arguments.length == 2) - return und( - a, - '(__alasql_tmp=(' + - b + - '),__alasql_tmp==0?"":(__alasql_tmp<0?(y.length+__alasql_tmp<0?"":y.substr(y.length+__alasql_tmp)):y.substr(__alasql_tmp-1)))' - ); - else if (arguments.length == 3) - return und( - a, - '(__alasql_tmp=(' + - b + - '),__alasql_tmp==0?"":(__alasql_tmp<0?(y.length+__alasql_tmp<0?"":y.substr(y.length+__alasql_tmp,' + - c + - ')):y.substr(__alasql_tmp-1,' + - c + - ')))' - ); + if (arguments.length == 2) return und(a, 'alasql.utils.substr(y,' + b + ')'); + else if (arguments.length == 3) return und(a, 'alasql.utils.substr(y,' + b + ',' + c + ')'); }; stdfn.REGEXP_LIKE = function (a, b, c) {