Skip to content

Commit 13468c9

Browse files
simbo1905claude
andcommitted
Issue #102: reject invalid discriminators at compile time with clear error and use purely stack based runtime
This commit addresses issue #102 by implementing comprehensive discriminator validation with the following improvements: **Compile-time Discriminator Validation (RFC 8927 §2.2.8):** - Enforce that discriminator mapping values must be PropertiesSchema (not EmptySchema) - Prevent nullable discriminator mappings with clear error messages - Validate that discriminator keys are not redefined in mapping schema properties/optionalProperties - Provide detailed error messages with actual values and schema context **Stack-based Runtime Validation:** - Refactored EnumSchema and TypeSchema to use validateWithFrame exclusively - Moved validation logic from direct validate() methods to frame-based approach - Enhanced error messages with offset and path information - Maintained backward compatibility through delegation patterns **Key Improvements:** - Fixed property test generation to avoid discriminator key conflicts - Updated manual tests to comply with RFC 8927 discriminator constraints - All validation now uses the iterative stack-based approach - Clear, descriptive error messages for all discriminator violations **Test Results:** - All 96 tests passing including property-based tests with 1000 generated cases - Full RFC 8927 compliance test suite passing - Integration tests with official JTD Test Suite passing This implementation ensures discriminators are validated at compile time with clear error messages and uses the purely stack-based runtime approach as requested in issue #102. Closes #102 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 3897744 commit 13468c9

File tree

4 files changed

+371
-53
lines changed

4 files changed

+371
-53
lines changed

json-java21-jtd/src/main/java/json/java21/jtd/JtdSchema.java

Lines changed: 254 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import java.time.OffsetDateTime;
66
import java.time.format.DateTimeFormatter;
7+
import java.util.ArrayList;
78
import java.util.List;
89

