Skip to content

Commit f103f0a

Browse files
authored
Merge pull request #2938 from glideapps/fix/2931-int64-range-overflow
fix(core): infer whole numbers outside the target's integer range as double
2 parents caec102 + 751ac79 commit f103f0a

21 files changed

Lines changed: 579 additions & 7 deletions

File tree

packages/quicktype-core/src/TargetLanguage.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { Type } from "./Type/Type.js";
1515
import type { StringTypeMapping } from "./Type/TypeBuilderUtils.js";
1616
import type { TypeGraph } from "./Type/TypeGraph.js";
1717
import type { Comment } from "./support/Comments.js";
18+
import { INT64_RANGE, type IntegerRange } from "./support/IntegerRange.js";
1819
import { defined } from "./support/Support.js";
1920
import type { LanguageName, RendererOptions } from "./types.js";
2021

@@ -121,4 +122,22 @@ export abstract class TargetLanguage<
121122
public get dateTimeRecognizer(): DateTimeRecognizer {
122123
return new DefaultDateTimeRecognizer();
123124
}
125+
126+
/**
127+
* The inclusive range of whole numbers in input JSON that quicktype
128+
* infers as the language's integer type. Whole numbers outside the
129+
* range are inferred as `double` instead, because the integer type
130+
* could not round-trip them (issue #2931). `null` means the
131+
* language's integers are arbitrary-precision.
132+
*
133+
* Languages whose integer width depends on a renderer option (like
134+
* cJSON's `integer-size`) override this and inspect
135+
* `rendererOptions`, which are the same untyped option values that
136+
* `makeRenderer` receives.
137+
*/
138+
public getSupportedIntegerRange(
139+
_rendererOptions: Record<string, unknown> = {},
140+
): IntegerRange | null {
141+
return INT64_RANGE;
142+
}
124143
}

