Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions docker/grafana/dashboards/test_uint64_precision_issue_832.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "vertamedia-clickhouse-datasource",
"uid": "clickhouse"
},
"description": "Exact reproduction from issue #832\n\nQuery: SELECT 11189782786942380395 AS v\n\nExpected: 11189782786942380395\nBug shows: 11189782786942380000",
"fieldConfig": {
"defaults": {},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": ["sum"],
"show": false
},
"showHeader": true
},
"pluginVersion": "10.0.0",
"targets": [
{
"datasource": {
"type": "vertamedia-clickhouse-datasource",
"uid": "clickhouse"
},
"format": "table",
"query": "SELECT 11189782786942380395 AS v",
"rawQuery": "SELECT 11189782786942380395 AS v",
"refId": "A"
}
],
"title": "Issue #832 Reproduction: SELECT 11189782786942380395 AS v",
"type": "table"
}
],
"refresh": "",
"schemaVersion": 39,
"tags": ["test", "uint64", "issue-832"],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Test UInt64 Precision (Issue #832)",
"uid": "test-uint64-precision-832",
"version": 1,
"weekStart": ""
}
7 changes: 6 additions & 1 deletion pkg/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,12 @@ func (client *ClickHouseClient) Query(ctx context.Context, query string) (*Respo
}

var jsonResp = &Response{ctx: ctx}
err = json.Unmarshal(body, jsonResp)
// Use json.Decoder with UseNumber() to preserve precision for large integers (UInt64/Int64)
// Without this, json.Unmarshal converts numbers to float64, losing precision for values > 2^53
// See: https://github.com/Altinity/clickhouse-grafana/issues/832
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()
err = decoder.Decode(jsonResp)
if err != nil {
return onErr(fmt.Errorf("unable to parse json %s. Error: %w", body, err))
}
Expand Down
86 changes: 72 additions & 14 deletions pkg/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,17 +90,13 @@ func NewDataFieldByType(fieldName, fieldType string) *data.Field {
return data.NewField(fieldName, nil, []time.Time{})
}

