Pacific Blue Software Logo

How to Send Int64 Values from Delphi to JavaScript Without Losing Precision

How to Send Int64 Values from Delphi to JavaScript Without Losing Precision

A Delphi Int64 holds any whole number up to 9,223,372,036,854,775,807. A JavaScript number is a 64-bit floating point value with a 53-bit mantissa, so it holds whole numbers exactly only up to 253 - 1, which is 9,007,199,254,740,991 (Number.MAX_SAFE_INTEGER). Anything larger is rounded to the nearest value it can hold.

When a Delphi server sends an Int64 in JSON, Delphi writes every digit correctly. The damage happens in the browser, when the JSON is parsed. There is no error and no warning - the value is simply different.

What It Looks Like

This is the JSON a Delphi server produced for 253 + 1, using System.JSON:

{"Rights":9007199254740993,"Rights_Hex":"0020000000000001"}

Parsed in the browser by a TMS WEB Core application, Rights came out as 9007199254740992. Bit 0 was lost.

It matters most for values where every bit counts:

TMS WEB Core is affected in the same way as plain JavaScript. pas2js compiles Int64 and NativeInt to JavaScript numbers, and the XData web client reads replies with JSON.parse.

The Fix: Send the Value as Text

A string is never rounded, so send the value as a string. Decimal text would work, but hex is easier to use at the other end:

Server Side

The server needs two functions: one to write the value and one to read it back when the browser returns it.

unit Hex64;

interface

// Converts an Int64 to exactly 16 hex digits, e.g. 255 -> '00000000000000FF'.
// Negative values come out as their two's complement bit pattern.
function Int64ToHex(const AValue: Int64): string;

// Converts 16 hex digits back to an Int64. Returns False, and AValue = 0,
// unless AHex is exactly 16 hex digits (upper or lower case).
function HexToInt64(const AHex: string; out AValue: Int64): Boolean;

implementation

uses
  System.SysUtils;

const
  HexLength = 16;
  HexDigits = '0123456789ABCDEFabcdef';

function IsHex16(const AHex: string): Boolean;
var
  I: Integer;
begin
  Result := Length(AHex) = HexLength;
  if not Result then
    Exit;
  for I := 1 to HexLength do
    if Pos(AHex[I], HexDigits) = 0 then
      Exit(False);
end;

function Int64ToHex(const AValue: Int64): string;
begin
  Result := IntToHex(AValue, HexLength);
end;

function HexToInt64(const AHex: string; out AValue: Int64): Boolean;
begin
  AValue := 0;
  // TryStrToInt64 accepts things like '$1F' or ' 1F', so check the text first
  Result := IsHex16(AHex) and TryStrToInt64('$' + AHex, AValue);
  if not Result then
    AValue := 0;
end;

end.

IsHex16 is there because TryStrToInt64 is generous. It accepts a leading space, a $ prefix and short values, all of which you want to refuse when the text came from a browser.

Writing the JSON is then one extra pair:

LObj := TJSONObject.Create;
try
  LObj.AddPair('Rights', TJSONNumber.Create(LRights));
  LObj.AddPair('Rights_Hex', Int64ToHex(LRights));
  Response := LObj.ToJSON;
finally
  LObj.Free;
end;

With TMS XData, add a string property to the class you return and fill it with Int64ToHex. If the value comes straight from a MySQL table through Aurelius, let the database do it with a generated column:

ALTER TABLE user_roles
  ADD COLUMN Rights_Hex varchar(16)
      GENERATED ALWAYS AS (LPAD(HEX(Rights), 16, '0')) VIRTUAL;

MySQL's HEX treats a BIGINT as 64 unsigned bits, so -1 becomes FFFFFFFFFFFFFFFF, the same as IntToHex. Map the column as read-only in the entity:

[Column('Rights_Hex', [TColumnProp.NoInsert, TColumnProp.NoUpdate], 16)]
FRights_Hex: string;

Browser Side (TMS WEB Core)

In the browser the 64 bits are held as two numbers of 32 bits each. Each half is at most 4,294,967,295, far below the 253 limit, so nothing is ever rounded.

unit Bits64;

interface

