diff --git a/build.sh b/build.sh index 4b64e3aa15..cb67f4310f 100755 --- a/build.sh +++ b/build.sh @@ -86,6 +86,7 @@ cat \ src/58json.js \ src/59convert.js \ src/60createtable.js \ + src/60range.js \ src/61date.js \ src/62droptable.js \ src/63createvertex.js \ diff --git a/src/60range.js b/src/60range.js new file mode 100644 index 0000000000..0b9f8fb115 --- /dev/null +++ b/src/60range.js @@ -0,0 +1,241 @@ +/* +// +// PostgreSQL Range Types Implementation +// Date: 2025-12-07 +// (c) AlaSQL Contributors +// +*/ + +// Helper functions for comparing values (including dates) +// JavaScript's comparison operators work with valueOf() for both Date and Number types +var compareValues = function (a, b) { + if (a < b) return -1; + if (a > b) return 1; + return 0; +}; + +var minValue = function (a, b) { + return a <= b ? a : b; +}; + +var maxValue = function (a, b) { + return a >= b ? a : b; +}; + +// Range class to represent a range of values +alasql.Range = function (lower, upper, lowerInc, upperInc) { + this.lower = lower; + this.upper = upper; + // Default: inclusive lower bound, exclusive upper bound [lower, upper) + this.lowerInc = lowerInc !== undefined ? lowerInc : true; + this.upperInc = upperInc !== undefined ? upperInc : false; +}; + +alasql.Range.prototype.isEmpty = function () { + if (this.lower === undefined || this.upper === undefined) return true; + var cmp = compareValues(this.lower, this.upper); + if (cmp > 0) return true; + if (cmp === 0 && (!this.lowerInc || !this.upperInc)) return true; + return false; +}; + +alasql.Range.prototype.contains = function (value) { + if (this.isEmpty()) return false; + var lowerCmp = compareValues(this.lower, value); + var upperCmp = compareValues(value, this.upper); + var lowerOk = this.lowerInc ? lowerCmp <= 0 : lowerCmp < 0; + var upperOk = this.upperInc ? upperCmp <= 0 : upperCmp < 0; + return lowerOk && upperOk; +}; + +alasql.Range.prototype.overlaps = function (other) { + if (this.isEmpty() || other.isEmpty()) return false; + // Ranges overlap if they are not disjoint + // They are disjoint if one ends before the other starts + var cmp1 = compareValues(this.upper, other.lower); + var cmp2 = compareValues(other.upper, this.lower); + if (cmp1 < 0) return false; + if (cmp2 < 0) return false; + // Handle boundary cases where bounds are equal but exclusive + if (cmp1 === 0 && (!this.upperInc || !other.lowerInc)) return false; + if (cmp2 === 0 && (!other.upperInc || !this.lowerInc)) return false; + return true; +}; + +alasql.Range.prototype.union = function (other) { + if (this.isEmpty()) return other; + if (other.isEmpty()) return this; + + var lower = minValue(this.lower, other.lower); + var upper = maxValue(this.upper, other.upper); + var lowerCmp = compareValues(this.lower, other.lower); + var upperCmp = compareValues(this.upper, other.upper); + + var lowerInc = + lowerCmp < 0 ? this.lowerInc : lowerCmp > 0 ? other.lowerInc : this.lowerInc || other.lowerInc; + var upperInc = + upperCmp > 0 ? this.upperInc : upperCmp < 0 ? other.upperInc : this.upperInc || other.upperInc; + + return new alasql.Range(lower, upper, lowerInc, upperInc); +}; + +alasql.Range.prototype.intersection = function (other) { + if (this.isEmpty() || other.isEmpty()) return null; + if (!this.overlaps(other)) return null; + + var lower = maxValue(this.lower, other.lower); + var upper = minValue(this.upper, other.upper); + var lowerCmp = compareValues(this.lower, other.lower); + var upperCmp = compareValues(this.upper, other.upper); + + var lowerInc = + lowerCmp > 0 ? this.lowerInc : lowerCmp < 0 ? other.lowerInc : this.lowerInc && other.lowerInc; + var upperInc = + upperCmp < 0 ? this.upperInc : upperCmp > 0 ? other.upperInc : this.upperInc && other.upperInc; + + var result = new alasql.Range(lower, upper, lowerInc, upperInc); + return result.isEmpty() ? null : result; +}; + +alasql.Range.prototype.difference = function (other) { + if (this.isEmpty()) return null; + if (other.isEmpty()) return this; + if (!this.overlaps(other)) return this; + + // If other completely contains this, return null + var lowerCmp1 = compareValues(other.lower, this.lower); + var upperCmp1 = compareValues(other.upper, this.upper); + + if (lowerCmp1 <= 0 && upperCmp1 >= 0) { + var thisLowerIn = lowerCmp1 === 0 ? other.lowerInc && this.lowerInc : lowerCmp1 < 0; + var thisUpperIn = upperCmp1 === 0 ? other.upperInc && this.upperInc : upperCmp1 > 0; + if (thisLowerIn && thisUpperIn) return null; + } + + // Return the portion before other starts + var lowerCmp2 = compareValues(this.lower, other.lower); + if (lowerCmp2 < 0) { + var upperInc = !other.lowerInc; + return new alasql.Range(this.lower, other.lower, this.lowerInc, upperInc); + } + + // Return the portion after other ends + var upperCmp2 = compareValues(this.upper, other.upper); + if (upperCmp2 > 0) { + var lowerInc = !other.upperInc; + return new alasql.Range(other.upper, this.upper, lowerInc, this.upperInc); + } + + return null; +}; + +alasql.Range.prototype.isSubsetOf = function (other) { + if (this.isEmpty()) return true; + if (other.isEmpty()) return false; + + var lowerCmp = compareValues(this.lower, other.lower); + var upperCmp = compareValues(this.upper, other.upper); + + var lowerOk = lowerCmp > 0 || (lowerCmp === 0 && (!this.lowerInc || other.lowerInc)); + var upperOk = upperCmp < 0 || (upperCmp === 0 && (!this.upperInc || other.upperInc)); + + return lowerOk && upperOk; +}; + +alasql.Range.prototype.isSupersetOf = function (other) { + return other.isSubsetOf(this); +}; + +alasql.Range.prototype.isDisjointFrom = function (other) { + return !this.overlaps(other); +}; + +// Range constructor functions + +// Integer range (int4range) +stdfn.INT4RANGE = function (lower, upper, lowerInc, upperInc) { + return new alasql.Range(lower, upper, lowerInc, upperInc); +}; + +// Big integer range (int8range) +stdfn.INT8RANGE = function (lower, upper, lowerInc, upperInc) { + return new alasql.Range(lower, upper, lowerInc, upperInc); +}; + +// Numeric range (numrange) +stdfn.NUMRANGE = function (lower, upper, lowerInc, upperInc) { + return new alasql.Range(lower, upper, lowerInc, upperInc); +}; + +// Timestamp range (tsrange) +stdfn.TSRANGE = function (lower, upper, lowerInc, upperInc) { + return new alasql.Range(lower, upper, lowerInc, upperInc); +}; + +// Timestamp with timezone range (tstzrange) +stdfn.TSTZRANGE = function (lower, upper, lowerInc, upperInc) { + return new alasql.Range(lower, upper, lowerInc, upperInc); +}; + +// Date range (daterange) +stdfn.DATERANGE = function (lower, upper, lowerInc, upperInc) { + return new alasql.Range(lower, upper, lowerInc, upperInc); +}; + +// Range operation functions + +// Check if ranges overlap +stdfn.RANGE_OVERLAPS = function (range1, range2) { + if (!range1 || !range2) return false; + return range1.overlaps(range2); +}; + +// Check if range contains element +stdfn.RANGE_CONTAINS = function (range, element) { + if (!range) return false; + return range.contains(element); +}; + +// Check if range1 contains range2 +stdfn.RANGE_CONTAINS_RANGE = function (range1, range2) { + if (!range1 || !range2) return false; + return range1.isSupersetOf(range2); +}; + +// Union of two ranges +stdfn.RANGE_UNION = function (range1, range2) { + if (!range1) return range2; + if (!range2) return range1; + return range1.union(range2); +}; + +// Intersection of two ranges +stdfn.RANGE_INTERSECTION = function (range1, range2) { + if (!range1 || !range2) return null; + return range1.intersection(range2); +}; + +// Difference of two ranges +stdfn.RANGE_DIFFERENCE = function (range1, range2) { + if (!range1) return null; + if (!range2) return range1; + return range1.difference(range2); +}; + +// Check if range1 is subset of range2 +stdfn.RANGE_IS_SUBSET = function (range1, range2) { + if (!range1 || !range2) return false; + return range1.isSubsetOf(range2); +}; + +// Check if range1 is superset of range2 +stdfn.RANGE_IS_SUPERSET = function (range1, range2) { + if (!range1 || !range2) return false; + return range1.isSupersetOf(range2); +}; + +// Check if ranges are disjoint +stdfn.RANGE_IS_DISJOINT = function (range1, range2) { + if (!range1 || !range2) return false; + return range1.isDisjointFrom(range2); +}; diff --git a/test/test055-B.js b/test/test055-B.js new file mode 100644 index 0000000000..f1bda5d178 --- /dev/null +++ b/test/test055-B.js @@ -0,0 +1,160 @@ +if (typeof exports === 'object') { + var assert = require('assert'); + var alasql = require('..'); +} + +describe('Test 055-B - PostgreSQL Range Types', function () { + const test = '055B'; + + before(function () { + alasql('create database test' + test); + alasql('use test' + test); + }); + + after(function () { + alasql('drop database test' + test); + }); + + it('A) Create integer ranges', function () { + var r1 = alasql('SELECT int4range(10, 20) as r')[0].r; + assert.deepEqual(r1, {lower: 10, upper: 20, lowerInc: true, upperInc: false}); + }); + + it('B) Create numeric ranges', function () { + var r1 = alasql('SELECT numrange(11.1, 22.2) as r')[0].r; + assert.deepEqual(r1, {lower: 11.1, upper: 22.2, lowerInc: true, upperInc: false}); + }); + + it('C) Create date ranges', function () { + var d1 = new Date('2020-01-01'); + var d2 = new Date('2020-12-31'); + var r1 = alasql('SELECT daterange(?, ?) as r', [d1, d2])[0].r; + assert(r1); + // Range object should have the dates + assert(r1.lower); + assert(r1.upper); + assert.equal(r1.lower.getFullYear(), 2020); + assert.equal(r1.upper.getFullYear(), 2020); + }); + + it('D) Test range_overlaps - overlapping ranges', function () { + var res = alasql('SELECT range_overlaps(int4range(10, 20), int4range(15, 25)) as r')[0].r; + assert.equal(res, true); + }); + + it('E) Test range_overlaps - non-overlapping ranges', function () { + var res = alasql('SELECT range_overlaps(int4range(10, 20), int4range(25, 30)) as r')[0].r; + assert.equal(res, false); + }); + + it('F) Test range_contains - element in range', function () { + var res = alasql('SELECT range_contains(int4range(10, 20), 15) as r')[0].r; + assert.equal(res, true); + }); + + it('G) Test range_contains - element not in range', function () { + var res = alasql('SELECT range_contains(int4range(10, 20), 25) as r')[0].r; + assert.equal(res, false); + }); + + it('H) Test range_contains_range - subset', function () { + var res = alasql('SELECT range_contains_range(int4range(10, 30), int4range(15, 25)) as r')[0].r; + assert.equal(res, true); + }); + + it('I) Test range_contains_range - not subset', function () { + var res = alasql('SELECT range_contains_range(int4range(10, 20), int4range(15, 25)) as r')[0].r; + assert.equal(res, false); + }); + + it('J) Test range_union', function () { + var r = alasql('SELECT range_union(int4range(10, 20), int4range(15, 25)) as r')[0].r; + assert.deepEqual(r, {lower: 10, upper: 25, lowerInc: true, upperInc: false}); + }); + + it('K) Test range_intersection', function () { + var r = alasql('SELECT range_intersection(int4range(10, 20), int4range(15, 25)) as r')[0].r; + assert.deepEqual(r, {lower: 15, upper: 20, lowerInc: true, upperInc: false}); + }); + + it('L) Test range_intersection - no overlap returns null', function () { + var r = alasql('SELECT range_intersection(int4range(10, 20), int4range(25, 30)) as r')[0].r; + assert.equal(r, null); + }); + + it('M) Test range_difference', function () { + var r = alasql('SELECT range_difference(int4range(10, 30), int4range(20, 40)) as r')[0].r; + // Difference should give [10, 20) + assert.deepEqual(r, {lower: 10, upper: 20, lowerInc: true, upperInc: false}); + }); + + it('N) Use range in table and query', function () { + // Note: AlaSQL treats 'range' as a generic column type that can store any JavaScript object + // In this case, it stores Range instances created by range constructor functions + alasql('CREATE TABLE events (id int, period range)'); + alasql('INSERT INTO events VALUES (1, int4range(10, 20))'); + alasql('INSERT INTO events VALUES (2, int4range(15, 25))'); + alasql('INSERT INTO events VALUES (3, int4range(30, 40))'); + + // Find events overlapping with [12, 18] + var res = alasql('SELECT * FROM events WHERE range_overlaps(period, int4range(12, 18))'); + assert.equal(res.length, 2); + assert.equal(res[0].id, 1); + assert.equal(res[1].id, 2); + }); + + it('O) Range with inclusive/exclusive bounds', function () { + // Test default bounds (inclusive lower, exclusive upper) + var r1 = alasql('SELECT int4range(10, 20) as r')[0].r; + assert.equal(r1.lowerInc, true); + assert.equal(r1.upperInc, false); + }); + + it('P) Range empty check', function () { + var r1 = alasql('SELECT int4range(10, 10) as r')[0].r; + assert.equal(r1.isEmpty(), true); + + var r2 = alasql('SELECT int4range(10, 20) as r')[0].r; + assert.equal(r2.isEmpty(), false); + }); + + it('Q) Test isSubsetOf method', function () { + var res = alasql('SELECT range_is_subset(int4range(15, 20), int4range(10, 30)) as r')[0].r; + assert.equal(res, true); + + var res2 = alasql('SELECT range_is_subset(int4range(5, 20), int4range(10, 30)) as r')[0].r; + assert.equal(res2, false); + }); + + it('R) Test isSupersetOf method', function () { + var res = alasql('SELECT range_is_superset(int4range(10, 30), int4range(15, 20)) as r')[0].r; + assert.equal(res, true); + + var res2 = alasql('SELECT range_is_superset(int4range(10, 25), int4range(15, 30)) as r')[0].r; + assert.equal(res2, false); + }); + + it('S) Test isDisjointFrom method', function () { + var res = alasql('SELECT range_is_disjoint(int4range(10, 20), int4range(25, 30)) as r')[0].r; + assert.equal(res, true); + + var res2 = alasql('SELECT range_is_disjoint(int4range(10, 20), int4range(15, 25)) as r')[0].r; + assert.equal(res2, false); + }); + + it('T) Test int8range (bigint range)', function () { + var r = alasql('SELECT int8range(1000000, 2000000) as r')[0].r; + assert.deepEqual(r, {lower: 1000000, upper: 2000000, lowerInc: true, upperInc: false}); + }); + + it('U) Test tsrange (timestamp range)', function () { + var t1 = new Date('2020-01-01T10:00:00'); + var t2 = new Date('2020-01-01T12:00:00'); + var r = alasql('SELECT tsrange(?, ?) as r', [t1, t2])[0].r; + assert(r); + assert(r.lower); + assert(r.upper); + assert.equal(r.lower.getHours(), 10); + assert.equal(r.upper.getHours(), 12); + }); +});