packages/quicktype-core/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ export {
1616
type InferenceFlagName,
1717
} from "./Inference.js";
1818
export { CompressedJSON, type Value } from "./input/CompressedJSON.js";
19+
export {
20+
INT8_RANGE,
21+
INT16_RANGE,
22+
INT32_RANGE,
23+
INT64_RANGE,
24+
type IntegerRange,
25+
JS_SAFE_INTEGER_RANGE,
26+
integerStringInRange,
27+
} from "./support/IntegerRange.js";
1928
export {
2029
type Input,
2130
InputData,

packages/quicktype-core/src/input/CompressedJSON.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { addHashCode, hashCodeInit, hashString } from "collection-utils";
22

33
import { inferTransformedStringTypeKindForString } from "../attributes/StringTypes.js";
44
import type { DateTimeRecognizer } from "../DateTime.js";
5+
import {
6+
INT64_RANGE,
7+
type IntegerRange,
8+
integerStringInRange,
9+
} from "../support/IntegerRange.js";
510
import { assert, defined, panic } from "../support/Support.js";
611
import {
712
type TransformedStringTypeKind,
@@ -66,13 +71,50 @@ export abstract class CompressedJSON<T> {
6671

6772
private readonly _arrays: Value[][] = [];
6873

74+
/**
75+
* `supportedIntegerRange` is the range of whole numbers in the input
76+
* that get inferred as `integer`; whole numbers outside it are inferred
77+
* as `double`, because the target language's integer type could not
78+
* round-trip them. `null` means the target's integers are
79+
* arbitrary-precision. See `TargetLanguage.getSupportedIntegerRange`.
80+
*/
6981
public constructor(
7082
public readonly dateTimeRecognizer: DateTimeRecognizer,
7183
public readonly handleRefs: boolean,
84+
public readonly supportedIntegerRange: IntegerRange | null = INT64_RANGE,
7285
) {}
7386

7487
public abstract parse(input: T): Promise<Value>;
7588

89+
/**
90+
* Whether a whole number in the input, given as the decimal string of
91+
* its JSON literal, fits `supportedIntegerRange`. Works on the digit
92+
* string because such literals can exceed what a JavaScript number can
93+
* represent exactly.
94+
*/
95+
protected integerStringFits(integerString: string): boolean {
96+
const range = this.supportedIntegerRange;
97+
if (range === null) return true;
98+
return integerStringInRange(integerString, range);
99+
}
100+
101+
/**
102+
* Whether a number that `JSON.parse` produced should be inferred as
103+
* `double`. The original literal is gone at this point, but for whole
104+
* numbers below 1e21, `toFixed(0)` gives the exact decimal value of the
105+
* double, and it errs on the right side at range boundaries: a literal
106+
* like 9223372036854775807 (INT64_MAX) parses to the double
107+
* 9223372036854775808, which is correctly outside the int64 range. At
108+
* 1e21 doubles are far beyond any fixed-size integer type and `toFixed`
109+
* switches to exponential notation, so those are doubles outright.
110+
*/
111+
protected parsedNumberIsDouble(n: number): boolean {
112+
if (n !== Math.floor(n)) return true;
113+
if (this.supportedIntegerRange === null) return false;
114+
if (Math.abs(n) >= 1e21) return true;
115+
return !this.integerStringFits(n.toFixed(0));
116+
}
117+
76118
public parseSync(_input: T): Value {
77119
return panic("parseSync not implemented in CompressedJSON");
78120
}
@@ -340,11 +382,7 @@ export class CompressedJSONFromString extends CompressedJSON<string> {
340382
} else if (typeof json === "string") {
341383
this.commitString(json);
342384
} else if (typeof json === "number") {
343-
const isDouble =
344-
json !== Math.floor(json) ||
345-
json < Number.MIN_SAFE_INTEGER ||
346-
json > Number.MAX_SAFE_INTEGER;
347-
this.commitNumber(isDouble);
385+
this.commitNumber(this.parsedNumberIsDouble(json));
348386
} else if (Array.isArray(json)) {
349387
this.pushArrayContext();
350388
for (const v of json) {

packages/quicktype-core/src/input/Inputs.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ export function jsonInputForTargetLanguage(
206206
targetLanguage: LanguageName | TargetLanguage,
207207
languages?: TargetLanguage[],
208208
handleJSONRefs = false,
209+
rendererOptions: Record<string, unknown> = {},
209210
): JSONInput<string> {
210211
if (typeof targetLanguage === "string") {
211212
targetLanguage = defined(languageNamed(targetLanguage, languages));
@@ -214,6 +215,7 @@ export function jsonInputForTargetLanguage(
214215
const compressedJSON = new CompressedJSONFromString(
215216
targetLanguage.dateTimeRecognizer,
216217
handleJSONRefs,
218+
targetLanguage.getSupportedIntegerRange(rendererOptions),
217219
);
218220
return new JSONInput(compressedJSON);
219221
}

packages/quicktype-core/src/language/CJSON/language.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ import {
2727
StringOption,
2828
getOptionValues,
2929
} from "../../RendererOptions/index.js";
30+
import {
31+
INT8_RANGE,
32+
INT16_RANGE,
33+
INT32_RANGE,
34+
INT64_RANGE,
35+
type IntegerRange,
36+
} from "../../support/IntegerRange.js";
3037
import { TargetLanguage } from "../../TargetLanguage.js";
3138
import type { LanguageName, RendererOptions } from "../../types.js";
3239

@@ -142,6 +149,28 @@ export class CJSONTargetLanguage extends TargetLanguage<
142149
return true;
143150
}
144151

152+
/**
153+
* Return the range of whole numbers the generated integer type can
154+
* represent, which depends on the `integer-size` renderer option
155+
* (int64_t by default)
156+
* @param rendererOptions: untyped renderer option values
157+
* @return the range of the configured integer type
158+
*/
159+
public getSupportedIntegerRange(
160+
rendererOptions: Record<string, unknown> = {},
161+
): IntegerRange | null {
162+
switch (cJSONOptions.typeIntegerSize.getValue(rendererOptions)) {
163+
case "int8_t":
164+
return INT8_RANGE;
165+
case "int16_t":
166+
return INT16_RANGE;
167+
case "int32_t":
168+
return INT32_RANGE;
169+
default:
170+
return INT64_RANGE;
171+
}
172+
}
173+
145174
/**
146175
* Indicate if language support optional class properties
147176
* @return true

packages/quicktype-core/src/language/Crystal/language.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { RenderContext } from "../../Renderer.js";
2+
import { INT32_RANGE, type IntegerRange } from "../../support/IntegerRange.js";
23
import { TargetLanguage } from "../../TargetLanguage.js";
34

45
import { CrystalRenderer } from "./CrystalRenderer.js";
@@ -16,6 +17,11 @@ export class CrystalTargetLanguage extends TargetLanguage<
1617
super(crystalLanguageConfig);
1718
}
1819

20+
// The Crystal renderer emits `Int32` for inferred integers.
21+
public getSupportedIntegerRange(): IntegerRange | null {
22+
return INT32_RANGE;
23+
}
24+
1925
protected makeRenderer(renderContext: RenderContext): CrystalRenderer {
2026
return new CrystalRenderer(this, renderContext);
2127
}

packages/quicktype-core/src/language/Elixir/language.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
StringOption,
55
getOptionValues,
66
} from "../../RendererOptions/index.js";
7+
import type { IntegerRange } from "../../support/IntegerRange.js";
78
import { TargetLanguage } from "../../TargetLanguage.js";
89
import type { LanguageName, RendererOptions } from "../../types.js";
910

@@ -28,6 +29,11 @@ export const elixirLanguageConfig = {
2829
export class ElixirTargetLanguage extends TargetLanguage<
2930
typeof elixirLanguageConfig
3031
> {
32+
// Elixir's integers are arbitrary-precision.
33+
public getSupportedIntegerRange(): IntegerRange | null {
34+
return null;
35+
}
36+
3137
public constructor() {
3238
super(elixirLanguageConfig);
3339
}

packages/quicktype-core/src/language/Elm/language.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import {
55
StringOption,
66
getOptionValues,
77
} from "../../RendererOptions/index.js";
8+
import {
9+
type IntegerRange,
10+
JS_SAFE_INTEGER_RANGE,
11+
} from "../../support/IntegerRange.js";
812
import { TargetLanguage } from "../../TargetLanguage.js";
913
import type { LanguageName, RendererOptions } from "../../types.js";
1014

@@ -55,6 +59,12 @@ export class ElmTargetLanguage extends TargetLanguage<
5559
return true;
5660
}
5761

62+
// Elm compiles to JavaScript, where `Int` is an IEEE-754 double at
63+
// runtime, so integers are only exact within the JS safe range.
64+
public getSupportedIntegerRange(): IntegerRange | null {
65+
return JS_SAFE_INTEGER_RANGE;
66+
}
67+
5868
protected makeRenderer<Lang extends LanguageName = "elm">(
5969
renderContext: RenderContext,
6070
untypedOptionValues: RendererOptions<Lang>,

packages/quicktype-core/src/language/JSONSchema/language.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { RenderContext } from "../../Renderer.js";
2+
import type { IntegerRange } from "../../support/IntegerRange.js";
23
import { TargetLanguage } from "../../TargetLanguage.js";
34
import {
45
type StringTypeMapping,
@@ -17,6 +18,11 @@ export const JSONSchemaLanguageConfig = {
1718
export class JSONSchemaTargetLanguage extends TargetLanguage<
1819
typeof JSONSchemaLanguageConfig
1920
> {
21+
// JSON Schema's `integer` type is unbounded.
22+
public getSupportedIntegerRange(): IntegerRange | null {
23+
return null;
24+
}
25+
2026
public constructor() {
2127
super(JSONSchemaLanguageConfig);
2228
}

packages/quicktype-core/src/language/JavaScript/language.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import {
66
} from "../../RendererOptions/index.js";
77
import { AcronymStyleOptions, acronymOption } from "../../support/Acronyms.js";
88
import { convertersOption } from "../../support/Converters.js";
9+
import {
10+
JS_SAFE_INTEGER_RANGE,
11+
type IntegerRange,
12+
} from "../../support/IntegerRange.js";
913
import { TargetLanguage } from "../../TargetLanguage.js";
1014
import type {
1115
PrimitiveStringTypeKind,
@@ -51,6 +55,10 @@ export const javaScriptLanguageConfig = {
5155
export class JavaScriptTargetLanguage extends TargetLanguage<
5256
typeof javaScriptLanguageConfig
5357
> {
58+
public getSupportedIntegerRange(): IntegerRange | null {
59+
return JS_SAFE_INTEGER_RANGE;
60+
}
61+
5462
public constructor() {
5563
super(javaScriptLanguageConfig);
5664
}

0 commit comments

Comments
 (0)