diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index 284bbcca263..e312807286e 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -25,6 +25,7 @@ image::https://raw.githubusercontent.com/apache/tinkerpop/master/docs/static/ima [[release-4-0-0]] === TinkerPop 4.0.0 (Release Date: NOT OFFICIALLY RELEASED YET) +* Added typed numeric wrappers and `preciseNumbers` connection option to `gremlin-javascript` for explicit control over numeric type serialization and deserialization. * Added `NextN(n)` to `Traversal` in `gremlin-go` for batched result iteration, providing API parity with `next(n)` in the Java, Python, and .NET GLVs, and updated the Go translators in `gremlin-core` and `gremlin-javascript` to emit `NextN(n)` for the batched form. * Added Gremlator, a single page web application, that translates Gremlin into various programming languages like Javascript and Python. * Removed `uuid` dependency from `gremlin-javascript` in favor of the built-in `globalThis.crypto.randomUUID()`. diff --git a/docs/src/reference/gremlin-variants.asciidoc b/docs/src/reference/gremlin-variants.asciidoc index 2ad34f1d9e6..521a7017bf7 100644 --- a/docs/src/reference/gremlin-variants.asciidoc +++ b/docs/src/reference/gremlin-variants.asciidoc @@ -1677,6 +1677,7 @@ can be passed in the constructor of a new `Client` or `DriverRemoteConnection` : |options.traversalSource |String |The traversal source. |'g' |options.headers |Object |Additional HTTP header key/values included with each request. |undefined |options.interceptors |RequestInterceptor/RequestInterceptor[] |One or more functions that can modify the HTTP request before it is sent. |undefined +|options.preciseNumbers |Boolean |When `true`, wraps deserialized numbers in typed wrappers that preserve the server's original type. |undefined |options.reader |GraphBinaryReader |The reader to use for deserializing responses. |GraphBinaryReader |options.writer |GraphBinaryWriter |The writer to use for serializing requests. |GraphBinaryWriter |options.enableUserAgentOnConnect |Boolean |Determines if a user agent header will be sent with requests. |true @@ -1975,15 +1976,61 @@ g.V().hasLabel('person').groupCount().by('age') Either of the above two options accomplishes the desired goal as both prevent `groupCount()` from having to process the possibility of `null`. +[[gremlin-javascript-numeric-types]] +=== Numeric Types + +JavaScript has a single `Number` type (IEEE 754 double) which cannot distinguish between Gremlin numeric types. The driver +provides typed wrapper classes and factory functions that give explicit control over serialization and deserialization. + +Wrapping a value selects the GremlinLang type suffix and GraphBinary type code sent to the server. Without wrappers the +driver infers types automatically, so existing code is unaffected. + +[source,javascript] +---- +const { toLong, toInt, toFloat, toDouble, toShort, toByte } = gremlin.structure; + +g.V().has('age', toInt(29)).next(); +g.V().has('score', toFloat(3.14)).next(); +g.V().has('id', toLong('9007199254740993')).next(); +---- + +`toLong()` accepts `number`, `string`, or `bigint`. String and bigint inputs support the full signed 64-bit range; +number inputs must be within the safe integer range (`RangeError` is thrown otherwise). + +By default, the driver deserializes all numeric values as plain `Number` (or `BigInt` for large longs). To preserve the +server's original type, set `preciseNumbers` to `true` on the connection: + +[source,javascript] +---- +const g = traversal().with_(new DriverRemoteConnection('http://localhost:8182/gremlin', { + preciseNumbers: true +})); + +const v = await g.V(1).elementMap().next(); +const age = v.value.get('age'); // Int { value: 29, type: 'int' } +age + 1; // 30 — wrappers support arithmetic via valueOf() +---- + +The `unwrap()` helper extracts the raw value from any wrapper, passing non-wrapper values through unchanged: + +[source,javascript] +---- +const { unwrap } = gremlin.structure; +unwrap(toInt(29)); // 29 +unwrap('hello'); // 'hello' +---- + [[gremlin-javascript-limitations]] === Limitations * JavaScript's `Number` type is an IEEE 754 double-precision float. `Float`, `Byte`, and `Short` values from the server -are deserialized as `Number` and lose their original type information. +are deserialized as `Number` and lose their original type information. Use `preciseNumbers: true` to preserve the +original types — see <>. * `Long` values outside the safe integer range (|n| > 2^53 - 1) are deserialized as `BigInt` to preserve precision. Values within the safe range are deserialized as `Number`. The same server type may produce different JavaScript types. * `Number.isInteger(1.0)` is `true` in JavaScript, so the driver cannot distinguish integer values from whole-number -doubles. `BigDecimal` is not implemented. +doubles. `BigDecimal` is not implemented. Typed wrappers (e.g. `toInt()`, `toDouble()`) can be used to control the +exact type sent to the server — see <>. * The driver applies GremlinLang type suffixes automatically based on value characteristics: integers within the 32-bit signed range are unsuffixed (Int), integers beyond that up to `Number.MAX_SAFE_INTEGER` use the `L` suffix (Long), non-integer numbers and integers beyond the safe range use the `D` suffix (Double), and `BigInt` values use the `N` diff --git a/docs/src/upgrade/release-4.x.x.asciidoc b/docs/src/upgrade/release-4.x.x.asciidoc index 960c090f5d7..aa14264ca0a 100644 --- a/docs/src/upgrade/release-4.x.x.asciidoc +++ b/docs/src/upgrade/release-4.x.x.asciidoc @@ -442,6 +442,53 @@ beyond this limit will be rejected with an error. See: link:https://issues.apache.org/jira/browse/TINKERPOP-3247[TINKERPOP-3247] +==== JavaScript Typed Numeric Wrappers + +JavaScript has a single `Number` type (IEEE 754 double) which loses the distinction between Gremlin numeric types like +`int`, `float`, `long`, and `double`. The `gremlin-javascript` driver now provides typed wrapper classes and factory +functions that give explicit control over how numbers are serialized and deserialized. + +On the *serialization* side, wrapping a value controls the GremlinLang type suffix and GraphBinary type code sent to +the server: + +[source,javascript] +---- +const { toInt, toLong, toFloat, toDouble } = gremlin.structure; + +g.V().has('age', toInt(29)).next(); // age sent as Int +g.V().has('score', toFloat(3.14)).next(); // score sent as Float +g.V().has('id', toLong('9007199254740993')).next(); // id sent as Long +---- + +`toLong()` accepts `number`, `string`, or `bigint`. Number inputs must be within the safe integer range (throws +`RangeError` otherwise); string and bigint inputs support the full signed 64-bit range. + +Without wrappers, the driver continues to infer types automatically from the JavaScript value, so existing code is +unaffected. + +On the *deserialization* side, a new `preciseNumbers: true` connection option wraps incoming numeric values in the +same typed wrappers, preserving the server's original type information: + +[source,javascript] +---- +const g = traversal().with_(new DriverRemoteConnection('http://localhost:8182/gremlin', { + preciseNumbers: true +})); + +const v = await g.V(1).elementMap().next(); +const age = v.value.get('age'); // Int { value: 29, type: 'int' } +age + 1; // 30 — wrappers support arithmetic via valueOf() +---- + +The `unwrap()` helper extracts the raw value from any wrapper, passing non-wrapper values through unchanged: + +[source,javascript] +---- +const { unwrap } = gremlin.structure; +unwrap(toInt(29)); // 29 +unwrap('hello'); // 'hello' +---- + === Upgrading for Providers ==== Graph System Providers diff --git a/gremlin-js/gremlin-javascript/lib/driver/connection.ts b/gremlin-js/gremlin-javascript/lib/driver/connection.ts index ea1b231d599..12f7da04674 100644 --- a/gremlin-js/gremlin-javascript/lib/driver/connection.ts +++ b/gremlin-js/gremlin-javascript/lib/driver/connection.ts @@ -24,7 +24,7 @@ import { Buffer } from 'buffer'; import { EventEmitter } from 'eventemitter3'; import type { Agent } from 'node:http'; -import ioc from '../structure/io/binary/GraphBinary.js'; +import ioc, { createPreciseReader } from '../structure/io/binary/GraphBinary.js'; import StreamReader from '../structure/io/binary/internals/StreamReader.js'; import * as utils from '../utils.js'; import ResultSet from './result-set.js'; @@ -53,6 +53,7 @@ export type ConnectionOptions = { ca?: string[]; cert?: string | string[] | Buffer; pfx?: string | Buffer; + preciseNumbers?: boolean; reader?: any; rejectUnauthorized?: boolean; traversalSource?: string; @@ -87,7 +88,7 @@ export default class Connection extends EventEmitter { ) { super(); - this._reader = options.reader || graphBinaryReader; + this._reader = options.reader || (options.preciseNumbers === true ? createPreciseReader() : graphBinaryReader); this._writer = 'writer' in options ? options.writer : graphBinaryWriter; this.traversalSource = options.traversalSource || 'g'; this._enableUserAgentOnConnect = options.enableUserAgentOnConnect !== false; diff --git a/gremlin-js/gremlin-javascript/lib/index.ts b/gremlin-js/gremlin-javascript/lib/index.ts index fd40186eb6b..f79c31e948b 100644 --- a/gremlin-js/gremlin-javascript/lib/index.ts +++ b/gremlin-js/gremlin-javascript/lib/index.ts @@ -87,6 +87,18 @@ export const structure = { Vertex: graph.Vertex, VertexProperty: graph.VertexProperty, toLong: utils.toLong, + toInt: utils.toInt, + toFloat: utils.toFloat, + toDouble: utils.toDouble, + toShort: utils.toShort, + toByte: utils.toByte, + Int: utils.Int, + Float: utils.Float, + Double: utils.Double, + Short: utils.Short, + Byte: utils.Byte, + Long: utils.Long, + unwrap: utils.unwrap, }; export default { driver, process, structure }; diff --git a/gremlin-js/gremlin-javascript/lib/process/gremlin-lang.ts b/gremlin-js/gremlin-javascript/lib/process/gremlin-lang.ts index 6d8c09287b7..2a07ad8dec4 100644 --- a/gremlin-js/gremlin-javascript/lib/process/gremlin-lang.ts +++ b/gremlin-js/gremlin-javascript/lib/process/gremlin-lang.ts @@ -19,7 +19,7 @@ import { P, TextP, EnumValue } from './traversal.js'; import { OptionsStrategy, TraversalStrategy } from './traversal-strategy.js'; -import { Long } from '../utils.js'; +import { Long, Int, Float, Double, Short, Byte, INT32_MIN, INT32_MAX } from '../utils.js'; import { Vertex } from '../structure/graph.js'; import { Buffer } from 'buffer'; @@ -72,6 +72,21 @@ export default class GremlinLang { if (arg instanceof Long) { return String(arg.value) + 'L'; } + if (arg instanceof Float) { + return GremlinLang._fpAsString(arg.value, 'F'); + } + if (arg instanceof Double) { + return GremlinLang._fpAsString(arg.value, 'D'); + } + if (arg instanceof Short) { + return String(arg.value) + 'S'; + } + if (arg instanceof Byte) { + return String(arg.value) + 'B'; + } + if (arg instanceof Int) { + return String(arg.value); + } if (typeof arg === 'bigint') { return String(arg) + 'N'; } @@ -80,7 +95,7 @@ export default class GremlinLang { if (arg === Infinity) return '+Infinity'; if (arg === -Infinity) return '-Infinity'; if (!Number.isInteger(arg)) return String(arg) + 'D'; - if (arg >= -2147483648 && arg <= 2147483647) return String(arg); + if (arg >= INT32_MIN && arg <= INT32_MAX) return String(arg); // Outside safe integer range, values have lost precision and may exceed Java Long — emit as Double. if (arg > Number.MAX_SAFE_INTEGER || arg < -Number.MAX_SAFE_INTEGER) return String(arg) + 'D'; return String(arg) + 'L'; @@ -187,6 +202,13 @@ export default class GremlinLang { return this; } + private static _fpAsString(v: number, suffix: 'F' | 'D'): string { + if (v === Infinity) return '+Infinity'; + if (v === -Infinity) return '-Infinity'; + if (Number.isNaN(v)) return 'NaN'; + return Number.isInteger(v) ? `${v}.0${suffix}` : `${v}${suffix}`; + } + getGremlin(prefix: string = 'g'): string { if (this.gremlin.length > 0 && this.gremlin[0] !== '.') { return this.gremlin; diff --git a/gremlin-js/gremlin-javascript/lib/structure/io/binary/GraphBinary.js b/gremlin-js/gremlin-javascript/lib/structure/io/binary/GraphBinary.js index b8930f91c44..4bd402b5960 100644 --- a/gremlin-js/gremlin-javascript/lib/structure/io/binary/GraphBinary.js +++ b/gremlin-js/gremlin-javascript/lib/structure/io/binary/GraphBinary.js @@ -70,48 +70,79 @@ import AnySerializer from './internals/AnySerializer.js'; import GraphBinaryReader from './internals/GraphBinaryReader.js'; import GraphBinaryWriter from './internals/GraphBinaryWriter.js'; -const ioc = {}; - -ioc.DataType = DataType; -ioc.utils = utils; - -ioc.serializers = {}; - -ioc.intSerializer = new IntSerializer(ioc); -ioc.longSerializer = new LongSerializer(ioc); -ioc.stringSerializer = new StringSerializer(ioc, ioc.DataType.STRING); -ioc.dateTimeSerializer = new DateTimeSerializer(ioc); -ioc.doubleSerializer = new DoubleSerializer(ioc); -ioc.floatSerializer = new FloatSerializer(ioc); -ioc.listSerializer = new ArraySerializer(ioc, ioc.DataType.LIST); -ioc.mapSerializer = new MapSerializer(ioc); -ioc.setSerializer = new SetSerializer(ioc, ioc.DataType.SET); -ioc.uuidSerializer = new UuidSerializer(ioc); -ioc.edgeSerializer = new EdgeSerializer(ioc); -ioc.pathSerializer = new PathSerializer(ioc); -ioc.propertySerializer = new PropertySerializer(ioc); -ioc.vertexSerializer = new VertexSerializer(ioc); -ioc.vertexPropertySerializer = new VertexPropertySerializer(ioc); -ioc.bigIntegerSerializer = new BigIntegerSerializer(ioc); -ioc.byteSerializer = new ByteSerializer(ioc); -ioc.binarySerializer = new BinarySerializer(ioc); -ioc.shortSerializer = new ShortSerializer(ioc); -ioc.booleanSerializer = new BooleanSerializer(ioc); -ioc.markerSerializer = new MarkerSerializer(ioc); -ioc.unspecifiedNullSerializer = new UnspecifiedNullSerializer(ioc); -ioc.enumSerializer = new EnumSerializer(ioc); - -// Register stub serializers for unimplemented v4 types -new StubSerializer(ioc, ioc.DataType.TREE, 'Tree'); -new StubSerializer(ioc, ioc.DataType.GRAPH, 'Graph'); -new StubSerializer(ioc, ioc.DataType.COMPOSITEPDT, 'CompositePDT'); -new StubSerializer(ioc, ioc.DataType.PRIMITIVEPDT, 'PrimitivePDT'); - -ioc.numberSerializationStrategy = new NumberSerializationStrategy(ioc); -ioc.anySerializer = new AnySerializer(ioc); - -ioc.graphBinaryReader = new GraphBinaryReader(ioc); -ioc.graphBinaryWriter = new GraphBinaryWriter(ioc); +import { Float, Double, Int, Long, Short, Byte } from '../../../utils.js'; + +function createIoc(anySerializerOptions) { + const ioc = {}; + + ioc.DataType = DataType; + ioc.utils = utils; + + ioc.serializers = {}; + + ioc.intSerializer = new IntSerializer(ioc); + ioc.longSerializer = new LongSerializer(ioc); + ioc.stringSerializer = new StringSerializer(ioc, ioc.DataType.STRING); + ioc.dateTimeSerializer = new DateTimeSerializer(ioc); + ioc.doubleSerializer = new DoubleSerializer(ioc); + ioc.floatSerializer = new FloatSerializer(ioc); + ioc.listSerializer = new ArraySerializer(ioc, ioc.DataType.LIST); + ioc.mapSerializer = new MapSerializer(ioc); + ioc.setSerializer = new SetSerializer(ioc, ioc.DataType.SET); + ioc.uuidSerializer = new UuidSerializer(ioc); + ioc.edgeSerializer = new EdgeSerializer(ioc); + ioc.pathSerializer = new PathSerializer(ioc); + ioc.propertySerializer = new PropertySerializer(ioc); + ioc.vertexSerializer = new VertexSerializer(ioc); + ioc.vertexPropertySerializer = new VertexPropertySerializer(ioc); + ioc.bigIntegerSerializer = new BigIntegerSerializer(ioc); + ioc.byteSerializer = new ByteSerializer(ioc); + ioc.binarySerializer = new BinarySerializer(ioc); + ioc.shortSerializer = new ShortSerializer(ioc); + ioc.booleanSerializer = new BooleanSerializer(ioc); + ioc.markerSerializer = new MarkerSerializer(ioc); + ioc.unspecifiedNullSerializer = new UnspecifiedNullSerializer(ioc); + ioc.enumSerializer = new EnumSerializer(ioc); + + // Register stub serializers for unimplemented v4 types + new StubSerializer(ioc, ioc.DataType.TREE, 'Tree'); + new StubSerializer(ioc, ioc.DataType.GRAPH, 'Graph'); + new StubSerializer(ioc, ioc.DataType.COMPOSITEPDT, 'CompositePDT'); + new StubSerializer(ioc, ioc.DataType.PRIMITIVEPDT, 'PrimitivePDT'); + + ioc.numberSerializationStrategy = new NumberSerializationStrategy(ioc); + ioc.anySerializer = new AnySerializer(ioc, anySerializerOptions); + + ioc.graphBinaryReader = new GraphBinaryReader(ioc); + ioc.graphBinaryWriter = new GraphBinaryWriter(ioc); + + return ioc; +} + +export function createPreciseReader() { + const wrapperMap = new Map([ + [DataType.INT, Int], + [DataType.LONG, Long], + [DataType.FLOAT, Float], + [DataType.DOUBLE, Double], + [DataType.SHORT, Short], + [DataType.BYTE, Byte], + ]); + + const preciseIoc = createIoc({ + postDeserialize(result, typeCode) { + const Wrapper = wrapperMap.get(typeCode); + if (Wrapper && result !== null && result !== undefined) { + return new Wrapper(result); + } + return result; + }, + }); + + return preciseIoc.graphBinaryReader; +} + +const ioc = createIoc(); export { default as DataType } from './internals/DataType.js'; diff --git a/gremlin-js/gremlin-javascript/lib/structure/io/binary/internals/AnySerializer.js b/gremlin-js/gremlin-javascript/lib/structure/io/binary/internals/AnySerializer.js index c3bd93bf1de..bfc20c00162 100644 --- a/gremlin-js/gremlin-javascript/lib/structure/io/binary/internals/AnySerializer.js +++ b/gremlin-js/gremlin-javascript/lib/structure/io/binary/internals/AnySerializer.js @@ -22,8 +22,9 @@ */ export default class AnySerializer { - constructor(ioc) { + constructor(ioc, { postDeserialize } = {}) { this.ioc = ioc; + this._postDeserialize = postDeserialize || null; // specifically ordered, the first canBeUsedFor=true wins this.serializers = [ @@ -84,11 +85,17 @@ export default class AnySerializer { throw new Error(`AnySerializer: unexpected {value_flag}=0x${value_flag.toString(16)} at position ${pos}`); } + let result; try { - return await serializer.deserializeValue(reader, value_flag, type_code); + result = await serializer.deserializeValue(reader, value_flag, type_code); } catch (err) { err.message = `${serializer.constructor.name}.deserializeValue() at position ${pos}: ${err.message}`; throw err; } + + if (this._postDeserialize) { + return this._postDeserialize(result, type_code); + } + return result; } } diff --git a/gremlin-js/gremlin-javascript/lib/structure/io/binary/internals/NumberSerializationStrategy.js b/gremlin-js/gremlin-javascript/lib/structure/io/binary/internals/NumberSerializationStrategy.js index c457935e450..61c74fd5512 100644 --- a/gremlin-js/gremlin-javascript/lib/structure/io/binary/internals/NumberSerializationStrategy.js +++ b/gremlin-js/gremlin-javascript/lib/structure/io/binary/internals/NumberSerializationStrategy.js @@ -21,6 +21,8 @@ * @author Igor Ostapenko */ +import { Long, Int, Float, Double, Short, Byte, INT32_MIN, INT32_MAX } from '../../../../utils.js'; + // Based on GraphSON NumberSerializer.serialize(). // It's tested by AnySerializer.serialize() tests. export default class NumberSerializationStrategy { @@ -29,6 +31,16 @@ export default class NumberSerializationStrategy { } canBeUsedFor(value) { + if ( + value instanceof Long || + value instanceof Int || + value instanceof Float || + value instanceof Double || + value instanceof Short || + value instanceof Byte + ) { + return true; + } if (Number.isNaN(value) || value === Number.POSITIVE_INFINITY || value === Number.NEGATIVE_INFINITY) { return true; } @@ -43,6 +55,25 @@ export default class NumberSerializationStrategy { } serialize(item, fullyQualifiedFormat = true) { + if (item instanceof Float) { + return this.ioc.floatSerializer.serialize(item.value, fullyQualifiedFormat); + } + if (item instanceof Double) { + return this.ioc.doubleSerializer.serialize(item.value, fullyQualifiedFormat); + } + if (item instanceof Int) { + return this.ioc.intSerializer.serialize(item.value, fullyQualifiedFormat); + } + if (item instanceof Long) { + return this.ioc.longSerializer.serialize(item.value, fullyQualifiedFormat); + } + if (item instanceof Short) { + return this.ioc.shortSerializer.serialize(item.value, fullyQualifiedFormat); + } + if (item instanceof Byte) { + return this.ioc.byteSerializer.serialize(item.value, fullyQualifiedFormat); + } + if (typeof item === 'number') { if ( Number.isNaN(item) || @@ -54,8 +85,7 @@ export default class NumberSerializationStrategy { return this.ioc.doubleSerializer.serialize(item, fullyQualifiedFormat); } - if (item >= -2147483648 && item <= 2147483647) { - // INT32_MIN/MAX + if (item >= INT32_MIN && item <= INT32_MAX) { return this.ioc.intSerializer.serialize(item, fullyQualifiedFormat); } if (item >= Number.MIN_SAFE_INTEGER && item <= Number.MAX_SAFE_INTEGER) { diff --git a/gremlin-js/gremlin-javascript/lib/utils.ts b/gremlin-js/gremlin-javascript/lib/utils.ts index 35ba5a45aec..be81755872d 100644 --- a/gremlin-js/gremlin-javascript/lib/utils.ts +++ b/gremlin-js/gremlin-javascript/lib/utils.ts @@ -24,16 +24,164 @@ const gremlinVersion = '4.0.0-SNAPSHOT'; // DO NOT MODIFY - Configured automatically by Maven Replacer Plugin -export function toLong(value: number | string) { +const INT64_MIN = -9223372036854775808n; +const INT64_MAX = 9223372036854775807n; + +export const INT32_MIN = -2147483648; +export const INT32_MAX = 2147483647; + +export function toLong(value: number | string | bigint) { return new Long(value); } export class Long { - constructor(public value: number | string) { - if (typeof value !== 'string' && typeof value !== 'number') { - throw new TypeError('The value must be a string or a number'); + readonly type = 'long'; + + constructor(public readonly value: number | string | bigint) { + if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'bigint') { + throw new TypeError('The value must be a string, a number, or a bigint'); + } + if (typeof value === 'string') { + if (!/^(?:0|-?[1-9]\d*)$/.test(value)) { + throw new TypeError('Long value must be a valid integer'); + } + const n = BigInt(value); + if (n < INT64_MIN || n > INT64_MAX) { + throw new RangeError('Long value is outside int64 range'); + } + } + if (typeof value === 'number') { + if (!Number.isInteger(value)) { + throw new TypeError('Long value must be an integer'); + } + if (value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER) { + throw new RangeError('Long number values outside safe integer range lose precision; use string or bigint'); + } + } + if (typeof value === 'bigint') { + if (value < INT64_MIN || value > INT64_MAX) { + throw new RangeError('Long value is outside int64 range'); + } + } + } + + valueOf(): number { + if (typeof this.value === 'number') return this.value; + const big = typeof this.value === 'bigint' ? this.value : BigInt(this.value); + if (big > BigInt(Number.MAX_SAFE_INTEGER) || big < BigInt(Number.MIN_SAFE_INTEGER)) { + throw new RangeError('Long value is outside safe integer range'); + } + return Number(big); + } + + [Symbol.toPrimitive](hint: string) { + if (hint === 'string') return String(this.value); + return this.valueOf(); + } + + toJSON() { + if (typeof this.value === 'bigint') return this.value.toString(); + return this.value; + } +} + +export class Int { + readonly type = 'int'; + constructor(public readonly value: number) { + if (typeof value !== 'number') { + throw new TypeError('Int value must be a number'); } + if (!Number.isFinite(value) || !Number.isInteger(value)) { + throw new TypeError('Int value must be a finite integer'); + } + if (value < INT32_MIN || value > INT32_MAX) { + throw new RangeError('Int value must be within int32 range'); + } + } + valueOf() { return this.value; } + [Symbol.toPrimitive](hint: string) { return hint === 'string' ? String(this.value) : this.value; } + toJSON() { return this.value; } +} + +export class Float { + readonly type = 'float'; + constructor(public readonly value: number) { + if (typeof value !== 'number') { + throw new TypeError('Float value must be a number'); + } + } + valueOf() { return this.value; } + [Symbol.toPrimitive](hint: string) { return hint === 'string' ? String(this.value) : this.value; } + toJSON() { return this.value; } +} + +export class Double { + readonly type = 'double'; + constructor(public readonly value: number) { + if (typeof value !== 'number') { + throw new TypeError('Double value must be a number'); + } + } + valueOf() { return this.value; } + [Symbol.toPrimitive](hint: string) { return hint === 'string' ? String(this.value) : this.value; } + toJSON() { return this.value; } +} + +export class Short { + readonly type = 'short'; + constructor(public readonly value: number) { + if (typeof value !== 'number') { + throw new TypeError('Short value must be a number'); + } + if (!Number.isFinite(value) || !Number.isInteger(value)) { + throw new TypeError('Short value must be a finite integer'); + } + if (value < -32768 || value > 32767) { + throw new RangeError('Short value must be within int16 range'); + } + } + valueOf() { return this.value; } + [Symbol.toPrimitive](hint: string) { return hint === 'string' ? String(this.value) : this.value; } + toJSON() { return this.value; } +} + +export class Byte { + readonly type = 'byte'; + constructor(public readonly value: number) { + if (typeof value !== 'number') { + throw new TypeError('Byte value must be a number'); + } + if (!Number.isFinite(value) || !Number.isInteger(value)) { + throw new TypeError('Byte value must be a finite integer'); + } + if (value < -128 || value > 127) { + throw new RangeError('Byte value must be within int8 range'); + } + } + valueOf() { return this.value; } + [Symbol.toPrimitive](hint: string) { return hint === 'string' ? String(this.value) : this.value; } + toJSON() { return this.value; } +} + +export function toInt(value: number) { return new Int(value); } +export function toFloat(value: number) { return new Float(value); } +export function toDouble(value: number) { return new Double(value); } +export function toShort(value: number) { return new Short(value); } +export function toByte(value: number) { return new Byte(value); } + +export function unwrap(value: Float): number; +export function unwrap(value: Double): number; +export function unwrap(value: Int): number; +export function unwrap(value: Long): number | string | bigint; +export function unwrap(value: Short): number; +export function unwrap(value: Byte): number; +export function unwrap(value: T): T; +export function unwrap(value: any): any { + if (value instanceof Long || value instanceof Int || value instanceof Float || + value instanceof Double || value instanceof Short || value instanceof Byte) { + return value.value; } + return value; } export function getUuid() { diff --git a/gremlin-js/gremlin-javascript/test/helper.js b/gremlin-js/gremlin-javascript/test/helper.js index 42926b323db..fa7f0d65b29 100644 --- a/gremlin-js/gremlin-javascript/test/helper.js +++ b/gremlin-js/gremlin-javascript/test/helper.js @@ -34,6 +34,8 @@ if (process.env.DOCKER_ENVIRONMENT === 'true') { serverAuthUrl = 'https://localhost:45941/gremlin'; } +export { serverUrl }; + /** @returns {DriverRemoteConnection} */ export function getConnection(traversalSource) { return new DriverRemoteConnection(serverUrl, { traversalSource }); diff --git a/gremlin-js/gremlin-javascript/test/integration/client-tests.js b/gremlin-js/gremlin-javascript/test/integration/client-tests.js index 09a4cd82302..aca3548d400 100644 --- a/gremlin-js/gremlin-javascript/test/integration/client-tests.js +++ b/gremlin-js/gremlin-javascript/test/integration/client-tests.js @@ -19,8 +19,10 @@ import assert from 'assert'; import { Vertex, Edge, VertexProperty } from '../../lib/structure/graph.js'; -import { getClient } from '../helper.js'; +import { getClient, serverUrl } from '../helper.js'; import { cardinality } from '../../lib/process/traversal.js'; +import Client from '../../lib/driver/client.js'; +import { Int, Double } from '../../lib/utils.js'; let client, clientCrew; @@ -157,6 +159,35 @@ describe('Client', function () { // assert.ok(!closingClient.isOpen()); // }); }); + + describe('#submit() with preciseNumbers', function () { + let preciseClient; + + before(async function () { + preciseClient = new Client(serverUrl, { traversalSource: 'gmodern', preciseNumbers: true }); + await preciseClient.open(); + }); + + after(async function () { + await preciseClient.close(); + }); + + it('should return Int wrapper for integer vertex property', async function () { + const result = await preciseClient.submit('g.V().has("name", "marko").values("age")'); + assert.ok(result); + assert.ok(result.first() instanceof Int); + assert.strictEqual(result.first().value, 29); + }); + + it('should return Double wrapper for edge weight property', async function () { + const result = await preciseClient.submit('g.E().has("weight", 0.5).limit(1)'); + assert.ok(result); + const edge = result.first(); + assert.ok(edge instanceof Edge); + assert.ok(edge.properties[0].value instanceof Double); + assert.strictEqual(edge.properties[0].value.value, 0.5); + }); + }); }); function assertVertexProperties(vertex) { diff --git a/gremlin-js/gremlin-javascript/test/unit/exports-test.js b/gremlin-js/gremlin-javascript/test/unit/exports-test.js index 3f5adb88bf0..f009c7074d6 100644 --- a/gremlin-js/gremlin-javascript/test/unit/exports-test.js +++ b/gremlin-js/gremlin-javascript/test/unit/exports-test.js @@ -61,6 +61,19 @@ describe('API', function () { assert.strictEqual(typeof glvModule.structure.Property, 'function'); assert.strictEqual(typeof glvModule.structure.Vertex, 'function'); assert.strictEqual(typeof glvModule.structure.VertexProperty, 'function'); + assert.strictEqual(typeof glvModule.structure.toLong, 'function'); + assert.strictEqual(typeof glvModule.structure.toInt, 'function'); + assert.strictEqual(typeof glvModule.structure.toFloat, 'function'); + assert.strictEqual(typeof glvModule.structure.toDouble, 'function'); + assert.strictEqual(typeof glvModule.structure.toShort, 'function'); + assert.strictEqual(typeof glvModule.structure.toByte, 'function'); + assert.strictEqual(typeof glvModule.structure.Int, 'function'); + assert.strictEqual(typeof glvModule.structure.Float, 'function'); + assert.strictEqual(typeof glvModule.structure.Double, 'function'); + assert.strictEqual(typeof glvModule.structure.Short, 'function'); + assert.strictEqual(typeof glvModule.structure.Byte, 'function'); + assert.strictEqual(typeof glvModule.structure.Long, 'function'); + assert.strictEqual(typeof glvModule.structure.unwrap, 'function'); }); it('should expose fields under driver', function () { assert.ok(glvModule.driver); diff --git a/gremlin-js/gremlin-javascript/test/unit/graphbinary/precise-mode-test.js b/gremlin-js/gremlin-javascript/test/unit/graphbinary/precise-mode-test.js new file mode 100644 index 00000000000..c2b9246713e --- /dev/null +++ b/gremlin-js/gremlin-javascript/test/unit/graphbinary/precise-mode-test.js @@ -0,0 +1,521 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'assert'; +import ioc, { createPreciseReader, DataType } from '../../../lib/structure/io/binary/GraphBinary.js'; +import StreamReader from '../../../lib/structure/io/binary/internals/StreamReader.js'; +import { Float, Double, Int, Long, Short, Byte, toFloat, toDouble, toInt, toLong, toShort, toByte, unwrap } from '../../../lib/utils.js'; +import Connection from '../../../lib/driver/connection.js'; +import { Path } from '../../../lib/structure/graph.js'; + +const { anySerializer, graphBinaryReader } = ioc; + +describe('Precise Mode Tests', () => { + let preciseReader; + + before(() => { + preciseReader = createPreciseReader(); + }); + + async function deserializeWithPrecise(buf) { + return preciseReader.ioc.anySerializer.deserialize(StreamReader.fromBuffer(buf)); + } + + async function deserializeWithDefault(buf) { + return anySerializer.deserialize(StreamReader.fromBuffer(buf)); + } + + describe('Basic deserialization', () => { + it('FLOAT bytes → Float instance', async () => { + const buf = anySerializer.serialize(toFloat(1.5)); + const result = await deserializeWithPrecise(buf); + assert.ok(result instanceof Float); + assert.strictEqual(result.value, 1.5); + assert.strictEqual(result.type, 'float'); + }); + + it('DOUBLE bytes → Double instance', async () => { + const buf = anySerializer.serialize(toDouble(3.14)); + const result = await deserializeWithPrecise(buf); + assert.ok(result instanceof Double); + assert.strictEqual(result.value, 3.14); + assert.strictEqual(result.type, 'double'); + }); + + it('INT bytes → Int instance', async () => { + const buf = anySerializer.serialize(toInt(42)); + const result = await deserializeWithPrecise(buf); + assert.ok(result instanceof Int); + assert.strictEqual(result.value, 42); + assert.strictEqual(result.type, 'int'); + }); + + it('LONG bytes (safe range) → Long instance with number value', async () => { + const buf = anySerializer.serialize(toLong(42)); + const result = await deserializeWithPrecise(buf); + assert.ok(result instanceof Long); + assert.strictEqual(result.value, 42); + assert.strictEqual(result.type, 'long'); + }); + + it('LONG bytes (unsafe range) → Long instance with bigint value', async () => { + const buf = anySerializer.serialize(toLong(9007199254740993n)); + const result = await deserializeWithPrecise(buf); + assert.ok(result instanceof Long); + assert.strictEqual(result.value, 9007199254740993n); + }); + + it('SHORT bytes → Short instance', async () => { + const buf = anySerializer.serialize(toShort(5)); + const result = await deserializeWithPrecise(buf); + assert.ok(result instanceof Short); + assert.strictEqual(result.value, 5); + assert.strictEqual(result.type, 'short'); + }); + + it('BYTE bytes → Byte instance', async () => { + const buf = anySerializer.serialize(toByte(127)); + const result = await deserializeWithPrecise(buf); + assert.ok(result instanceof Byte); + assert.strictEqual(result.value, 127); + assert.strictEqual(result.type, 'byte'); + }); + }); + + describe('Nested structures', () => { + it('MAP containing Float values → Float wrappers inside Map', async () => { + const map = new Map([['x', toFloat(1.5)]]); + const buf = anySerializer.serialize(map); + const result = await deserializeWithPrecise(buf); + assert.ok(result.get('x') instanceof Float); + assert.strictEqual(result.get('x').value, 1.5); + }); + + it('LIST with mixed numeric types → each wrapped correctly', async () => { + const list = [toFloat(1.5), toInt(2), toDouble(3.14)]; + const buf = anySerializer.serialize(list); + const result = await deserializeWithPrecise(buf); + assert.ok(result[0] instanceof Float); + assert.ok(result[1] instanceof Int); + assert.ok(result[2] instanceof Double); + }); + + it('PATH with numeric vertex IDs → wrapped correctly', async () => { + const path = new Path([['a'], ['b']], [toInt(1), toInt(2)]); + const buf = anySerializer.serialize(path); + const result = await deserializeWithPrecise(buf); + assert.ok(result instanceof Path); + assert.ok(result.objects[0] instanceof Int); + assert.strictEqual(result.objects[0].value, 1); + assert.ok(result.objects[1] instanceof Int); + assert.strictEqual(result.objects[1].value, 2); + }); + + it('LIST with Short, Byte, Long → each wrapped correctly', async () => { + const list = [toShort(5), toByte(127), toLong(99)]; + const buf = anySerializer.serialize(list); + const result = await deserializeWithPrecise(buf); + assert.ok(result[0] instanceof Short); + assert.strictEqual(result[0].value, 5); + assert.ok(result[1] instanceof Byte); + assert.strictEqual(result[1].value, 127); + assert.ok(result[2] instanceof Long); + assert.strictEqual(result[2].value, 99); + }); + + it('Non-numeric types still deserialize correctly', async () => { + const list = ['hello', true, 'world']; + const buf = anySerializer.serialize(list); + const result = await deserializeWithPrecise(buf); + assert.deepStrictEqual(result, ['hello', true, 'world']); + }); + }); + + describe('Precise reader error paths', () => { + it('returns null for a null-flagged numeric value', async () => { + const buf = Buffer.from([DataType.INT, 0x01]); + const result = await deserializeWithPrecise(buf); + assert.strictEqual(result, null); + }); + + it('returns null for a null-flagged LONG value', async () => { + const buf = Buffer.from([DataType.LONG, 0x01]); + assert.strictEqual(await deserializeWithPrecise(buf), null); + }); + + it('returns null for a null-flagged FLOAT value', async () => { + const buf = Buffer.from([DataType.FLOAT, 0x01]); + assert.strictEqual(await deserializeWithPrecise(buf), null); + }); + + it('returns null for a null-flagged DOUBLE value', async () => { + const buf = Buffer.from([DataType.DOUBLE, 0x01]); + assert.strictEqual(await deserializeWithPrecise(buf), null); + }); + + it('returns null for a null-flagged SHORT value', async () => { + const buf = Buffer.from([DataType.SHORT, 0x01]); + assert.strictEqual(await deserializeWithPrecise(buf), null); + }); + + it('returns null for a null-flagged BYTE value', async () => { + const buf = Buffer.from([DataType.BYTE, 0x01]); + assert.strictEqual(await deserializeWithPrecise(buf), null); + }); + + it('throws on invalid value_flag', async () => { + const buf = Buffer.from([DataType.INT, 0xFF, 0, 0, 0, 0]); + await assert.rejects(() => deserializeWithPrecise(buf), /AnySerializer: unexpected \{value_flag}=0x/); + }); + + it('throws on unknown type_code', async () => { + const buf = Buffer.from([0xEE, 0x00]); + await assert.rejects(() => deserializeWithPrecise(buf), /AnySerializer: unknown \{type_code}=0xee/); + }); + + it('deserializes value_flag 0x02 as non-null', async () => { + const buf = Buffer.from([DataType.INT, 0x02, 0x00, 0x00, 0x00, 0x2A]); + const result = await deserializeWithPrecise(buf); + assert.ok(result instanceof Int); + assert.strictEqual(result.value, 42); + }); + + it('wraps deserializeValue errors with position info', async () => { + const buf = Buffer.from([DataType.INT, 0x00]); + await assert.rejects(() => deserializeWithPrecise(buf), /IntSerializer\.deserializeValue\(\) at position \d+/); + }); + }); + + describe('Wrapper behavior', () => { + it('Float valueOf works in arithmetic', () => { + assert.strictEqual(new Float(1.5) + 1, 2.5); + }); + + it('Int valueOf works in arithmetic', () => { + assert.strictEqual(new Int(42) + 0, 42); + }); + + it('Double valueOf works in arithmetic', () => { + assert.strictEqual(new Double(3.14) + 0, 3.14); + }); + + it('Short valueOf works in arithmetic', () => { + assert.strictEqual(new Short(5) + 1, 6); + }); + + it('Byte valueOf works in arithmetic', () => { + assert.strictEqual(new Byte(127) + 0, 127); + }); + + it('Long with unsafe bigint throws RangeError on arithmetic', () => { + assert.throws(() => new Long(9007199254740993n) + 1, RangeError); + }); + + it('Long with unsafe string throws RangeError on arithmetic', () => { + assert.throws(() => new Long('9007199254740993') + 1, RangeError); + }); + + it('Long with safe bigint works in arithmetic', () => { + assert.strictEqual(new Long(42n) + 1, 43); + }); + + it('Long toPrimitive string hint works for unsafe values', () => { + assert.strictEqual(`${new Long(9007199254740993n)}`, '9007199254740993'); + }); + + it('Float toPrimitive string hint', () => { + assert.strictEqual(`${new Float(1.5)}`, '1.5'); + }); + + it('Double toPrimitive string hint', () => { + assert.strictEqual(`${new Double(3.14)}`, '3.14'); + }); + + it('Int toPrimitive string hint', () => { + assert.strictEqual(`${new Int(67)}`, '67'); + }); + + it('Short toPrimitive string hint', () => { + assert.strictEqual(`${new Short(5)}`, '5'); + }); + + it('Byte toPrimitive string hint', () => { + assert.strictEqual(`${new Byte(127)}`, '127'); + }); + + it('JSON.stringify Float', () => { + assert.strictEqual(JSON.stringify(new Float(1.5)), '1.5'); + }); + + it('JSON.stringify Long with bigint', () => { + assert.strictEqual(JSON.stringify(new Long(9007199254740993n)), '"9007199254740993"'); + }); + + it('Long valueOf with safe string returns number', () => { + assert.strictEqual(new Long('42').valueOf(), 42); + }); + + it('Long valueOf with unsafe string throws RangeError', () => { + assert.throws(() => new Long('9007199254740993').valueOf(), RangeError); + }); + + it('JSON.stringify Long with string value preserves string', () => { + assert.strictEqual(JSON.stringify(new Long('9007199254740993')), '"9007199254740993"'); + }); + + it('JSON.stringify Long with number value is a number', () => { + assert.strictEqual(JSON.stringify(new Long(42)), '42'); + }); + + it('JSON.stringify Double', () => { + assert.strictEqual(JSON.stringify(new Double(3.14)), '3.14'); + }); + + it('JSON.stringify Int', () => { + assert.strictEqual(JSON.stringify(new Int(67)), '67'); + }); + + it('JSON.stringify Short', () => { + assert.strictEqual(JSON.stringify(new Short(5)), '5'); + }); + + it('JSON.stringify Byte', () => { + assert.strictEqual(JSON.stringify(new Byte(127)), '127'); + }); + + it('unwrap Float', () => { + assert.strictEqual(unwrap(new Float(1.5)), 1.5); + }); + + it('unwrap Long with bigint', () => { + assert.strictEqual(unwrap(new Long(42n)), 42n); + }); + + it('unwrap Long with string', () => { + assert.strictEqual(unwrap(new Long('123')), '123'); + }); + + it('unwrap Long with number', () => { + assert.strictEqual(unwrap(new Long(42)), 42); + }); + + it('unwrap Int', () => { + assert.strictEqual(unwrap(new Int(42)), 42); + }); + + it('unwrap Double', () => { + assert.strictEqual(unwrap(new Double(3.14)), 3.14); + }); + + it('unwrap Short', () => { + assert.strictEqual(unwrap(new Short(5)), 5); + }); + + it('unwrap Byte', () => { + assert.strictEqual(unwrap(new Byte(127)), 127); + }); + + it('unwrap plain number passthrough', () => { + assert.strictEqual(unwrap(42), 42); + }); + + it('unwrap null passthrough', () => { + assert.strictEqual(unwrap(null), null); + }); + + it('unwrap undefined passthrough', () => { + assert.strictEqual(unwrap(undefined), undefined); + }); + }); + + describe('Long constructor validation', () => { + it('rejects non-numeric string', () => { + assert.throws(() => new Long('abc'), TypeError); + }); + + it('rejects injection attempt', () => { + assert.throws(() => new Long('1L).drop()'), TypeError); + }); + + it('rejects empty string', () => { + assert.throws(() => new Long(''), TypeError); + }); + + it('rejects non-integer number', () => { + assert.throws(() => new Long(1.5), TypeError); + }); + + it('accepts exact int64 max (bigint)', () => { + const l = new Long(9223372036854775807n); + assert.strictEqual(l.value, 9223372036854775807n); + }); + + it('accepts exact int64 min (bigint)', () => { + const l = new Long(-9223372036854775808n); + assert.strictEqual(l.value, -9223372036854775808n); + }); + + it('rejects one above int64 max (bigint)', () => { + assert.throws(() => new Long(9223372036854775808n), RangeError); + }); + + it('rejects one below int64 min (bigint)', () => { + assert.throws(() => new Long(-9223372036854775809n), RangeError); + }); + + it('accepts exact int64 max (string)', () => { + const l = new Long('9223372036854775807'); + assert.strictEqual(l.value, '9223372036854775807'); + }); + + it('rejects one above int64 max (string)', () => { + assert.throws(() => new Long('9223372036854775808'), RangeError); + }); + + it('rejects negative zero string', () => { + assert.throws(() => new Long('-0'), TypeError); + }); + + it('accepts negative zero number', () => { + const l = new Long(-0); + assert.strictEqual(l.valueOf(), -0); + }); + + it('rejects leading zeros in string', () => { + assert.throws(() => new Long('0042'), TypeError); + }); + }); + + describe('Float constructor validation', () => { + it('rejects non-number argument', () => { + assert.throws(() => new Float('1.5'), TypeError); + }); + }); + + describe('Double constructor validation', () => { + it('rejects non-number argument', () => { + assert.throws(() => new Double('3.14'), TypeError); + }); + }); + + describe('Int constructor validation', () => { + it('accepts exact int32 max', () => { + assert.strictEqual(new Int(2147483647).value, 2147483647); + }); + + it('accepts exact int32 min', () => { + assert.strictEqual(new Int(-2147483648).value, -2147483648); + }); + + it('rejects above int32 max', () => { + assert.throws(() => new Int(2147483648), RangeError); + }); + + it('rejects below int32 min', () => { + assert.throws(() => new Int(-2147483649), RangeError); + }); + + it('rejects non-integer', () => { + assert.throws(() => new Int(1.5), TypeError); + }); + + it('rejects non-number argument', () => { + assert.throws(() => new Int('5'), TypeError); + }); + }); + + describe('Short constructor validation', () => { + it('accepts exact int16 max', () => { + assert.strictEqual(new Short(32767).value, 32767); + }); + + it('accepts exact int16 min', () => { + assert.strictEqual(new Short(-32768).value, -32768); + }); + + it('rejects above int16 max', () => { + assert.throws(() => new Short(32768), RangeError); + }); + + it('rejects below int16 min', () => { + assert.throws(() => new Short(-32769), RangeError); + }); + + it('rejects non-integer', () => { + assert.throws(() => new Short(1.5), TypeError); + }); + + it('rejects non-number argument', () => { + assert.throws(() => new Short('5'), TypeError); + }); + }); + + describe('Byte constructor validation', () => { + it('accepts exact int8 max', () => { + assert.strictEqual(new Byte(127).value, 127); + }); + + it('accepts exact int8 min', () => { + assert.strictEqual(new Byte(-128).value, -128); + }); + + it('rejects above int8 max', () => { + assert.throws(() => new Byte(128), RangeError); + }); + + it('rejects below int8 min', () => { + assert.throws(() => new Byte(-129), RangeError); + }); + + it('rejects non-integer', () => { + assert.throws(() => new Byte(1.5), TypeError); + }); + + it('rejects non-number argument', () => { + assert.throws(() => new Byte('127'), TypeError); + }); + }); + + describe('Backward compatibility', () => { + it('default graphBinaryReader still returns plain numbers after createPreciseReader()', async () => { + const buf = anySerializer.serialize(toFloat(1.5)); + const result = await deserializeWithDefault(buf); + assert.strictEqual(result, 1.5); + assert.ok(!(result instanceof Float)); + }); + }); + + describe('Connection option wiring', () => { + it('preciseNumbers: true uses a precise reader', () => { + const conn = new Connection('http://localhost:8182', { preciseNumbers: true }); + assert.ok(conn._reader !== graphBinaryReader); + }); + + it('explicit reader takes precedence over preciseNumbers', () => { + const customReader = { custom: true }; + const conn = new Connection('http://localhost:8182', { reader: customReader, preciseNumbers: true }); + assert.strictEqual(conn._reader, customReader); + }); + + it('default uses the default reader', () => { + const conn = new Connection('http://localhost:8182', {}); + assert.strictEqual(conn._reader, graphBinaryReader); + }); + }); +}); diff --git a/gremlin-js/gremlin-javascript/test/unit/graphbinary/typed-number-test.js b/gremlin-js/gremlin-javascript/test/unit/graphbinary/typed-number-test.js new file mode 100644 index 00000000000..9e611e419f9 --- /dev/null +++ b/gremlin-js/gremlin-javascript/test/unit/graphbinary/typed-number-test.js @@ -0,0 +1,212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'assert'; +import { toFloat, toDouble, toInt, toLong, toShort, toByte } from '../../../lib/utils.js'; +import ioc, { DataType } from '../../../lib/structure/io/binary/GraphBinary.js'; +import StreamReader from '../../../lib/structure/io/binary/internals/StreamReader.js'; +import { P } from '../../../lib/process/traversal.js'; + +const { anySerializer, numberSerializationStrategy } = ioc; + +describe('Typed Number Tests', () => { + describe('Type-code routing via anySerializer', () => { + it('toFloat → FLOAT', () => { + assert.strictEqual(anySerializer.serialize(toFloat(1.0))[0], DataType.FLOAT); + }); + + it('toDouble → DOUBLE', () => { + assert.strictEqual(anySerializer.serialize(toDouble(1.0))[0], DataType.DOUBLE); + }); + + it('toInt → INT', () => { + assert.strictEqual(anySerializer.serialize(toInt(67))[0], DataType.INT); + }); + + it('toLong → LONG', () => { + assert.strictEqual(anySerializer.serialize(toLong(67))[0], DataType.LONG); + }); + + it('toShort → SHORT', () => { + assert.strictEqual(anySerializer.serialize(toShort(5))[0], DataType.SHORT); + }); + + it('toByte → BYTE', () => { + assert.strictEqual(anySerializer.serialize(toByte(127))[0], DataType.BYTE); + }); + }); + + describe('canBeUsedFor routing', () => { + it('accepts typed wrappers', () => { + assert.ok(numberSerializationStrategy.canBeUsedFor(toFloat(1.0))); + assert.ok(numberSerializationStrategy.canBeUsedFor(toInt(67))); + assert.ok(numberSerializationStrategy.canBeUsedFor(toLong(67n))); + assert.ok(numberSerializationStrategy.canBeUsedFor(toDouble(1.0))); + assert.ok(numberSerializationStrategy.canBeUsedFor(toShort(5))); + assert.ok(numberSerializationStrategy.canBeUsedFor(toByte(127))); + }); + + it('rejects plain objects', () => { + assert.ok(!numberSerializationStrategy.canBeUsedFor({ value: 1 })); + }); + }); + + describe('Byte-level verification', () => { + it('toFloat(1.0) produces different bytes than toInt(1)', () => { + const floatBytes = anySerializer.serialize(toFloat(1.0)); + const intBytes = anySerializer.serialize(toInt(1)); + assert.ok(!Buffer.from(floatBytes).equals(Buffer.from(intBytes))); + }); + + it('toFloat(1.0) produces different bytes than toDouble(1.0) (4 vs 8 value bytes)', () => { + const floatBytes = anySerializer.serialize(toFloat(1.0)); + const doubleBytes = anySerializer.serialize(toDouble(1.0)); + assert.ok(!Buffer.from(floatBytes).equals(Buffer.from(doubleBytes))); + }); + + it('toShort(1) produces different bytes than toInt(1)', () => { + const shortBytes = anySerializer.serialize(toShort(1)); + const intBytes = anySerializer.serialize(toInt(1)); + assert.ok(!Buffer.from(shortBytes).equals(Buffer.from(intBytes))); + }); + + it('toByte(1) produces different bytes than toShort(1)', () => { + const byteBytes = anySerializer.serialize(toByte(1)); + const shortBytes = anySerializer.serialize(toShort(1)); + assert.ok(!Buffer.from(byteBytes).equals(Buffer.from(shortBytes))); + }); + + it('toLong(1) produces different bytes than toInt(1)', () => { + const longBytes = anySerializer.serialize(toLong(1)); + const intBytes = anySerializer.serialize(toInt(1)); + assert.ok(!Buffer.from(longBytes).equals(Buffer.from(intBytes))); + }); + }); + + describe('Backward compatibility', () => { + it('plain 67 still routes to INT', () => { + assert.strictEqual(anySerializer.serialize(67)[0], DataType.INT); + }); + + it('plain 3.14 still routes to DOUBLE', () => { + assert.strictEqual(anySerializer.serialize(3.14)[0], DataType.DOUBLE); + }); + + it('plain 2147483648 still routes to LONG', () => { + assert.strictEqual(anySerializer.serialize(2147483648)[0], DataType.LONG); + }); + }); + + describe('Edge cases', () => { + it('toLong(67n) serializes correctly (bigint input)', () => { + const bytes = anySerializer.serialize(toLong(67n)); + assert.strictEqual(bytes[0], DataType.LONG); + }); + + it('toLong string input serializes without precision loss', () => { + const bytes = anySerializer.serialize(toLong('9007199254740993')); + assert.strictEqual(bytes[0], DataType.LONG); + }); + + it('toShort(-1) — negative value', () => { + const bytes = anySerializer.serialize(toShort(-1)); + assert.strictEqual(bytes[0], DataType.SHORT); + }); + + it('toByte(-128) — negative value', () => { + const bytes = anySerializer.serialize(toByte(-128)); + assert.strictEqual(bytes[0], DataType.BYTE); + }); + + it('toFloat(NaN)', () => { + const bytes = anySerializer.serialize(toFloat(NaN)); + assert.strictEqual(bytes[0], DataType.FLOAT); + }); + + it('toFloat(Infinity)', () => { + const bytes = anySerializer.serialize(toFloat(Infinity)); + assert.strictEqual(bytes[0], DataType.FLOAT); + }); + + it('toFloat(-0)', () => { + const bytes = anySerializer.serialize(toFloat(-0)); + assert.strictEqual(bytes[0], DataType.FLOAT); + }); + + it('toDouble(NaN)', () => { + const bytes = anySerializer.serialize(toDouble(NaN)); + assert.strictEqual(bytes[0], DataType.DOUBLE); + }); + + it('toDouble(Infinity)', () => { + const bytes = anySerializer.serialize(toDouble(Infinity)); + assert.strictEqual(bytes[0], DataType.DOUBLE); + }); + + it('toDouble(-Infinity)', () => { + const bytes = anySerializer.serialize(toDouble(-Infinity)); + assert.strictEqual(bytes[0], DataType.DOUBLE); + }); + + it('toDouble(-0)', () => { + const bytes = anySerializer.serialize(toDouble(-0)); + assert.strictEqual(bytes[0], DataType.DOUBLE); + }); + }); + + describe('Round-trip through default reader', () => { + it('toFloat(1.5) round-trips to plain number', async () => { + const bytes = anySerializer.serialize(toFloat(1.5)); + const result = await anySerializer.deserialize(StreamReader.fromBuffer(bytes)); + assert.strictEqual(result, 1.5); + }); + + it('toInt(67) round-trips to plain number', async () => { + const bytes = anySerializer.serialize(toInt(67)); + const result = await anySerializer.deserialize(StreamReader.fromBuffer(bytes)); + assert.strictEqual(result, 67); + }); + + it('toLong string value round-trips without precision loss', async () => { + const bytes = anySerializer.serialize(toLong('9007199254740993')); + const result = await anySerializer.deserialize(StreamReader.fromBuffer(bytes)); + assert.strictEqual(result, 9007199254740993n); + }); + }); + + describe('Integration with traversal structures', () => { + it('wrappers work inside P predicates', () => { + const predicate = P.gt(toFloat(1.0)); + const bytes = anySerializer.serialize(predicate); + // P serializes successfully — the inner Float gets correct type code + assert.ok(bytes.length > 0); + }); + + it('wrappers inside collections get correct type codes', async () => { + const bytes = anySerializer.serialize([toFloat(1.0), toInt(2)]); + const reader = StreamReader.fromBuffer(bytes); + // Skip list type code (1 byte) and length (4 bytes) + const result = await anySerializer.deserialize(reader); + // The list deserializes — each element was serialized with its own type code + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0], 1.0); + assert.strictEqual(result[1], 2); + }); + }); +}); diff --git a/gremlin-js/gremlin-javascript/test/unit/gremlin-lang-test.js b/gremlin-js/gremlin-javascript/test/unit/gremlin-lang-test.js index 40472c99f5e..a8c4e9add02 100644 --- a/gremlin-js/gremlin-javascript/test/unit/gremlin-lang-test.js +++ b/gremlin-js/gremlin-javascript/test/unit/gremlin-lang-test.js @@ -27,7 +27,7 @@ import { ReadOnlyStrategy, SubgraphStrategy, OptionsStrategy, PartitionStrategy, SeedStrategy } from '../../lib/process/traversal-strategy.js'; import { Graph, Vertex } from '../../lib/structure/graph.js'; import { TraversalStrategies } from '../../lib/process/traversal-strategy.js'; -import { Long } from '../../lib/utils.js'; +import { Long, toFloat, toDouble, toShort, toByte, toInt, toLong } from '../../lib/utils.js'; import GremlinLang from '../../lib/process/gremlin-lang.js'; const g = new GraphTraversalSource(new Graph(), new TraversalStrategies()); @@ -434,6 +434,138 @@ describe('GremlinLang', function () { const view = new Uint8Array(ab, 1, 3); // bytes [1, 2, 3] assert.strictEqual(g.inject(view).getGremlinLang().getGremlin(), 'g.inject(Binary("AQID"))'); }); + + it('should handle toFloat with round number', function () { + assert.strictEqual(g.V(toFloat(1.0)).getGremlinLang().getGremlin(), 'g.V(1.0F)'); + }); + + it('should handle toFloat with fractional number', function () { + assert.strictEqual(g.V(toFloat(1.5)).getGremlinLang().getGremlin(), 'g.V(1.5F)'); + }); + + it('should handle toDouble with round number', function () { + assert.strictEqual(g.V(toDouble(1.0)).getGremlinLang().getGremlin(), 'g.V(1.0D)'); + }); + + it('should handle toDouble with fractional number', function () { + assert.strictEqual(g.V(toDouble(1.5)).getGremlinLang().getGremlin(), 'g.V(1.5D)'); + }); + + it('should handle toShort', function () { + assert.strictEqual(g.V(toShort(5)).getGremlinLang().getGremlin(), 'g.V(5S)'); + }); + + it('should handle toShort with negative value', function () { + assert.strictEqual(g.V(toShort(-32768)).getGremlinLang().getGremlin(), 'g.V(-32768S)'); + }); + + it('should handle toByte', function () { + assert.strictEqual(g.V(toByte(127)).getGremlinLang().getGremlin(), 'g.V(127B)'); + }); + + it('should handle toByte with negative value', function () { + assert.strictEqual(g.V(toByte(-128)).getGremlinLang().getGremlin(), 'g.V(-128B)'); + }); + + it('should handle toInt', function () { + assert.strictEqual(g.V(toInt(42)).getGremlinLang().getGremlin(), 'g.V(42)'); + }); + + it('should handle toLong with bigint input', function () { + assert.strictEqual(g.V(toLong(42n)).getGremlinLang().getGremlin(), 'g.V(42L)'); + }); + + it('should handle toLong with number input', function () { + assert.strictEqual(g.V(toLong(42)).getGremlinLang().getGremlin(), 'g.V(42L)'); + }); + + it('should handle toLong with string input', function () { + assert.strictEqual(g.V(toLong('9007199254740993')).getGremlinLang().getGremlin(), 'g.V(9007199254740993L)'); + }); + + it('should handle toFloat with zero', function () { + assert.strictEqual(g.V(toFloat(0)).getGremlinLang().getGremlin(), 'g.V(0.0F)'); + }); + + it('should handle toDouble with zero', function () { + assert.strictEqual(g.V(toDouble(0)).getGremlinLang().getGremlin(), 'g.V(0.0D)'); + }); + + it('should handle toFloat with NaN', function () { + assert.strictEqual(g.V(toFloat(NaN)).getGremlinLang().getGremlin(), 'g.V(NaN)'); + }); + + it('should handle toFloat with Infinity', function () { + assert.strictEqual(g.V(toFloat(Infinity)).getGremlinLang().getGremlin(), 'g.V(+Infinity)'); + }); + + it('should handle toFloat with -Infinity', function () { + assert.strictEqual(g.V(toFloat(-Infinity)).getGremlinLang().getGremlin(), 'g.V(-Infinity)'); + }); + + it('should handle toDouble with NaN', function () { + assert.strictEqual(g.V(toDouble(NaN)).getGremlinLang().getGremlin(), 'g.V(NaN)'); + }); + + it('should handle toDouble with Infinity', function () { + assert.strictEqual(g.V(toDouble(Infinity)).getGremlinLang().getGremlin(), 'g.V(+Infinity)'); + }); + + it('should handle toDouble with -Infinity', function () { + assert.strictEqual(g.V(toDouble(-Infinity)).getGremlinLang().getGremlin(), 'g.V(-Infinity)'); + }); + + it('should handle toLong with negative number input', function () { + assert.strictEqual(g.V(toLong(-1)).getGremlinLang().getGremlin(), 'g.V(-1L)'); + }); + + it('should handle toLong with int64 min bigint', function () { + assert.strictEqual(g.V(toLong(-9223372036854775808n)).getGremlinLang().getGremlin(), 'g.V(-9223372036854775808L)'); + }); + + it('should handle toInt with negative value', function () { + assert.strictEqual(g.V(toInt(-1)).getGremlinLang().getGremlin(), 'g.V(-1)'); + }); + + it('should handle toInt at int32 max', function () { + assert.strictEqual(g.V(toInt(2147483647)).getGremlinLang().getGremlin(), 'g.V(2147483647)'); + }); + + it('should handle toInt at int32 min', function () { + assert.strictEqual(g.V(toInt(-2147483648)).getGremlinLang().getGremlin(), 'g.V(-2147483648)'); + }); + + it('should handle toShort at boundary values', function () { + assert.strictEqual(g.V(toShort(32767)).getGremlinLang().getGremlin(), 'g.V(32767S)'); + }); + + it('should handle toByte at zero', function () { + assert.strictEqual(g.V(toByte(0)).getGremlinLang().getGremlin(), 'g.V(0B)'); + }); + + it('should handle toFloat inside P.gt', function () { + assert.strictEqual(g.V().has('x', P.gt(toFloat(1.5))).getGremlinLang().getGremlin(), "g.V().has('x',gt(1.5F))"); + }); + + it('should handle toShort inside P.between', function () { + assert.strictEqual(g.V().has('x', P.between(toShort(1), toShort(10))).getGremlinLang().getGremlin(), "g.V().has('x',between(1S,10S))"); + }); + + it('should handle toInt inside P.within', function () { + assert.strictEqual(g.V().has('x', P.within([toInt(1), toInt(2)])).getGremlinLang().getGremlin(), "g.V().has('x',within([1,2]))"); + }); + + it('should handle toDouble inside P.gt', function () { + assert.strictEqual(g.V().has('x', P.gt(toDouble(3.14))).getGremlinLang().getGremlin(), "g.V().has('x',gt(3.14D))"); + }); + + it('should handle typed wrappers in inject array', function () { + assert.strictEqual(g.inject([toFloat(1.5), toFloat(2.5)]).getGremlinLang().getGremlin(), 'g.inject([1.5F,2.5F])'); + }); + + it('should handle typed wrapper in inject Map', function () { + assert.strictEqual(g.inject(new Map([['x', toDouble(3.14)]])).getGremlinLang().getGremlin(), "g.inject(['x':3.14D])"); + }); }); describe('Unsupported type tests', function () {