if isNullable {
return data.NewField(fieldName, nil, []*uint64{})
} else {
return data.NewField(fieldName, nil, []uint64{})
}
// Use string fields for UInt64 to preserve precision for values > 2^53-1
// See: https://github.com/Altinity/clickhouse-grafana/issues/832
return newStringField(fieldName, isNullable)
case "Int64":
if isNullable {
return data.NewField(fieldName, nil, []*int64{})
} else {
return data.NewField(fieldName, nil, []int64{})
}
// Use string fields for Int64 to preserve precision for values outside ±2^53-1
// See: https://github.com/Altinity/clickhouse-grafana/issues/832
return newStringField(fieldName, isNullable)
default:
if strings.HasPrefix(fieldType, "Decimal") {
return newFloat64Field(fieldName, isNullable)
Expand Down Expand Up @@ -194,7 +190,17 @@ func parseMapValue(value interface{}, isNullable bool) Value {

func parseUInt64Value(value interface{}, isNullable bool) Value {
if value != nil {
ui64v, err := strconv.ParseUint(fmt.Sprintf("%v", value), 10, 64)
var ui64v uint64
var err error

// Handle json.Number type which preserves precision for large integers
// See: https://github.com/Altinity/clickhouse-grafana/issues/832
switch v := value.(type) {
case json.Number:
ui64v, err = strconv.ParseUint(string(v), 10, 64)
default:
ui64v, err = strconv.ParseUint(fmt.Sprintf("%v", value), 10, 64)
}

if err == nil {
if isNullable {
Expand All @@ -213,7 +219,17 @@ func parseUInt64Value(value interface{}, isNullable bool) Value {

func parseInt64Value(value interface{}, isNullable bool) Value {
if value != nil {
i64v, err := strconv.ParseInt(fmt.Sprintf("%v", value), 10, 64)
var i64v int64
var err error

// Handle json.Number type which preserves precision for large integers
// See: https://github.com/Altinity/clickhouse-grafana/issues/832
switch v := value.(type) {
case json.Number:
i64v, err = strconv.ParseInt(string(v), 10, 64)
default:
i64v, err = strconv.ParseInt(fmt.Sprintf("%v", value), 10, 64)
}

if err == nil {
if isNullable {
Expand All @@ -231,6 +247,44 @@ func parseInt64Value(value interface{}, isNullable bool) Value {
}
}

// parseUInt64AsStringValue returns UInt64 values as strings to preserve precision for values > 2^53-1
// See: https://github.com/Altinity/clickhouse-grafana/issues/832
func parseUInt64AsStringValue(value interface{}, isNullable bool) Value {
if value == nil {
if isNullable {
return nil
}
return "0"
}

// Handle json.Number type which preserves precision for large integers
switch v := value.(type) {
case json.Number:
return parseStringValue(string(v), isNullable)
default:
return parseStringValue(fmt.Sprintf("%v", value), isNullable)
}
}

// parseInt64AsStringValue returns Int64 values as strings to preserve precision for values outside ±2^53-1
// See: https://github.com/Altinity/clickhouse-grafana/issues/832
func parseInt64AsStringValue(value interface{}, isNullable bool) Value {
if value == nil {
if isNullable {
return nil
}
return "0"
}

// Handle json.Number type which preserves precision for large integers
switch v := value.(type) {
case json.Number:
return parseStringValue(string(v), isNullable)
default:
return parseStringValue(fmt.Sprintf("%v", value), isNullable)
}
}

func parseTimestampValue(value interface{}, isNullable bool) Value {
if value != nil {
strValue := fmt.Sprintf("%v", value)
Expand Down Expand Up @@ -294,12 +348,16 @@ func ParseValue(fieldName string, fieldType string, tz *time.Location, value int
return parseTimestampValue(value, isNullable)
}

return parseUInt64Value(value, isNullable)
// Return as string to preserve precision for values > 2^53-1
// See: https://github.com/Altinity/clickhouse-grafana/issues/832
return parseUInt64AsStringValue(value, isNullable)
case "Int64":
if fieldName == "t" {
return parseTimestampValue(value, isNullable)
}
return parseInt64Value(value, isNullable)
// Return as string to preserve precision for values outside ±2^53-1
// See: https://github.com/Altinity/clickhouse-grafana/issues/832
return parseInt64AsStringValue(value, isNullable)
default:
if strings.HasPrefix(fieldType, "Decimal") {
return parseFloatValue(value, isNullable)
Expand Down
173 changes: 173 additions & 0 deletions src/datasource/sql-series/bigIntUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* Utilities for handling big integers (UInt64/Int64) safely in JavaScript.
*
* JavaScript Number can only safely represent integers up to 2^53 - 1.
* Values larger than this will lose precision when converted to Number.
*
* @see https://github.com/Altinity/clickhouse-grafana/issues/832
*/

export const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER; // 9007199254740991 (2^53 - 1)
export const MIN_SAFE_INTEGER = Number.MIN_SAFE_INTEGER; // -9007199254740991

/**
* Check if a numeric string represents a value within JavaScript's safe integer range.
* For non-string values, converts to number first and checks if it's a safe integer.
*/
export const isSafeInteger = (value: string | number): boolean => {
if (typeof value === 'number') {
return Number.isSafeInteger(value);
}

// For strings, parse and check
// Note: We need to be careful here - if the string represents a number
// larger than MAX_SAFE_INTEGER, parseFloat will already lose precision.
// So we compare string lengths and values carefully.
const trimmed = value.trim();

// Check for non-numeric strings
if (!/^-?\d+$/.test(trimmed)) {
return false; // Not an integer string
}

// For positive numbers
if (!trimmed.startsWith('-')) {
// MAX_SAFE_INTEGER = 9007199254740991 (16 digits)
if (trimmed.length > 16) {
return false;
}
if (trimmed.length < 16) {
return true;
}
// Exactly 16 digits - compare lexicographically
return trimmed <= '9007199254740991';
}

// For negative numbers
const abs = trimmed.slice(1);
// MIN_SAFE_INTEGER = -9007199254740991 (16 digits without minus)
if (abs.length > 16) {
return false;
}
if (abs.length < 16) {
return true;
}
// Exactly 16 digits - compare lexicographically
return abs <= '9007199254740991';
};

/**
* Check if a ClickHouse type can potentially exceed JavaScript's safe integer range.
*
* Types that need special handling:
* - UInt64: 0 to 18,446,744,073,709,551,615 (can exceed 2^53-1)
* - Int64: -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 (can exceed ±2^53-1)
* - Decimal64/Decimal128: Fixed-point, can have large integer parts
*
* Types that DON'T need special handling:
* - Float64: JS Number IS IEEE 754 double, so no precision loss
* - DateTime64: Represents time, handled separately
* - UInt32/Int32 and smaller: Always within safe range
*
* Also handles nested types like Array(Tuple(String, UInt64)) by extracting the value type.
*/
export const is64BitIntegerType = (chType: string): boolean => {
if (!chType) {
return false;
}

// Handle Nullable wrapper
let type = chType;
if (type.startsWith('Nullable(')) {
type = type.slice('Nullable('.length, -1);
}

// Handle LowCardinality wrapper
if (type.startsWith('LowCardinality(')) {
type = type.slice('LowCardinality('.length, -1);
}

// Direct match for 64-bit integer types
if (type === 'UInt64' || type === 'Int64') {
return true;
}

// Decimal64 and Decimal128 can have values exceeding safe integer range
// Decimal64(S) has up to 18 digits, Decimal128(S) has up to 38 digits
// Note: Decimal32 max is ~4 billion, which is safe
if (type.startsWith('Decimal64') || type.startsWith('Decimal128')) {
return true;
}

// Check for Array(Tuple(...)) pattern used by $columns macro
// E.g., Array(Tuple(String, UInt64)) -> extract UInt64
const arrayTupleMatch = type.match(/^Array\(Tuple\([^,]+,\s*(\w+)\)\)$/);
if (arrayTupleMatch) {
const valueType = arrayTupleMatch[1];
return valueType === 'UInt64' || valueType === 'Int64';
}

// Check for Array(Tuple(..., Decimal...)) pattern
const arrayTupleDecimalMatch = type.match(/^Array\(Tuple\([^,]+,\s*(Decimal(?:64|128)[^)]*)\)\)$/);
if (arrayTupleDecimalMatch) {
return true;
}

return false;
};

/**
* Extract the value type from an Array(Tuple(...)) type pattern.
* Returns the original type if not matching the pattern.
*/
export const extractValueTypeFromArrayTuple = (chType: string): string => {
if (!chType) {
return chType;
}

const arrayTupleMatch = chType.match(/^Array\(Tuple\([^,]+,\s*(\w+)\)\)$/);
if (arrayTupleMatch) {
return arrayTupleMatch[1];
}

return chType;
};

/**
* Safely format a numeric value, preserving precision for large integers.
*
* For 64-bit integer types:
* - If the value is within safe range, return as number
* - If the value is outside safe range, return as string to preserve precision
*
* For other numeric types, always convert to number.
*/
export const formatNumericValue = (value: any, chType?: string): number | string | null => {
if (value === null || value === undefined) {
return value;
}

if (typeof value === 'object') {
return JSON.stringify(value);
}

// For 64-bit types, check if we can safely convert
if (chType && is64BitIntegerType(chType)) {
if (typeof value === 'string') {
if (isSafeInteger(value)) {
return Number(value);
}
// Keep as string to preserve precision
return value;
}
// Already a number - return as is (precision may already be lost)
return value;
}

// For non-64-bit types, convert to number
const numeric = Number(value);
if (isNaN(numeric)) {
return value;
}
return numeric;
};
Loading
Loading