type
  // 64 bits held as two 32-bit halves, each a plain number 0..4294967295.
  // Bits are tested with arithmetic, never with and/or/shl.
  TBits64 = record
  private
    FLo: NativeInt;   // bits 0..31
    FHi: NativeInt;   // bits 32..63
  public
    procedure Clear;
    function IsSet(ABit: Integer): Boolean;
    procedure Include(ABit: Integer);
    procedure Exclude(ABit: Integer);
    function ToHex: string;
    // False, with every bit cleared, unless AHex is exactly 16 hex digits
    function FromHex(const AHex: string): Boolean;
  end;

implementation

uses
  SysUtils;

const
  HexLength     = 16;
  HalfHexLength = 8;
  BitsPerHalf   = 32;
  HexDigits     = '0123456789ABCDEFabcdef';

function PowerOf2(ABit: Integer): NativeInt;
var
  I: Integer;
begin
  Result := 1;
  for I := 1 to ABit do
    Result := Result * 2;
end;

function BitIsSet(const AHalf: NativeInt; ABit: Integer): Boolean;
begin
  Result := (AHalf div PowerOf2(ABit)) mod 2 = 1;
end;

function IsHex16(const AHex: string): Boolean;
var
  I: Integer;
begin
  Result := Length(AHex) = HexLength;
  if not Result then
    Exit;
  for I := 1 to HexLength do
    if Pos(AHex[I], HexDigits) = 0 then
      Exit(False);
end;

procedure TBits64.Clear;
begin
  FLo := 0;
  FHi := 0;
end;

function TBits64.IsSet(ABit: Integer): Boolean;
begin
  if ABit < BitsPerHalf then
    Result := BitIsSet(FLo, ABit)
  else
    Result := BitIsSet(FHi, ABit - BitsPerHalf);
end;

procedure TBits64.Include(ABit: Integer);
begin
  if IsSet(ABit) then
    Exit;
  if ABit < BitsPerHalf then
    FLo := FLo + PowerOf2(ABit)
  else
    FHi := FHi + PowerOf2(ABit - BitsPerHalf);
end;

procedure TBits64.Exclude(ABit: Integer);
begin
  if not IsSet(ABit) then
    Exit;
  if ABit < BitsPerHalf then
    FLo := FLo - PowerOf2(ABit)
  else
    FHi := FHi - PowerOf2(ABit - BitsPerHalf);
end;

function TBits64.ToHex: string;
begin
  Result := IntToHex(FHi, HalfHexLength) + IntToHex(FLo, HalfHexLength);
end;

function TBits64.FromHex(const AHex: string): Boolean;
var
  LHi, LLo: NativeInt;
begin
  Result := IsHex16(AHex) and
            TryStrToInt('$' + Copy(AHex, 1, HalfHexLength), LHi) and
            TryStrToInt('$' + Copy(AHex, HalfHexLength + 1, HalfHexLength), LLo);
  if Result then
  begin
    FHi := LHi;
    FLo := LLo;
  end
  else
    Clear;
end;

end.

Using it after an XData call or any other JSON reply:

var
  LRights: TBits64;
begin
  if not LRights.FromHex(string(LReply['Rights_Hex'])) then
    raise Exception.Create('Invalid rights from server');

  if LRights.IsSet(Ord(rView_Bookings)) then
    ShowBookingsMenu;

  // Change a bit and send the hex back; the server reads it with HexToInt64
  LRights.Include(Ord(rEdit_Bookings));
  SaveRights(LRights.ToHex);
end;

Why Arithmetic Instead of and / shl

In plain JavaScript the bitwise operators &, | and << work on 32-bit signed integers, so 2147483648 & 2147483648 gives -2147483648. pas2js does better than that: on NativeInt it compiles and, or and shl into helper functions (rtl.and, rtl.shl) that work correctly up to 53 bits. In a test, 2147483648 and 2147483648 gave 2147483648 and (4294967296 + 5) and 4294967296 gave 4294967296.

So inside the 53-bit range you could use and in Pascal code. The record uses div and mod anyway, for two reasons. The same logic works in an asm block or a JavaScript library where the operators really are 32-bit, and it does not depend on a variable being declared NativeInt rather than Integer. Neither approach can hold all 64 bits in one number, which is why the record keeps two halves.

Testing

Both units were compiled and run before this article was published.

Pitfalls


PBS Delphi - Int64 Values in JSON and TMS WEB Core


Back to Articles for Developers
Back to Articles on Delphi
How to Stop the Red Error Panel in TMS WEB Core
How to Handle Nil Pointers in TMS WebCore
Implement Virtual Tables in TMS WebCore and XData

If you found this useful, then please consider making a donation.

paypal
QR Code for donation Please donate if helpful