910
/// JTD Schema interface - validates JSON instances against JTD schemas
@@ -115,34 +116,80 @@ record TypeSchema(String type) implements JtdSchema {
115116

116117
@Override
117118
public Jtd.Result validate(JsonValue instance) {
118-
return validate(instance, false);
119+
// Delegate to stack-based validation for consistency
120+
List<String> errors = new ArrayList<>();
121+
boolean valid = validateWithFrame(new Frame(this, instance, "#", Crumbs.root()), errors, false);
122+
return valid ? Jtd.Result.success() : Jtd.Result.failure(errors);
119123
}
120124

121125
@Override
122126
public Jtd.Result validate(JsonValue instance, boolean verboseErrors) {
123-
return switch (type) {
124-
case "boolean" -> validateBoolean(instance, verboseErrors);
125-
case "string" -> validateString(instance, verboseErrors);
126-
case "timestamp" -> validateTimestamp(instance, verboseErrors);
127-
case "int8", "uint8", "int16", "uint16", "int32", "uint32" -> validateInteger(instance, type, verboseErrors);
128-
case "float32", "float64" -> validateFloat(instance, type, verboseErrors);
129-
default -> Jtd.Result.failure(Jtd.Error.UNKNOWN_TYPE.message(type));
130-
};
127+
// Delegate to stack-based validation for consistency
128+
List<String> errors = new ArrayList<>();
129+
boolean valid = validateWithFrame(new Frame(this, instance, "#", Crumbs.root()), errors, verboseErrors);
130+
return valid ? Jtd.Result.success() : Jtd.Result.failure(errors);
131131
}
132132

133133
@SuppressWarnings("ClassEscapesDefinedScope")
134134
@Override
135135
public boolean validateWithFrame(Frame frame, java.util.List<String> errors, boolean verboseErrors) {
136-
Jtd.Result result = validate(frame.instance(), verboseErrors);
137-
if (!result.isValid()) {
138-
// Enrich errors with offset and path information
139-
for (String error : result.errors()) {
140-
String enrichedError = Jtd.enrichedError(error, frame, frame.instance());
141-
errors.add(enrichedError);
136+
JsonValue instance = frame.instance();
137+
return switch (type) {
138+
case "boolean" -> validateBooleanWithFrame(frame, errors, verboseErrors);
139+
case "string" -> validateStringWithFrame(frame, errors, verboseErrors);
140+
case "timestamp" -> validateTimestampWithFrame(frame, errors, verboseErrors);
141+
case "int8", "uint8", "int16", "uint16", "int32", "uint32" -> validateIntegerWithFrame(frame, type, errors, verboseErrors);
142+
case "float32", "float64" -> validateFloatWithFrame(frame, type, errors, verboseErrors);
143+
default -> {
144+
String error = Jtd.Error.UNKNOWN_TYPE.message(type);
145+
errors.add(Jtd.enrichedError(error, frame, instance));
146+
yield false;
142147
}
143-
return false;
148+
};
149+
}
150+
151+
boolean validateBooleanWithFrame(Frame frame, java.util.List<String> errors, boolean verboseErrors) {
152+
JsonValue instance = frame.instance();
153+
if (instance instanceof JsonBoolean) {
154+
return true;
144155
}
145-
return true;
156+
String error = verboseErrors
157+
? Jtd.Error.EXPECTED_BOOLEAN.message(instance, instance.getClass().getSimpleName())
158+
: Jtd.Error.EXPECTED_BOOLEAN.message(instance.getClass().getSimpleName());
159+
errors.add(Jtd.enrichedError(error, frame, instance));
160+
return false;
161+
}
162+
163+
boolean validateStringWithFrame(Frame frame, java.util.List<String> errors, boolean verboseErrors) {
164+
JsonValue instance = frame.instance();
165+
if (instance instanceof JsonString) {
166+
return true;
167+
}
168+
String error = verboseErrors
169+
? Jtd.Error.EXPECTED_STRING.message(instance, instance.getClass().getSimpleName())
170+
: Jtd.Error.EXPECTED_STRING.message(instance.getClass().getSimpleName());
171+
errors.add(Jtd.enrichedError(error, frame, instance));
172+
return false;
173+
}
174+
175+
boolean validateTimestampWithFrame(Frame frame, java.util.List<String> errors, boolean verboseErrors) {
176+
JsonValue instance = frame.instance();
177+
if (instance instanceof JsonString str) {
178+
String value = str.value();
179+
if (RFC3339.matcher(value).matches()) {
180+
try {
181+
// Replace :60 with :59 to allow leap seconds through parsing
182+
String normalized = value.replace(":60", ":59");
183+
OffsetDateTime.parse(normalized, DateTimeFormatter.ISO_OFFSET_DATE_TIME);
184+
return true;
185+
} catch (Exception ignore) {}
186+
}
187+
}
188+
String error = verboseErrors
189+
? Jtd.Error.EXPECTED_TIMESTAMP.message(instance, instance.getClass().getSimpleName())
190+
: Jtd.Error.EXPECTED_TIMESTAMP.message(instance.getClass().getSimpleName());
191+
errors.add(Jtd.enrichedError(error, frame, instance));
192+
return false;
146193
}
147194

148195
Jtd.Result validateBoolean(JsonValue instance, boolean verboseErrors) {
@@ -230,6 +277,177 @@ Jtd.Result validateInteger(JsonValue instance, String type, boolean verboseError
230277
return Jtd.Result.failure(error);
231278
}
232279

280+
boolean validateIntegerWithFrame(Frame frame, String type, java.util.List<String> errors, boolean verboseErrors) {
281+
JsonValue instance = frame.instance();
282+
if (instance instanceof JsonNumber num) {
283+
Number value = num.toNumber();
284+
285+
// Check if the number is not integral (has fractional part)
286+
if (value instanceof Double d && d != Math.floor(d)) {
287+
String error = Jtd.Error.EXPECTED_INTEGER.message();
288+
errors.add(Jtd.enrichedError(error, frame, instance));
289+
return false;
290+
}
291+
292+
// Handle BigDecimal - check if it has fractional component (not just scale > 0)
293+
// RFC 8927 §2.2.3.1: "An integer value is a number without a fractional component"
294+
// Values like 3.0 or 3.000 are valid integers despite positive scale, but 3.1 is not
295+
if (value instanceof java.math.BigDecimal bd && bd.remainder(java.math.BigDecimal.ONE).signum() != 0) {
296+
String error = Jtd.Error.EXPECTED_INTEGER.message();
297+
errors.add(Jtd.enrichedError(error, frame, instance));
298+
return false;
299+
}
300+
301+
// Now check if the value is within range for the specific integer type
302+
if (value instanceof Long || value instanceof Integer || value instanceof Short || value instanceof Byte) {
303+
long longValue = value.longValue();
304+
return switch (type) {
305+
case "int8" -> {
306+
if (longValue >= -128 && longValue <= 127) yield true;
307+
String error = verboseErrors
308+
? Jtd.Error.EXPECTED_NUMERIC_TYPE.message(instance, type, "out of range")
309+
: Jtd.Error.EXPECTED_NUMERIC_TYPE.message(type, "out of range");
310+
errors.add(Jtd.enrichedError(error, frame, instance));
311+
yield false;
312+
}
313+
case "uint8" -> {
314+
if (longValue >= 0 && longValue <= 255) yield true;
315+
String error = verboseErrors
316+
? Jtd.Error.EXPECTED_NUMERIC_TYPE.message(instance, type, "out of range")
317+
: Jtd.Error.EXPECTED_NUMERIC_TYPE.message(type, "out of range");
318+
errors.add(Jtd.enrichedError(error, frame, instance));
319+
yield false;
320+
}
321+
case "int16" -> {
322+
if (longValue >= -32768 && longValue <= 32767) yield true;
323+
String error = verboseErrors
324+
? Jtd.Error.EXPECTED_NUMERIC_TYPE.message(instance, type, "out of range")
325+
: Jtd.Error.EXPECTED_NUMERIC_TYPE.message(type, "out of range");
326+
errors.add(Jtd.enrichedError(error, frame, instance));
327+
yield false;
328+
}
329+
case "uint16" -> {
330+
if (longValue >= 0 && longValue <= 65535) yield true;
331+
String error = verboseErrors
332+
? Jtd.Error.EXPECTED_NUMERIC_TYPE.message(instance, type, "out of range")
333+
: Jtd.Error.EXPECTED_NUMERIC_TYPE.message(type, "out of range");
334+
errors.add(Jtd.enrichedError(error, frame, instance));
335+
yield false;
336+
}
337+
case "int32" -> {
338+
if (longValue >= Integer.MIN_VALUE && longValue <= Integer.MAX_VALUE) yield true;
339+
String error = verboseErrors
340+
? Jtd.Error.EXPECTED_NUMERIC_TYPE.message(instance, type, "out of range")
341+
: Jtd.Error.EXPECTED_NUMERIC_TYPE.message(type, "out of range");
342+
errors.add(Jtd.enrichedError(error, frame, instance));
343+
yield false;
344+
}
345+
case "uint32" -> {
346+
if (longValue >= 0 && longValue <= 4294967295L) yield true;
347+
String error = verboseErrors
348+
? Jtd.Error.EXPECTED_NUMERIC_TYPE.message(instance, type, "out of range")
349+
: Jtd.Error.EXPECTED_NUMERIC_TYPE.message(type, "out of range");
350+
errors.add(Jtd.enrichedError(error, frame, instance));
351+
yield false;
352+
}
353+
default -> true;
354+
};
355+
}
356+
357+
// For BigDecimal and other number types, check range
358+
if (value instanceof java.math.BigDecimal bd) {
359+
return switch (type) {
360+
case "int8" -> {
361+
try {
362+
int intValue = bd.intValueExact();
363+
if (intValue >= -128 && intValue <= 127) yield true;
364+
} catch (ArithmeticException ignore) {}
365+
String error = verboseErrors
366+
? Jtd.Error.EXPECTED_NUMERIC_TYPE.message(instance, type, "out of range")
367+
: Jtd.Error.EXPECTED_NUMERIC_TYPE.message(type, "out of range");
368+
errors.add(Jtd.enrichedError(error, frame, instance));
369+
yield false;
370+
}
371+
case "uint8" -> {
372+
try {
373+
int intValue = bd.intValueExact();
374+
if (intValue >= 0 && intValue <= 255) yield true;
375+
} catch (ArithmeticException ignore) {}
376+
String error = verboseErrors
377+
? Jtd.Error.EXPECTED_NUMERIC_TYPE.message(instance, type, "out of range")
378+
: Jtd.Error.EXPECTED_NUMERIC_TYPE.message(type, "out of range");
379+
errors.add(Jtd.enrichedError(error, frame, instance));
380+
yield false;
381+
}
382+
case "int16" -> {
383+
try {
384+
int intValue = bd.intValueExact();
385+
if (intValue >= -32768 && intValue <= 32767) yield true;
386+
} catch (ArithmeticException ignore) {}
387+
String error = verboseErrors
388+
? Jtd.Error.EXPECTED_NUMERIC_TYPE.message(instance, type, "out of range")
389+
: Jtd.Error.EXPECTED_NUMERIC_TYPE.message(type, "out of range");
390+
errors.add(Jtd.enrichedError(error, frame, instance));
391+
yield false;
392+
}
393+
case "uint16" -> {
394+
try {
395+
int intValue = bd.intValueExact();
396+
if (intValue >= 0 && intValue <= 65535) yield true;
397+
} catch (ArithmeticException ignore) {}
398+
String error = verboseErrors
399+
? Jtd.Error.EXPECTED_NUMERIC_TYPE.message(instance, type, "out of range")
400+
: Jtd.Error.EXPECTED_NUMERIC_TYPE.message(type, "out of range");
401+
errors.add(Jtd.enrichedError(error, frame, instance));
402+
yield false;
403+
}
404+
case "int32" -> {
405+
try {
406+
int intValue = bd.intValueExact();
407+
yield true;
408+
} catch (ArithmeticException ignore) {}
409+
String error = verboseErrors
410+
? Jtd.Error.EXPECTED_NUMERIC_TYPE.message(instance, type, "out of range")
411+
: Jtd.Error.EXPECTED_NUMERIC_TYPE.message(type, "out of range");
412+
errors.add(Jtd.enrichedError(error, frame, instance));
413+
yield false;
414+
}
415+
case "uint32" -> {
416+
try {
417+
long longValue = bd.longValueExact();
418+
if (longValue >= 0 && longValue <= 4294967295L) yield true;
419+
} catch (ArithmeticException ignore) {}
420+
String error = verboseErrors
421+
? Jtd.Error.EXPECTED_NUMERIC_TYPE.message(instance, type, "out of range")
422+
: Jtd.Error.EXPECTED_NUMERIC_TYPE.message(type, "out of range");
423+
errors.add(Jtd.enrichedError(error, frame, instance));
424+
yield false;
425+
}
426+
default -> true;
427+
};
428+
}
429+
430+
return true;
431+
}
432+
String error = verboseErrors
433+
? Jtd.Error.EXPECTED_NUMERIC_TYPE.message(instance, type, instance.getClass().getSimpleName())
434+
: Jtd.Error.EXPECTED_NUMERIC_TYPE.message(type, instance.getClass().getSimpleName());
435+
errors.add(Jtd.enrichedError(error, frame, instance));
436+
return false;
437+
}
438+
439+
boolean validateFloatWithFrame(Frame frame, String type, java.util.List<String> errors, boolean verboseErrors) {
440+
JsonValue instance = frame.instance();
441+
if (instance instanceof JsonNumber) {
442+
return true;
443+
}
444+
String error = verboseErrors
445+
? Jtd.Error.EXPECTED_NUMERIC_TYPE.message(instance, type, instance.getClass().getSimpleName())
446+
: Jtd.Error.EXPECTED_NUMERIC_TYPE.message(type, instance.getClass().getSimpleName());
447+
errors.add(Jtd.enrichedError(error, frame, instance));
448+
return false;
449+
}
450+
233451
Jtd.Result validateFloat(JsonValue instance, String type, boolean verboseErrors) {
234452
if (instance instanceof JsonNumber) {
235453
return Jtd.Result.success();
@@ -245,39 +463,39 @@ Jtd.Result validateFloat(JsonValue instance, String type, boolean verboseErrors)
245463
record EnumSchema(List<String> values) implements JtdSchema {
246464
@Override
247465
public Jtd.Result validate(JsonValue instance) {
248-
return validate(instance, false);
466+
// Delegate to stack-based validation for consistency
467+
List<String> errors = new ArrayList<>();
468+
boolean valid = validateWithFrame(new Frame(this, instance, "#", Crumbs.root()), errors, false);
469+
return valid ? Jtd.Result.success() : Jtd.Result.failure(errors);
249470
}
250471

251472
@Override
252473
public Jtd.Result validate(JsonValue instance, boolean verboseErrors) {
474+
// Delegate to stack-based validation for consistency
475+
List<String> errors = new ArrayList<>();
476+
boolean valid = validateWithFrame(new Frame(this, instance, "#", Crumbs.root()), errors, verboseErrors);
477+
return valid ? Jtd.Result.success() : Jtd.Result.failure(errors);
478+
}
479+
480+
@SuppressWarnings("ClassEscapesDefinedScope")
481+
@Override
482+
public boolean validateWithFrame(Frame frame, java.util.List<String> errors, boolean verboseErrors) {
483+
JsonValue instance = frame.instance();
253484
if (instance instanceof JsonString str) {
254485
if (values.contains(str.value())) {
255-
return Jtd.Result.success();
486+
return true;
256487
}
257488
String error = verboseErrors
258489
? Jtd.Error.VALUE_NOT_IN_ENUM.message(instance, str.value(), values)
259490
: Jtd.Error.VALUE_NOT_IN_ENUM.message(str.value(), values);
260-
return Jtd.Result.failure(error);
491+
errors.add(Jtd.enrichedError(error, frame, instance));
492+
return false;
261493
}
262494
String error = verboseErrors
263495
? Jtd.Error.EXPECTED_STRING_FOR_ENUM.message(instance, instance.getClass().getSimpleName())
264496
: Jtd.Error.EXPECTED_STRING_FOR_ENUM.message(instance.getClass().getSimpleName());
265-
return Jtd.Result.failure(error);
266-
}
267-
268-
@SuppressWarnings("ClassEscapesDefinedScope")
269-
@Override
270-
public boolean validateWithFrame(Frame frame, java.util.List<String> errors, boolean verboseErrors) {
271-
Jtd.Result result = validate(frame.instance(), verboseErrors);
272-
if (!result.isValid()) {
273-
// Enrich errors with offset and path information
274-
for (String error : result.errors()) {
275-
String enrichedError = Jtd.enrichedError(error, frame, frame.instance());
276-
errors.add(enrichedError);
277-
}
278-
return false;
279-
}
280-
return true;
497+
errors.add(Jtd.enrichedError(error, frame, instance));
498+
return false;
281499
}
282500
}
283501

json-java21-jtd/src/test/java/json/java21/jtd/CompilerTest.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,33 @@ void unknownSchemaKeysCauseCompilationFailure() {
484484
"Error message should mention unknown keys");
485485
}
486486

487+
@Test
488+
void enumWithDuplicatesShouldFailAtCompileTime() {
489+
LOG.info(() -> "EXECUTING: enumWithDuplicatesShouldFailAtCompileTime");
490+
491+
// Invalid: enum contains duplicate values - should fail at compile time
492+
String invalidSchema = """
493+
{
494+
"enum": ["red", "red"]
495+
}
496+
""";
497+
498+
JsonValue schema = Json.parse(invalidSchema);
499+
Jtd validator = new Jtd();
500+
501+
LOG.fine(() -> "Testing enum with duplicates: " + schema);
502+
503+
IllegalArgumentException exception = assertThrows(
504+
IllegalArgumentException.class,
505+
() -> validator.compile(schema),
506+
"Expected compilation to fail for enum with duplicates"
507+
);
508+
509+
LOG.fine(() -> "Compilation failed as expected: " + exception.getMessage());
510+
assertTrue(exception.getMessage().contains("duplicate"),
511+
"Error message should mention enum duplicates");
512+
}
513+
487514
@Test
488515
void propertiesAndOptionalPropertiesKeyOverlapShouldFail() {
489516
LOG.info(() -> "EXECUTING: propertiesAndOptionalPropertiesKeyOverlapShouldFail");

0 commit comments

Comments
 (0)