diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eefb80d..2e41420 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,11 +13,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Set up JDK 21 + - name: Set up JDK 24 uses: actions/setup-java@v4 with: - distribution: temurin - java-version: '21' + distribution: oracle + java-version: '24' cache: 'maven' - name: Build and verify @@ -39,7 +39,7 @@ jobs: for k in totals: totals[k]+=int(r.get(k,'0')) except Exception: pass - exp_tests=611 + exp_tests=1354 exp_skipped=0 if totals['tests']!=exp_tests or totals['skipped']!=exp_skipped: print(f"Unexpected test totals: {totals} != expected tests={exp_tests}, skipped={exp_skipped}") diff --git a/json-java21-jtd-codegen/pom.xml b/json-java21-jtd-codegen/pom.xml new file mode 100644 index 0000000..3185af8 --- /dev/null +++ b/json-java21-jtd-codegen/pom.xml @@ -0,0 +1,92 @@ + + + 4.0.0 + + + io.github.simbo1905.json + parent + 0.1.9 + + + java.util.json.jtd.codegen + jar + java.util.json Java21 Backport JTD Codegen + https://simbo1905.github.io/java.util.json.Java21/ + + scm:git:https://github.com/simbo1905/java.util.json.Java21.git + scm:git:git@github.com:simbo1905/java.util.json.Java21.git + https://github.com/simbo1905/java.util.json.Java21 + HEAD + + Bytecode-generated JTD validators using the JDK 24+ ClassFile API. + Generates Java 21 compatible classfiles for hot-path validation. + Optional dependency: falls back to the interpreter path when absent. + + + UTF-8 + 24 + + + + + io.github.simbo1905.json + java.util.json + ${project.version} + + + io.github.simbo1905.json + java.util.json.jtd + ${project.version} + + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.assertj + assertj-core + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 24 + + -Xlint:all + -Xdiags:verbose + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + 24 + none + + + + + diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/Descriptors.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/Descriptors.java new file mode 100644 index 0000000..13d147a --- /dev/null +++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/Descriptors.java @@ -0,0 +1,74 @@ +package json.java21.jtd.codegen; + +import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDescs; +import java.lang.constant.MethodTypeDesc; + +/// Shared class descriptors and method type descriptors for bytecode emission. +/// +/// All fields are compile-time constants referencing the types the generated +/// classfiles interact with at runtime (JSON API, validation result types, JDK stdlib). +final class Descriptors { + + private Descriptors() {} + + // -- JDK types -- + static final ClassDesc CD_Object = ConstantDescs.CD_Object; + static final ClassDesc CD_String = ConstantDescs.CD_String; + static final ClassDesc CD_Math = ClassDesc.of("java.lang.Math"); + static final ClassDesc CD_CharSequence = ClassDesc.of("java.lang.CharSequence"); + static final ClassDesc CD_OffsetDateTime = ClassDesc.of("java.time.OffsetDateTime"); + static final ClassDesc CD_DateTimeFormatter = ClassDesc.of("java.time.format.DateTimeFormatter"); + static final ClassDesc CD_Pattern = ClassDesc.of("java.util.regex.Pattern"); + static final ClassDesc CD_Matcher = ClassDesc.of("java.util.regex.Matcher"); + + // -- Collections -- + static final ClassDesc CD_ArrayList = ClassDesc.of("java.util.ArrayList"); + static final ClassDesc CD_List = ClassDesc.of("java.util.List"); + static final ClassDesc CD_Map = ClassDesc.of("java.util.Map"); + static final ClassDesc CD_MapEntry = ClassDesc.of("java.util.Map$Entry"); + static final ClassDesc CD_Set = ClassDesc.of("java.util.Set"); + static final ClassDesc CD_Iterator = ClassDesc.of("java.util.Iterator"); + + // -- JSON API types -- + static final ClassDesc CD_JsonValue = ClassDesc.of("jdk.sandbox.java.util.json.JsonValue"); + static final ClassDesc CD_JsonObject = ClassDesc.of("jdk.sandbox.java.util.json.JsonObject"); + static final ClassDesc CD_JsonArray = ClassDesc.of("jdk.sandbox.java.util.json.JsonArray"); + static final ClassDesc CD_JsonString = ClassDesc.of("jdk.sandbox.java.util.json.JsonString"); + static final ClassDesc CD_JsonNumber = ClassDesc.of("jdk.sandbox.java.util.json.JsonNumber"); + static final ClassDesc CD_JsonBoolean = ClassDesc.of("jdk.sandbox.java.util.json.JsonBoolean"); + static final ClassDesc CD_JsonNull = ClassDesc.of("jdk.sandbox.java.util.json.JsonNull"); + + // -- Validation result types -- + static final ClassDesc CD_JtdValidationError = ClassDesc.of("json.java21.jtd.JtdValidationError"); + static final ClassDesc CD_JtdValidationResult = ClassDesc.of("json.java21.jtd.JtdValidationResult"); + static final ClassDesc CD_JtdValidator = ClassDesc.of("json.java21.jtd.JtdValidator"); + + // -- Common method type descriptors -- + static final MethodTypeDesc MTD_String = MethodTypeDesc.of(CD_String); + static final MethodTypeDesc MTD_boolean = MethodTypeDesc.of(ConstantDescs.CD_boolean); + static final MethodTypeDesc MTD_double = MethodTypeDesc.of(ConstantDescs.CD_double); + static final MethodTypeDesc MTD_long = MethodTypeDesc.of(ConstantDescs.CD_long); + static final MethodTypeDesc MTD_int = MethodTypeDesc.of(ConstantDescs.CD_int); + static final MethodTypeDesc MTD_boolean_Object = MethodTypeDesc.of(ConstantDescs.CD_boolean, CD_Object); + static final MethodTypeDesc MTD_Object_Object = MethodTypeDesc.of(CD_Object, CD_Object); + static final MethodTypeDesc MTD_Object_int = MethodTypeDesc.of(CD_Object, ConstantDescs.CD_int); + static final MethodTypeDesc MTD_boolean_CharSequence = MethodTypeDesc.of(ConstantDescs.CD_boolean, CD_CharSequence); + static final MethodTypeDesc MTD_String_String = MethodTypeDesc.of(CD_String, CD_String); + static final MethodTypeDesc MTD_String_int = MethodTypeDesc.of(CD_String, ConstantDescs.CD_int); + static final MethodTypeDesc MTD_String_CharSeq_CharSeq = MethodTypeDesc.of(CD_String, CD_CharSequence, CD_CharSequence); + static final MethodTypeDesc MTD_Map = MethodTypeDesc.of(CD_Map); + static final MethodTypeDesc MTD_List = MethodTypeDesc.of(CD_List); + static final MethodTypeDesc MTD_Set = MethodTypeDesc.of(CD_Set); + static final MethodTypeDesc MTD_Iterator = MethodTypeDesc.of(CD_Iterator); + static final MethodTypeDesc MTD_Object = MethodTypeDesc.of(CD_Object); + static final MethodTypeDesc MTD_double_double = MethodTypeDesc.of(ConstantDescs.CD_double, ConstantDescs.CD_double); + static final MethodTypeDesc MTD_Pattern_String = MethodTypeDesc.of(CD_Pattern, CD_String); + static final MethodTypeDesc MTD_Matcher_CharSequence = MethodTypeDesc.of(CD_Matcher, CD_CharSequence); + static final MethodTypeDesc MTD_OffsetDateTime_CharSeq_DTF = MethodTypeDesc.of(CD_OffsetDateTime, CD_CharSequence, CD_DateTimeFormatter); + static final MethodTypeDesc MTD_void_String_String = MethodTypeDesc.of(ConstantDescs.CD_void, CD_String, CD_String); + static final MethodTypeDesc MTD_JtdValidationResult = MethodTypeDesc.of(CD_JtdValidationResult); + static final MethodTypeDesc MTD_JtdValidationResult_List = MethodTypeDesc.of(CD_JtdValidationResult, CD_List); + static final MethodTypeDesc MTD_JtdValidationResult_JsonValue = MethodTypeDesc.of(CD_JtdValidationResult, CD_JsonValue); + static final MethodTypeDesc MTD_void_String = MethodTypeDesc.of(ConstantDescs.CD_void, CD_String); +} diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitDiscriminator.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitDiscriminator.java new file mode 100644 index 0000000..f49de7b --- /dev/null +++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitDiscriminator.java @@ -0,0 +1,193 @@ +package json.java21.jtd.codegen; + +import java.lang.classfile.CodeBuilder; +import java.lang.classfile.TypeKind; +import java.lang.constant.ConstantDescs; + +import json.java21.jtd.JtdSchema; + +import static json.java21.jtd.codegen.Descriptors.*; + +/// Emits bytecode for JTD Discriminator schema (tagged union). +final class EmitDiscriminator { + + private EmitDiscriminator() {} + + static void emit(CodeBuilder cob, JtdSchema.DiscriminatorSchema d, + int instSlot, int errSlot, + String instPath, String schemaPath) { + var end = cob.newLabel(); + + // Step 1: must be object + var step1Fail = cob.newLabel(); + cob.aload(instSlot); + cob.instanceOf(CD_JsonObject); + cob.ifeq(step1Fail); + + cob.aload(instSlot); + cob.checkcast(CD_JsonObject); + cob.invokeinterface(CD_JsonObject, "members", MTD_Map); + int mapSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(mapSlot); + + // Step 2: tag must exist + var step2Fail = cob.newLabel(); + cob.aload(mapSlot); + cob.ldc(d.discriminator()); + cob.invokeinterface(CD_Map, "containsKey", MTD_boolean_Object); + cob.ifeq(step2Fail); + + cob.aload(mapSlot); + cob.ldc(d.discriminator()); + cob.invokeinterface(CD_Map, "get", MTD_Object_Object); + cob.checkcast(CD_JsonValue); + int tagValSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(tagValSlot); + + // Step 3: tag must be string + var step3Fail = cob.newLabel(); + cob.aload(tagValSlot); + cob.instanceOf(CD_JsonString); + cob.ifeq(step3Fail); + + cob.aload(tagValSlot); + cob.checkcast(CD_JsonString); + cob.invokeinterface(CD_JsonString, "string", MTD_String); + int tagStrSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(tagStrSlot); + + // Step 4: dispatch to variants + for (final var entry : d.mapping().entrySet()) { + final var tagValue = entry.getKey(); + final var variantSchema = entry.getValue(); + var nextVariant = cob.newLabel(); + + cob.aload(tagStrSlot); + cob.ldc(tagValue); + cob.invokevirtual(CD_String, "equals", MTD_boolean_Object); + cob.ifeq(nextVariant); + + if (variantSchema instanceof JtdSchema.PropertiesSchema props) { + EmitProperties.emit(cob, props, instSlot, errSlot, instPath, + schemaPath + "/mapping/" + tagValue, d.discriminator()); + } else { + EmitNode.emit(cob, variantSchema, instSlot, errSlot, instPath, + schemaPath + "/mapping/" + tagValue); + } + cob.goto_(end); + + cob.labelBinding(nextVariant); + } + + // Step 5: tag not in mapping + EmitError.addError(cob, errSlot, + instPath + "/" + d.discriminator(), schemaPath + "/mapping"); + cob.goto_(end); + + // Error paths + cob.labelBinding(step1Fail); + EmitError.addError(cob, errSlot, instPath, schemaPath + "/discriminator"); + cob.goto_(end); + + cob.labelBinding(step2Fail); + EmitError.addError(cob, errSlot, instPath, schemaPath + "/discriminator"); + cob.goto_(end); + + cob.labelBinding(step3Fail); + EmitError.addError(cob, errSlot, + instPath + "/" + d.discriminator(), schemaPath + "/discriminator"); + + cob.labelBinding(end); + } + + /// Dynamic-path variant: parent instancePath from local variable. + static void emitDynamic(CodeBuilder cob, JtdSchema.DiscriminatorSchema d, + int instSlot, int errSlot, + int pathSlot, String schemaPath) { + var end = cob.newLabel(); + + var step1Fail = cob.newLabel(); + cob.aload(instSlot); + cob.instanceOf(CD_JsonObject); + cob.ifeq(step1Fail); + + cob.aload(instSlot); + cob.checkcast(CD_JsonObject); + cob.invokeinterface(CD_JsonObject, "members", MTD_Map); + int mapSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(mapSlot); + + var step2Fail = cob.newLabel(); + cob.aload(mapSlot); + cob.ldc(d.discriminator()); + cob.invokeinterface(CD_Map, "containsKey", MTD_boolean_Object); + cob.ifeq(step2Fail); + + cob.aload(mapSlot); + cob.ldc(d.discriminator()); + cob.invokeinterface(CD_Map, "get", MTD_Object_Object); + cob.checkcast(CD_JsonValue); + int tagValSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(tagValSlot); + + var step3Fail = cob.newLabel(); + cob.aload(tagValSlot); + cob.instanceOf(CD_JsonString); + cob.ifeq(step3Fail); + + cob.aload(tagValSlot); + cob.checkcast(CD_JsonString); + cob.invokeinterface(CD_JsonString, "string", MTD_String); + int tagStrSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(tagStrSlot); + + for (final var entry : d.mapping().entrySet()) { + final var tagValue = entry.getKey(); + final var variantSchema = entry.getValue(); + var nextVariant = cob.newLabel(); + + cob.aload(tagStrSlot); + cob.ldc(tagValue); + cob.invokevirtual(CD_String, "equals", MTD_boolean_Object); + cob.ifeq(nextVariant); + + if (variantSchema instanceof JtdSchema.PropertiesSchema props) { + EmitProperties.emitDynamic(cob, props, instSlot, errSlot, pathSlot, + schemaPath + "/mapping/" + tagValue, d.discriminator()); + } else { + EmitNode.emitDynamic(cob, variantSchema, instSlot, errSlot, pathSlot, + schemaPath + "/mapping/" + tagValue); + } + cob.goto_(end); + + cob.labelBinding(nextVariant); + } + + // tag not in mapping: error at pathSlot + "/" + discriminator + cob.aload(pathSlot); + cob.ldc("/" + d.discriminator()); + cob.invokevirtual(CD_String, "concat", MTD_String_String); + int tagPathSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(tagPathSlot); + EmitError.addErrorDynamic(cob, errSlot, tagPathSlot, schemaPath + "/mapping"); + cob.goto_(end); + + cob.labelBinding(step1Fail); + EmitError.addErrorDynamic(cob, errSlot, pathSlot, schemaPath + "/discriminator"); + cob.goto_(end); + + cob.labelBinding(step2Fail); + EmitError.addErrorDynamic(cob, errSlot, pathSlot, schemaPath + "/discriminator"); + cob.goto_(end); + + cob.labelBinding(step3Fail); + cob.aload(pathSlot); + cob.ldc("/" + d.discriminator()); + cob.invokevirtual(CD_String, "concat", MTD_String_String); + int tagPath2Slot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(tagPath2Slot); + EmitError.addErrorDynamic(cob, errSlot, tagPath2Slot, schemaPath + "/discriminator"); + + cob.labelBinding(end); + } +} diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitElements.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitElements.java new file mode 100644 index 0000000..9ded40c --- /dev/null +++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitElements.java @@ -0,0 +1,149 @@ +package json.java21.jtd.codegen; + +import java.lang.classfile.CodeBuilder; +import java.lang.classfile.TypeKind; + +import json.java21.jtd.JtdSchema; + +import static json.java21.jtd.codegen.Descriptors.*; + +/// Emits bytecode for JTD Elements schema (array validation). +final class EmitElements { + + private EmitElements() {} + + static void emit(CodeBuilder cob, JtdSchema.ElementsSchema e, + int instSlot, int errSlot, + String instPath, String schemaPath) { + var ok = cob.newLabel(); + var fail = cob.newLabel(); + + cob.aload(instSlot); + cob.instanceOf(CD_JsonArray); + cob.ifeq(fail); + + emitLoop(cob, e, instSlot, errSlot, instPath + "/", schemaPath + "/elements"); + + cob.goto_(ok); + + cob.labelBinding(fail); + EmitError.addError(cob, errSlot, instPath, schemaPath + "/elements"); + cob.labelBinding(ok); + } + + /// Dynamic-path variant: parent instancePath comes from a local variable. + static void emitDynamic(CodeBuilder cob, JtdSchema.ElementsSchema e, + int instSlot, int errSlot, + int pathSlot, String schemaPath) { + var ok = cob.newLabel(); + var fail = cob.newLabel(); + + cob.aload(instSlot); + cob.instanceOf(CD_JsonArray); + cob.ifeq(fail); + + // Build prefix: parentPath + "/" + cob.aload(pathSlot); + cob.ldc("/"); + cob.invokevirtual(CD_String, "concat", MTD_String_String); + int prefixSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(prefixSlot); + + emitLoopDynamic(cob, e, instSlot, errSlot, prefixSlot, schemaPath + "/elements"); + + cob.goto_(ok); + + cob.labelBinding(fail); + EmitError.addErrorDynamic(cob, errSlot, pathSlot, schemaPath + "/elements"); + cob.labelBinding(ok); + } + + /// Shared loop logic: iterates array elements and validates each. + /// `prefix` is a compile-time string like "instPath/". + private static void emitLoop(CodeBuilder cob, JtdSchema.ElementsSchema e, + int instSlot, int errSlot, + String prefix, String childSchemaPath) { + cob.aload(instSlot); + cob.checkcast(CD_JsonArray); + cob.invokeinterface(CD_JsonArray, "elements", MTD_List); + int listSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(listSlot); + + cob.aload(listSlot); + cob.invokeinterface(CD_List, "size", MTD_int); + int sizeSlot = cob.allocateLocal(TypeKind.INT); + cob.istore(sizeSlot); + + int iSlot = cob.allocateLocal(TypeKind.INT); + cob.iconst_0(); + cob.istore(iSlot); + + cob.ldc(prefix); + int prefixSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(prefixSlot); + + var loopStart = cob.newLabel(); + var loopEnd = cob.newLabel(); + + cob.labelBinding(loopStart); + cob.iload(iSlot); + cob.iload(sizeSlot); + cob.if_icmpge(loopEnd); + + cob.aload(listSlot); + cob.iload(iSlot); + cob.invokeinterface(CD_List, "get", MTD_Object_int); + cob.checkcast(CD_JsonValue); + int elemSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(elemSlot); + + EmitNode.emitDeferredIdx(cob, e.elements(), elemSlot, errSlot, prefixSlot, iSlot, childSchemaPath); + + cob.iinc(iSlot, 1); + cob.goto_(loopStart); + + cob.labelBinding(loopEnd); + } + + /// Loop with dynamic prefix: prefix comes from a local variable. + private static void emitLoopDynamic(CodeBuilder cob, JtdSchema.ElementsSchema e, + int instSlot, int errSlot, + int prefixSlot, String childSchemaPath) { + cob.aload(instSlot); + cob.checkcast(CD_JsonArray); + cob.invokeinterface(CD_JsonArray, "elements", MTD_List); + int listSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(listSlot); + + cob.aload(listSlot); + cob.invokeinterface(CD_List, "size", MTD_int); + int sizeSlot = cob.allocateLocal(TypeKind.INT); + cob.istore(sizeSlot); + + int iSlot = cob.allocateLocal(TypeKind.INT); + cob.iconst_0(); + cob.istore(iSlot); + + var loopStart = cob.newLabel(); + var loopEnd = cob.newLabel(); + + cob.labelBinding(loopStart); + cob.iload(iSlot); + cob.iload(sizeSlot); + cob.if_icmpge(loopEnd); + + cob.aload(listSlot); + cob.iload(iSlot); + cob.invokeinterface(CD_List, "get", MTD_Object_int); + cob.checkcast(CD_JsonValue); + int elemSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(elemSlot); + + EmitNode.emitDeferredIdx(cob, e.elements(), elemSlot, errSlot, prefixSlot, iSlot, childSchemaPath); + + cob.iinc(iSlot, 1); + cob.goto_(loopStart); + + cob.labelBinding(loopEnd); + } +} diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitEnum.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitEnum.java new file mode 100644 index 0000000..9125a51 --- /dev/null +++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitEnum.java @@ -0,0 +1,75 @@ +package json.java21.jtd.codegen; + +import java.lang.classfile.CodeBuilder; +import java.lang.classfile.TypeKind; + +import json.java21.jtd.JtdSchema; + +import static json.java21.jtd.codegen.Descriptors.*; + +/// Emits bytecode for JTD Enum schema checks. +/// +/// Checks: instance is a JsonString whose value is in the enum's value set. +final class EmitEnum { + + private EmitEnum() {} + + static void emit(CodeBuilder cob, JtdSchema.EnumSchema e, + int instSlot, int errSlot, + String instPath, String schemaPath) { + emitEnumCore(cob, e, instSlot, errSlot, schemaPath, + (c, es, sp) -> EmitError.addError(c, es, instPath, sp)); + } + + static void emitDynamic(CodeBuilder cob, JtdSchema.EnumSchema e, + int instSlot, int errSlot, + int pathSlot, String schemaPath) { + emitEnumCore(cob, e, instSlot, errSlot, schemaPath, + (c, es, sp) -> EmitError.addErrorDynamic(c, es, pathSlot, sp)); + } + + static void emitDeferredStr(CodeBuilder cob, JtdSchema.EnumSchema e, + int instSlot, int errSlot, + int parentPathSlot, int segmentSlot, + String schemaPath) { + emitEnumCore(cob, e, instSlot, errSlot, schemaPath, + (c, es, sp) -> EmitError.addErrorDeferred(c, es, parentPathSlot, segmentSlot, sp)); + } + + static void emitDeferredIdx(CodeBuilder cob, JtdSchema.EnumSchema e, + int instSlot, int errSlot, + int prefixSlot, int indexSlot, + String schemaPath) { + emitEnumCore(cob, e, instSlot, errSlot, schemaPath, + (c, es, sp) -> EmitError.addErrorDeferredIdx(c, es, prefixSlot, indexSlot, sp)); + } + + private static void emitEnumCore(CodeBuilder cob, JtdSchema.EnumSchema e, + int instSlot, int errSlot, + String schemaPath, + EmitType.ErrorEmitter onError) { + var ok = cob.newLabel(); + var fail = cob.newLabel(); + + cob.aload(instSlot); + cob.instanceOf(CD_JsonString); + cob.ifeq(fail); + + cob.aload(instSlot); + cob.checkcast(CD_JsonString); + cob.invokeinterface(CD_JsonString, "string", MTD_String); + int strSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(strSlot); + + for (final var val : e.values()) { + cob.aload(strSlot); + cob.ldc(val); + cob.invokevirtual(CD_String, "equals", MTD_boolean_Object); + cob.ifne(ok); + } + + cob.labelBinding(fail); + onError.emit(cob, errSlot, schemaPath + "/enum"); + cob.labelBinding(ok); + } +} diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitError.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitError.java new file mode 100644 index 0000000..d781182 --- /dev/null +++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitError.java @@ -0,0 +1,81 @@ +package json.java21.jtd.codegen; + +import java.lang.classfile.CodeBuilder; +import java.lang.constant.ConstantDescs; + +import static json.java21.jtd.codegen.Descriptors.*; + +/// Emits bytecode to add a [JtdValidationError] to the errors list. +/// +/// Four variants: +/// - [#addError]: both instancePath and schemaPath are compile-time string constants. +/// - [#addErrorDynamic]: instancePath comes from a local variable slot (runtime string). +/// - [#addErrorDeferred]: instancePath is `parentPath.concat(segment)`, only materialized at error time. +/// - [#addErrorDeferredIdx]: instancePath is `prefix.concat(String.valueOf(index))`, only materialized at error time. +final class EmitError { + + private EmitError() {} + + /// Emit: errors.add(new JtdValidationError(instPath, schemaPath)) + /// Both paths are compile-time constants baked into the classfile. + static void addError(CodeBuilder cob, int errSlot, + String instPath, String schemaPath) { + cob.aload(errSlot); + cob.new_(CD_JtdValidationError); + cob.dup(); + cob.ldc(instPath); + cob.ldc(schemaPath); + cob.invokespecial(CD_JtdValidationError, ConstantDescs.INIT_NAME, MTD_void_String_String); + cob.invokevirtual(CD_ArrayList, "add", MTD_boolean_Object); + cob.pop(); + } + + /// Emit: errors.add(new JtdValidationError(localPathSlot, schemaPath)) + /// instancePath comes from a local variable, schemaPath is a constant. + static void addErrorDynamic(CodeBuilder cob, int errSlot, + int pathSlot, String schemaPath) { + cob.aload(errSlot); + cob.new_(CD_JtdValidationError); + cob.dup(); + cob.aload(pathSlot); + cob.ldc(schemaPath); + cob.invokespecial(CD_JtdValidationError, ConstantDescs.INIT_NAME, MTD_void_String_String); + cob.invokevirtual(CD_ArrayList, "add", MTD_boolean_Object); + cob.pop(); + } + + /// Deferred string-segment variant: instancePath = parentPath.concat(segment). + /// Both parentPath and segment are local string variables; concat only happens here. + static void addErrorDeferred(CodeBuilder cob, int errSlot, + int parentPathSlot, int segmentSlot, + String schemaPath) { + cob.aload(errSlot); + cob.new_(CD_JtdValidationError); + cob.dup(); + cob.aload(parentPathSlot); + cob.aload(segmentSlot); + cob.invokevirtual(CD_String, "concat", MTD_String_String); + cob.ldc(schemaPath); + cob.invokespecial(CD_JtdValidationError, ConstantDescs.INIT_NAME, MTD_void_String_String); + cob.invokevirtual(CD_ArrayList, "add", MTD_boolean_Object); + cob.pop(); + } + + /// Deferred index-segment variant: instancePath = prefix.concat(String.valueOf(index)). + /// prefix is a local string variable, index is a local int; only materialized here. + static void addErrorDeferredIdx(CodeBuilder cob, int errSlot, + int prefixSlot, int indexSlot, + String schemaPath) { + cob.aload(errSlot); + cob.new_(CD_JtdValidationError); + cob.dup(); + cob.aload(prefixSlot); + cob.iload(indexSlot); + cob.invokestatic(CD_String, "valueOf", MTD_String_int); + cob.invokevirtual(CD_String, "concat", MTD_String_String); + cob.ldc(schemaPath); + cob.invokespecial(CD_JtdValidationError, ConstantDescs.INIT_NAME, MTD_void_String_String); + cob.invokevirtual(CD_ArrayList, "add", MTD_boolean_Object); + cob.pop(); + } +} diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitNode.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitNode.java new file mode 100644 index 0000000..ca8da61 --- /dev/null +++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitNode.java @@ -0,0 +1,226 @@ +package json.java21.jtd.codegen; + +import java.lang.classfile.CodeBuilder; +import java.lang.classfile.TypeKind; +import java.lang.constant.ConstantDescs; +import java.util.logging.Logger; + +import json.java21.jtd.JtdSchema; + +import static json.java21.jtd.codegen.Descriptors.*; + +/// Dispatches AST node emission to the appropriate emitter. +/// +/// This is the central routing point: each [JtdSchema] variant maps to +/// a specific emitter class. Static-path variants bake the instance path +/// as a string constant; dynamic-path variants read it from a local variable. +final class EmitNode { + + private static final Logger LOG = Logger.getLogger(EmitNode.class.getName()); + + private EmitNode() {} + + /// Emit bytecode for an AST node where instancePath is a compile-time constant. + static void emit(CodeBuilder cob, JtdSchema node, + int instSlot, int errSlot, + String instPath, String schemaPath) { + switch (node) { + case JtdSchema.EmptySchema e -> { /* no check */ } + case JtdSchema.NullableSchema n -> + emitNullable(cob, n, instSlot, errSlot, instPath, schemaPath); + case JtdSchema.RefSchema r -> + emit(cob, r.target(), instSlot, errSlot, instPath, "/definitions/" + r.ref()); + case JtdSchema.TypeSchema t -> + emitType(cob, t, instSlot, errSlot, instPath, schemaPath); + case JtdSchema.EnumSchema e -> + EmitEnum.emit(cob, e, instSlot, errSlot, instPath, schemaPath); + case JtdSchema.ElementsSchema e -> + EmitElements.emit(cob, e, instSlot, errSlot, instPath, schemaPath); + case JtdSchema.PropertiesSchema p -> + EmitProperties.emit(cob, p, instSlot, errSlot, instPath, schemaPath, null); + case JtdSchema.ValuesSchema v -> + EmitValues.emit(cob, v, instSlot, errSlot, instPath, schemaPath); + case JtdSchema.DiscriminatorSchema d -> + EmitDiscriminator.emit(cob, d, instSlot, errSlot, instPath, schemaPath); + } + } + + /// Emit bytecode for an AST node where instancePath is in a local variable. + static void emitDynamic(CodeBuilder cob, JtdSchema node, + int instSlot, int errSlot, + int pathSlot, String schemaPath) { + switch (node) { + case JtdSchema.EmptySchema e -> { /* no check */ } + case JtdSchema.NullableSchema n -> { + var afterNull = cob.newLabel(); + cob.aload(instSlot); + cob.instanceOf(CD_JsonNull); + cob.ifne(afterNull); + emitDynamic(cob, n.wrapped(), instSlot, errSlot, pathSlot, schemaPath); + cob.labelBinding(afterNull); + } + case JtdSchema.RefSchema r -> + emitDynamic(cob, r.target(), instSlot, errSlot, pathSlot, "/definitions/" + r.ref()); + case JtdSchema.TypeSchema t -> + emitTypeDynamic(cob, t, instSlot, errSlot, pathSlot, schemaPath); + case JtdSchema.EnumSchema e -> + EmitEnum.emitDynamic(cob, e, instSlot, errSlot, pathSlot, schemaPath); + case JtdSchema.ElementsSchema e -> + EmitElements.emitDynamic(cob, e, instSlot, errSlot, pathSlot, schemaPath); + case JtdSchema.PropertiesSchema p -> + EmitProperties.emitDynamic(cob, p, instSlot, errSlot, pathSlot, schemaPath, null); + case JtdSchema.ValuesSchema v -> + EmitValues.emitDynamic(cob, v, instSlot, errSlot, pathSlot, schemaPath); + case JtdSchema.DiscriminatorSchema d -> + EmitDiscriminator.emitDynamic(cob, d, instSlot, errSlot, pathSlot, schemaPath); + } + } + + private static void emitNullable(CodeBuilder cob, JtdSchema.NullableSchema n, + int instSlot, int errSlot, + String instPath, String schemaPath) { + var afterNull = cob.newLabel(); + cob.aload(instSlot); + cob.instanceOf(CD_JsonNull); + cob.ifne(afterNull); + emit(cob, n.wrapped(), instSlot, errSlot, instPath, schemaPath); + cob.labelBinding(afterNull); + } + + private static void emitType(CodeBuilder cob, JtdSchema.TypeSchema t, + int instSlot, int errSlot, + String instPath, String schemaPath) { + switch (t.type()) { + case "boolean" -> EmitType.emitBoolean(cob, instSlot, errSlot, instPath, schemaPath); + case "string" -> EmitType.emitString(cob, instSlot, errSlot, instPath, schemaPath); + case "float32", "float64" -> EmitType.emitFloat(cob, instSlot, errSlot, instPath, schemaPath); + case "timestamp" -> EmitType.emitTimestamp(cob, instSlot, errSlot, instPath, schemaPath); + default -> EmitType.emitInt(cob, t.type(), instSlot, errSlot, instPath, schemaPath); + } + } + + private static void emitTypeDynamic(CodeBuilder cob, JtdSchema.TypeSchema t, + int instSlot, int errSlot, + int pathSlot, String schemaPath) { + switch (t.type()) { + case "boolean" -> EmitType.emitBooleanDynamic(cob, instSlot, errSlot, pathSlot, schemaPath); + case "string" -> EmitType.emitStringDynamic(cob, instSlot, errSlot, pathSlot, schemaPath); + case "float32", "float64" -> EmitType.emitFloatDynamic(cob, instSlot, errSlot, pathSlot, schemaPath); + default -> EmitType.emitIntDynamic(cob, t.type(), instSlot, errSlot, pathSlot, schemaPath); + } + } + + // ------------------------------------------------------------------ + // Deferred-path dispatchers: concat only on error for leaf schemas. + // Containers materialize the path once, then delegate to emitDynamic. + // ------------------------------------------------------------------ + + /// Deferred string-segment variant: parentPathSlot + segmentStringSlot. + static void emitDeferredStr(CodeBuilder cob, JtdSchema node, + int instSlot, int errSlot, + int parentPathSlot, int segmentSlot, + String schemaPath) { + switch (node) { + case JtdSchema.EmptySchema _ -> { /* no check */ } + case JtdSchema.NullableSchema n -> { + var afterNull = cob.newLabel(); + cob.aload(instSlot); + cob.instanceOf(CD_JsonNull); + cob.ifne(afterNull); + emitDeferredStr(cob, n.wrapped(), instSlot, errSlot, parentPathSlot, segmentSlot, schemaPath); + cob.labelBinding(afterNull); + } + case JtdSchema.RefSchema r -> + emitDeferredStr(cob, r.target(), instSlot, errSlot, parentPathSlot, segmentSlot, "/definitions/" + r.ref()); + case JtdSchema.TypeSchema t -> + emitTypeDeferredStr(cob, t, instSlot, errSlot, parentPathSlot, segmentSlot, schemaPath); + case JtdSchema.EnumSchema e -> + EmitEnum.emitDeferredStr(cob, e, instSlot, errSlot, parentPathSlot, segmentSlot, schemaPath); + case JtdSchema.ElementsSchema _, + JtdSchema.PropertiesSchema _, + JtdSchema.ValuesSchema _, + JtdSchema.DiscriminatorSchema _ -> { + int pathSlot = materializeStr(cob, parentPathSlot, segmentSlot); + emitDynamic(cob, node, instSlot, errSlot, pathSlot, schemaPath); + } + } + } + + /// Deferred index-segment variant: prefixSlot + indexIntSlot. + static void emitDeferredIdx(CodeBuilder cob, JtdSchema node, + int instSlot, int errSlot, + int prefixSlot, int indexSlot, + String schemaPath) { + switch (node) { + case JtdSchema.EmptySchema _ -> { /* no check */ } + case JtdSchema.NullableSchema n -> { + var afterNull = cob.newLabel(); + cob.aload(instSlot); + cob.instanceOf(CD_JsonNull); + cob.ifne(afterNull); + emitDeferredIdx(cob, n.wrapped(), instSlot, errSlot, prefixSlot, indexSlot, schemaPath); + cob.labelBinding(afterNull); + } + case JtdSchema.RefSchema r -> + emitDeferredIdx(cob, r.target(), instSlot, errSlot, prefixSlot, indexSlot, "/definitions/" + r.ref()); + case JtdSchema.TypeSchema t -> + emitTypeDeferredIdx(cob, t, instSlot, errSlot, prefixSlot, indexSlot, schemaPath); + case JtdSchema.EnumSchema e -> + EmitEnum.emitDeferredIdx(cob, e, instSlot, errSlot, prefixSlot, indexSlot, schemaPath); + case JtdSchema.ElementsSchema _, + JtdSchema.PropertiesSchema _, + JtdSchema.ValuesSchema _, + JtdSchema.DiscriminatorSchema _ -> { + int pathSlot = materializeIdx(cob, prefixSlot, indexSlot); + emitDynamic(cob, node, instSlot, errSlot, pathSlot, schemaPath); + } + } + } + + private static void emitTypeDeferredStr(CodeBuilder cob, JtdSchema.TypeSchema t, + int instSlot, int errSlot, + int parentPathSlot, int segmentSlot, + String schemaPath) { + switch (t.type()) { + case "boolean" -> EmitType.emitBooleanDeferredStr(cob, instSlot, errSlot, parentPathSlot, segmentSlot, schemaPath); + case "string" -> EmitType.emitStringDeferredStr(cob, instSlot, errSlot, parentPathSlot, segmentSlot, schemaPath); + case "float32", "float64" -> EmitType.emitFloatDeferredStr(cob, instSlot, errSlot, parentPathSlot, segmentSlot, schemaPath); + case "timestamp" -> EmitType.emitTimestampDeferredStr(cob, instSlot, errSlot, parentPathSlot, segmentSlot, schemaPath); + default -> EmitType.emitIntDeferredStr(cob, t.type(), instSlot, errSlot, parentPathSlot, segmentSlot, schemaPath); + } + } + + private static void emitTypeDeferredIdx(CodeBuilder cob, JtdSchema.TypeSchema t, + int instSlot, int errSlot, + int prefixSlot, int indexSlot, + String schemaPath) { + switch (t.type()) { + case "boolean" -> EmitType.emitBooleanDeferredIdx(cob, instSlot, errSlot, prefixSlot, indexSlot, schemaPath); + case "string" -> EmitType.emitStringDeferredIdx(cob, instSlot, errSlot, prefixSlot, indexSlot, schemaPath); + case "float32", "float64" -> EmitType.emitFloatDeferredIdx(cob, instSlot, errSlot, prefixSlot, indexSlot, schemaPath); + case "timestamp" -> EmitType.emitTimestampDeferredIdx(cob, instSlot, errSlot, prefixSlot, indexSlot, schemaPath); + default -> EmitType.emitIntDeferredIdx(cob, t.type(), instSlot, errSlot, prefixSlot, indexSlot, schemaPath); + } + } + + /// Materialize: parentPath.concat(segment) → new local slot. + private static int materializeStr(CodeBuilder cob, int parentPathSlot, int segmentSlot) { + cob.aload(parentPathSlot); + cob.aload(segmentSlot); + cob.invokevirtual(CD_String, "concat", MTD_String_String); + int slot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(slot); + return slot; + } + + /// Materialize: prefix.concat(String.valueOf(index)) → new local slot. + private static int materializeIdx(CodeBuilder cob, int prefixSlot, int indexSlot) { + cob.aload(prefixSlot); + cob.iload(indexSlot); + cob.invokestatic(CD_String, "valueOf", MTD_String_int); + cob.invokevirtual(CD_String, "concat", MTD_String_String); + int slot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(slot); + return slot; + } +} diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitProperties.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitProperties.java new file mode 100644 index 0000000..44e8e05 --- /dev/null +++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitProperties.java @@ -0,0 +1,297 @@ +package json.java21.jtd.codegen; + +import java.lang.classfile.CodeBuilder; +import java.lang.classfile.Label; +import java.lang.classfile.TypeKind; + +import json.java21.jtd.JtdSchema; + +import static json.java21.jtd.codegen.Descriptors.*; + +/// Emits bytecode for JTD Properties schema (object validation). +/// +/// Two variants: +/// - [#emit]: instance path is a compile-time constant. +/// - [#emitDynamic]: instance path is in a local variable (used inside loops). +final class EmitProperties { + + private EmitProperties() {} + + static void emit(CodeBuilder cob, JtdSchema.PropertiesSchema p, + int instSlot, int errSlot, + String instPath, String schemaPath, + String discriminatorTag) { + var ok = cob.newLabel(); + var fail = cob.newLabel(); + + cob.aload(instSlot); + cob.instanceOf(CD_JsonObject); + cob.ifeq(fail); + + cob.aload(instSlot); + cob.checkcast(CD_JsonObject); + cob.invokeinterface(CD_JsonObject, "members", MTD_Map); + int mapSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(mapSlot); + + for (final var entry : p.properties().entrySet()) { + final var key = entry.getKey(); + var present = cob.newLabel(); + cob.aload(mapSlot); + cob.ldc(key); + cob.invokeinterface(CD_Map, "containsKey", MTD_boolean_Object); + cob.ifne(present); + EmitError.addError(cob, errSlot, instPath, schemaPath + "/properties/" + key); + cob.labelBinding(present); + } + + if (!p.additionalProperties()) { + emitAdditionalCheck(cob, p, mapSlot, errSlot, instPath, schemaPath, discriminatorTag); + } + + for (final var entry : p.properties().entrySet()) { + final var key = entry.getKey(); + if (key.equals(discriminatorTag)) continue; + emitPropertyChild(cob, entry.getValue(), mapSlot, errSlot, key, + instPath + "/" + key, schemaPath + "/properties/" + key); + } + + for (final var entry : p.optionalProperties().entrySet()) { + final var key = entry.getKey(); + if (key.equals(discriminatorTag)) continue; + emitPropertyChild(cob, entry.getValue(), mapSlot, errSlot, key, + instPath + "/" + key, schemaPath + "/optionalProperties/" + key); + } + + cob.goto_(ok); + + cob.labelBinding(fail); + final var guardSuffix = p.properties().isEmpty() ? "/optionalProperties" : "/properties"; + EmitError.addError(cob, errSlot, instPath, schemaPath + guardSuffix); + cob.labelBinding(ok); + } + + /// Dynamic-path variant: parent instancePath comes from a local variable. + static void emitDynamic(CodeBuilder cob, JtdSchema.PropertiesSchema p, + int instSlot, int errSlot, + int pathSlot, String schemaPath, + String discriminatorTag) { + var ok = cob.newLabel(); + var fail = cob.newLabel(); + + cob.aload(instSlot); + cob.instanceOf(CD_JsonObject); + cob.ifeq(fail); + + cob.aload(instSlot); + cob.checkcast(CD_JsonObject); + cob.invokeinterface(CD_JsonObject, "members", MTD_Map); + int mapSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(mapSlot); + + // Missing required keys: error path = pathSlot + "/properties/" + key + for (final var entry : p.properties().entrySet()) { + final var key = entry.getKey(); + var present = cob.newLabel(); + cob.aload(mapSlot); + cob.ldc(key); + cob.invokeinterface(CD_Map, "containsKey", MTD_boolean_Object); + cob.ifne(present); + EmitError.addErrorDynamic(cob, errSlot, pathSlot, schemaPath + "/properties/" + key); + cob.labelBinding(present); + } + + if (!p.additionalProperties()) { + emitAdditionalCheckDynamic(cob, p, mapSlot, errSlot, pathSlot, schemaPath, discriminatorTag); + } + + // Child property values: build child path = pathSlot + "/" + key + for (final var entry : p.properties().entrySet()) { + final var key = entry.getKey(); + if (key.equals(discriminatorTag)) continue; + emitPropertyChildDynamic(cob, entry.getValue(), mapSlot, errSlot, pathSlot, key, + schemaPath + "/properties/" + key); + } + + for (final var entry : p.optionalProperties().entrySet()) { + final var key = entry.getKey(); + if (key.equals(discriminatorTag)) continue; + emitPropertyChildDynamic(cob, entry.getValue(), mapSlot, errSlot, pathSlot, key, + schemaPath + "/optionalProperties/" + key); + } + + cob.goto_(ok); + + cob.labelBinding(fail); + final var dynGuardSuffix = p.properties().isEmpty() ? "/optionalProperties" : "/properties"; + EmitError.addErrorDynamic(cob, errSlot, pathSlot, schemaPath + dynGuardSuffix); + cob.labelBinding(ok); + } + + private static void emitPropertyChild(CodeBuilder cob, JtdSchema childSchema, + int mapSlot, int errSlot, String key, + String childInstPath, String childSchemaPath) { + var absent = cob.newLabel(); + var after = cob.newLabel(); + + cob.aload(mapSlot); + cob.ldc(key); + cob.invokeinterface(CD_Map, "get", MTD_Object_Object); + cob.dup(); + cob.ifnull(absent); + cob.checkcast(CD_JsonValue); + int childSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(childSlot); + EmitNode.emit(cob, childSchema, childSlot, errSlot, childInstPath, childSchemaPath); + cob.goto_(after); + + cob.labelBinding(absent); + cob.pop(); + cob.labelBinding(after); + } + + /// Dynamic child: uses deferred path for leaf schemas, materialized path for containers. + private static void emitPropertyChildDynamic(CodeBuilder cob, JtdSchema childSchema, + int mapSlot, int errSlot, + int parentPathSlot, String key, + String childSchemaPath) { + var absent = cob.newLabel(); + var after = cob.newLabel(); + + cob.aload(mapSlot); + cob.ldc(key); + cob.invokeinterface(CD_Map, "get", MTD_Object_Object); + cob.dup(); + cob.ifnull(absent); + cob.checkcast(CD_JsonValue); + int childSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(childSlot); + + cob.ldc("/" + key); + int segmentSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(segmentSlot); + + EmitNode.emitDeferredStr(cob, childSchema, childSlot, errSlot, + parentPathSlot, segmentSlot, childSchemaPath); + cob.goto_(after); + + cob.labelBinding(absent); + cob.pop(); + cob.labelBinding(after); + } + + private static void emitAdditionalCheck(CodeBuilder cob, JtdSchema.PropertiesSchema p, + int mapSlot, int errSlot, + String instPath, String schemaPath, + String discriminatorTag) { + cob.aload(mapSlot); + cob.invokeinterface(CD_Map, "keySet", MTD_Set); + cob.invokeinterface(CD_Set, "iterator", MTD_Iterator); + int iterSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(iterSlot); + + var loopStart = cob.newLabel(); + var loopEnd = cob.newLabel(); + + cob.labelBinding(loopStart); + cob.aload(iterSlot); + cob.invokeinterface(CD_Iterator, "hasNext", MTD_boolean); + cob.ifeq(loopEnd); + + cob.aload(iterSlot); + cob.invokeinterface(CD_Iterator, "next", MTD_Object); + cob.checkcast(CD_String); + int keySlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(keySlot); + + var known = cob.newLabel(); + emitKnownKeyChecks(cob, p, keySlot, known, discriminatorTag); + + cob.aload(errSlot); + cob.new_(CD_JtdValidationError); + cob.dup(); + cob.ldc(instPath + "/"); + cob.aload(keySlot); + cob.invokevirtual(CD_String, "concat", MTD_String_String); + cob.ldc(schemaPath); + cob.invokespecial(CD_JtdValidationError, "", MTD_void_String_String); + cob.invokevirtual(CD_ArrayList, "add", MTD_boolean_Object); + cob.pop(); + + cob.labelBinding(known); + cob.goto_(loopStart); + + cob.labelBinding(loopEnd); + } + + /// Dynamic-path additional properties check: parent path from local variable. + private static void emitAdditionalCheckDynamic(CodeBuilder cob, JtdSchema.PropertiesSchema p, + int mapSlot, int errSlot, + int parentPathSlot, String schemaPath, + String discriminatorTag) { + cob.aload(mapSlot); + cob.invokeinterface(CD_Map, "keySet", MTD_Set); + cob.invokeinterface(CD_Set, "iterator", MTD_Iterator); + int iterSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(iterSlot); + + var loopStart = cob.newLabel(); + var loopEnd = cob.newLabel(); + + cob.labelBinding(loopStart); + cob.aload(iterSlot); + cob.invokeinterface(CD_Iterator, "hasNext", MTD_boolean); + cob.ifeq(loopEnd); + + cob.aload(iterSlot); + cob.invokeinterface(CD_Iterator, "next", MTD_Object); + cob.checkcast(CD_String); + int keySlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(keySlot); + + var known = cob.newLabel(); + emitKnownKeyChecks(cob, p, keySlot, known, discriminatorTag); + + // Build error path: parentPath + "/" + key + cob.aload(errSlot); + cob.new_(CD_JtdValidationError); + cob.dup(); + cob.aload(parentPathSlot); + cob.ldc("/"); + cob.invokevirtual(CD_String, "concat", MTD_String_String); + cob.aload(keySlot); + cob.invokevirtual(CD_String, "concat", MTD_String_String); + cob.ldc(schemaPath); + cob.invokespecial(CD_JtdValidationError, "", MTD_void_String_String); + cob.invokevirtual(CD_ArrayList, "add", MTD_boolean_Object); + cob.pop(); + + cob.labelBinding(known); + cob.goto_(loopStart); + + cob.labelBinding(loopEnd); + } + + private static void emitKnownKeyChecks(CodeBuilder cob, JtdSchema.PropertiesSchema p, + int keySlot, Label known, + String discriminatorTag) { + for (final var k : p.properties().keySet()) { + cob.aload(keySlot); + cob.ldc(k); + cob.invokevirtual(CD_String, "equals", MTD_boolean_Object); + cob.ifne(known); + } + for (final var k : p.optionalProperties().keySet()) { + cob.aload(keySlot); + cob.ldc(k); + cob.invokevirtual(CD_String, "equals", MTD_boolean_Object); + cob.ifne(known); + } + if (discriminatorTag != null) { + cob.aload(keySlot); + cob.ldc(discriminatorTag); + cob.invokevirtual(CD_String, "equals", MTD_boolean_Object); + cob.ifne(known); + } + } +} diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitScaffold.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitScaffold.java new file mode 100644 index 0000000..cb066ab --- /dev/null +++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitScaffold.java @@ -0,0 +1,77 @@ +package json.java21.jtd.codegen; + +import java.lang.classfile.*; +import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDescs; + +import json.java21.jtd.JtdSchema; + +import static json.java21.jtd.codegen.Descriptors.*; + +/// Emits the class skeleton: constructor, toString, and the validate method shell. +/// +/// The validate method delegates to [EmitNode.emit] for the actual schema logic. +final class EmitScaffold { + + private EmitScaffold() {} + + static void emitConstructor(ClassBuilder clb, ClassDesc self) { + clb.withMethodBody(ConstantDescs.INIT_NAME, + MTD_void_String, + ClassFile.ACC_PUBLIC, + cob -> { + cob.aload(0); + cob.invokespecial(CD_Object, ConstantDescs.INIT_NAME, ConstantDescs.MTD_void); + cob.aload(0); + cob.aload(1); + cob.putfield(self, "schemaJson", CD_String); + cob.return_(); + }); + } + + static void emitToString(ClassBuilder clb, ClassDesc self) { + clb.withMethodBody("toString", + MTD_String, + ClassFile.ACC_PUBLIC, + cob -> { + cob.aload(0); + cob.getfield(self, "schemaJson", CD_String); + cob.areturn(); + }); + } + + /// Emits: public JtdValidationResult validate(JsonValue instance) + /// + /// Layout: local 0 = this, local 1 = instance, local 2 = errors (ArrayList) + static void emitValidateMethod(ClassBuilder clb, ClassDesc self, JtdSchema schema) { + clb.withMethodBody("validate", + MTD_JtdValidationResult_JsonValue, + ClassFile.ACC_PUBLIC, + cob -> { + // local 0 = this, local 1 = instance + // allocate local 2 = errors (ArrayList) + int errSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.new_(CD_ArrayList); + cob.dup(); + cob.invokespecial(CD_ArrayList, ConstantDescs.INIT_NAME, ConstantDescs.MTD_void); + cob.astore(errSlot); + + // Emit the root schema validation + EmitNode.emit(cob, schema, 1, errSlot, "", ""); + + // Build result: errors.isEmpty() ? success() : failure(errors) + cob.aload(errSlot); + cob.invokevirtual(CD_ArrayList, "isEmpty", MTD_boolean); + var failLabel = cob.newLabel(); + cob.ifeq(failLabel); + + cob.invokestatic(CD_JtdValidationResult, "success", MTD_JtdValidationResult); + cob.areturn(); + + cob.labelBinding(failLabel); + cob.aload(errSlot); + cob.invokestatic(CD_JtdValidationResult, "failure", MTD_JtdValidationResult_List); + cob.areturn(); + }); + } +} diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitType.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitType.java new file mode 100644 index 0000000..a430428 --- /dev/null +++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitType.java @@ -0,0 +1,310 @@ +package json.java21.jtd.codegen; + +import java.lang.classfile.CodeBuilder; +import java.lang.classfile.TypeKind; +import java.lang.constant.ConstantDescs; + +import static json.java21.jtd.codegen.Descriptors.*; + +/// Emits bytecode for JTD Type schema checks (RFC 8927 §2.2.3). +/// +/// Each method is independently testable: feed a small `{"type":"X"}` AST +/// fragment and verify the generated validator produces correct error pairs. +final class EmitType { + + private EmitType() {} + + /// Emit: if (!(instance instanceof JsonBoolean)) addError + static void emitBoolean(CodeBuilder cob, int instSlot, int errSlot, + String instPath, String schemaPath) { + var ok = cob.newLabel(); + cob.aload(instSlot); + cob.instanceOf(CD_JsonBoolean); + cob.ifne(ok); + EmitError.addError(cob, errSlot, instPath, schemaPath + "/type"); + cob.labelBinding(ok); + } + + /// Emit: if (!(instance instanceof JsonString)) addError + static void emitString(CodeBuilder cob, int instSlot, int errSlot, + String instPath, String schemaPath) { + var ok = cob.newLabel(); + cob.aload(instSlot); + cob.instanceOf(CD_JsonString); + cob.ifne(ok); + EmitError.addError(cob, errSlot, instPath, schemaPath + "/type"); + cob.labelBinding(ok); + } + + /// Emit: if (!(instance instanceof JsonNumber)) addError + static void emitFloat(CodeBuilder cob, int instSlot, int errSlot, + String instPath, String schemaPath) { + var ok = cob.newLabel(); + cob.aload(instSlot); + cob.instanceOf(CD_JsonNumber); + cob.ifne(ok); + EmitError.addError(cob, errSlot, instPath, schemaPath + "/type"); + cob.labelBinding(ok); + } + + /// Emit integer range check for int8..uint32. + /// + /// Checks: instance is JsonNumber, zero fractional part, and in [min, max]. + static void emitInt(CodeBuilder cob, String type, int instSlot, int errSlot, + String instPath, String schemaPath) { + emitIntCore(cob, type, instSlot, errSlot, schemaPath, + (c, e, sp) -> EmitError.addError(c, e, instPath, sp)); + } + + /// Emit timestamp check: JsonString + RFC 3339 regex + OffsetDateTime.parse. + static void emitTimestamp(CodeBuilder cob, int instSlot, int errSlot, + String instPath, String schemaPath) { + emitTimestampCore(cob, instSlot, errSlot, schemaPath, + (c, e, sp) -> EmitError.addError(c, e, instPath, sp)); + } + + // ------------------------------------------------------------------ + // Dynamic-path variants (materialized pathSlot) + // ------------------------------------------------------------------ + + static void emitBooleanDynamic(CodeBuilder cob, int instSlot, int errSlot, + int pathSlot, String schemaPath) { + var ok = cob.newLabel(); + cob.aload(instSlot); + cob.instanceOf(CD_JsonBoolean); + cob.ifne(ok); + EmitError.addErrorDynamic(cob, errSlot, pathSlot, schemaPath + "/type"); + cob.labelBinding(ok); + } + + static void emitStringDynamic(CodeBuilder cob, int instSlot, int errSlot, + int pathSlot, String schemaPath) { + var ok = cob.newLabel(); + cob.aload(instSlot); + cob.instanceOf(CD_JsonString); + cob.ifne(ok); + EmitError.addErrorDynamic(cob, errSlot, pathSlot, schemaPath + "/type"); + cob.labelBinding(ok); + } + + static void emitFloatDynamic(CodeBuilder cob, int instSlot, int errSlot, + int pathSlot, String schemaPath) { + var ok = cob.newLabel(); + cob.aload(instSlot); + cob.instanceOf(CD_JsonNumber); + cob.ifne(ok); + EmitError.addErrorDynamic(cob, errSlot, pathSlot, schemaPath + "/type"); + cob.labelBinding(ok); + } + + static void emitIntDynamic(CodeBuilder cob, String type, int instSlot, int errSlot, + int pathSlot, String schemaPath) { + emitIntCore(cob, type, instSlot, errSlot, schemaPath, + (c, e, sp) -> EmitError.addErrorDynamic(c, e, pathSlot, sp)); + } + + // ------------------------------------------------------------------ + // Deferred string-segment variants (parentPathSlot + segmentSlot) + // Concat only on error. + // ------------------------------------------------------------------ + + static void emitBooleanDeferredStr(CodeBuilder cob, int instSlot, int errSlot, + int parentPathSlot, int segmentSlot, + String schemaPath) { + var ok = cob.newLabel(); + cob.aload(instSlot); + cob.instanceOf(CD_JsonBoolean); + cob.ifne(ok); + EmitError.addErrorDeferred(cob, errSlot, parentPathSlot, segmentSlot, schemaPath + "/type"); + cob.labelBinding(ok); + } + + static void emitStringDeferredStr(CodeBuilder cob, int instSlot, int errSlot, + int parentPathSlot, int segmentSlot, + String schemaPath) { + var ok = cob.newLabel(); + cob.aload(instSlot); + cob.instanceOf(CD_JsonString); + cob.ifne(ok); + EmitError.addErrorDeferred(cob, errSlot, parentPathSlot, segmentSlot, schemaPath + "/type"); + cob.labelBinding(ok); + } + + static void emitFloatDeferredStr(CodeBuilder cob, int instSlot, int errSlot, + int parentPathSlot, int segmentSlot, + String schemaPath) { + var ok = cob.newLabel(); + cob.aload(instSlot); + cob.instanceOf(CD_JsonNumber); + cob.ifne(ok); + EmitError.addErrorDeferred(cob, errSlot, parentPathSlot, segmentSlot, schemaPath + "/type"); + cob.labelBinding(ok); + } + + static void emitIntDeferredStr(CodeBuilder cob, String type, int instSlot, int errSlot, + int parentPathSlot, int segmentSlot, + String schemaPath) { + emitIntCore(cob, type, instSlot, errSlot, schemaPath, + (c, e, sp) -> EmitError.addErrorDeferred(c, e, parentPathSlot, segmentSlot, sp)); + } + + static void emitTimestampDeferredStr(CodeBuilder cob, int instSlot, int errSlot, + int parentPathSlot, int segmentSlot, + String schemaPath) { + emitTimestampCore(cob, instSlot, errSlot, schemaPath, + (c, e, sp) -> EmitError.addErrorDeferred(c, e, parentPathSlot, segmentSlot, sp)); + } + + // ------------------------------------------------------------------ + // Deferred index-segment variants (prefixSlot + indexIntSlot) + // Concat + valueOf only on error. + // ------------------------------------------------------------------ + + static void emitBooleanDeferredIdx(CodeBuilder cob, int instSlot, int errSlot, + int prefixSlot, int indexSlot, + String schemaPath) { + var ok = cob.newLabel(); + cob.aload(instSlot); + cob.instanceOf(CD_JsonBoolean); + cob.ifne(ok); + EmitError.addErrorDeferredIdx(cob, errSlot, prefixSlot, indexSlot, schemaPath + "/type"); + cob.labelBinding(ok); + } + + static void emitStringDeferredIdx(CodeBuilder cob, int instSlot, int errSlot, + int prefixSlot, int indexSlot, + String schemaPath) { + var ok = cob.newLabel(); + cob.aload(instSlot); + cob.instanceOf(CD_JsonString); + cob.ifne(ok); + EmitError.addErrorDeferredIdx(cob, errSlot, prefixSlot, indexSlot, schemaPath + "/type"); + cob.labelBinding(ok); + } + + static void emitFloatDeferredIdx(CodeBuilder cob, int instSlot, int errSlot, + int prefixSlot, int indexSlot, + String schemaPath) { + var ok = cob.newLabel(); + cob.aload(instSlot); + cob.instanceOf(CD_JsonNumber); + cob.ifne(ok); + EmitError.addErrorDeferredIdx(cob, errSlot, prefixSlot, indexSlot, schemaPath + "/type"); + cob.labelBinding(ok); + } + + static void emitIntDeferredIdx(CodeBuilder cob, String type, int instSlot, int errSlot, + int prefixSlot, int indexSlot, + String schemaPath) { + emitIntCore(cob, type, instSlot, errSlot, schemaPath, + (c, e, sp) -> EmitError.addErrorDeferredIdx(c, e, prefixSlot, indexSlot, sp)); + } + + static void emitTimestampDeferredIdx(CodeBuilder cob, int instSlot, int errSlot, + int prefixSlot, int indexSlot, + String schemaPath) { + emitTimestampCore(cob, instSlot, errSlot, schemaPath, + (c, e, sp) -> EmitError.addErrorDeferredIdx(c, e, prefixSlot, indexSlot, sp)); + } + + // ------------------------------------------------------------------ + // Shared core logic extracted to avoid duplication + // ------------------------------------------------------------------ + + @FunctionalInterface + interface ErrorEmitter { + void emit(CodeBuilder cob, int errSlot, String schemaPath); + } + + private static void emitIntCore(CodeBuilder cob, String type, + int instSlot, int errSlot, + String schemaPath, ErrorEmitter onError) { + long min, max; + switch (type) { + case "int8" -> { min = -128; max = 127; } + case "uint8" -> { min = 0; max = 255; } + case "int16" -> { min = -32768; max = 32767; } + case "uint16" -> { min = 0; max = 65535; } + case "int32" -> { min = Integer.MIN_VALUE; max = Integer.MAX_VALUE; } + case "uint32" -> { min = 0; max = 4294967295L; } + default -> throw new IllegalArgumentException("Unknown int type: " + type); + } + + var ok = cob.newLabel(); + var fail = cob.newLabel(); + + cob.aload(instSlot); + cob.instanceOf(CD_JsonNumber); + cob.ifeq(fail); + + cob.aload(instSlot); + cob.checkcast(CD_JsonNumber); + cob.invokeinterface(CD_JsonNumber, "toDouble", MTD_double); + int dSlot = cob.allocateLocal(TypeKind.DOUBLE); + cob.dstore(dSlot); + + cob.dload(dSlot); + cob.dload(dSlot); + cob.invokestatic(CD_Math, "floor", MTD_double_double); + cob.dcmpl(); + cob.ifne(fail); + + cob.aload(instSlot); + cob.checkcast(CD_JsonNumber); + cob.invokeinterface(CD_JsonNumber, "toLong", MTD_long); + int lSlot = cob.allocateLocal(TypeKind.LONG); + cob.lstore(lSlot); + + cob.lload(lSlot); + cob.ldc(min); + cob.lcmp(); + cob.iflt(fail); + + cob.lload(lSlot); + cob.ldc(max); + cob.lcmp(); + cob.ifgt(fail); + + cob.goto_(ok); + + cob.labelBinding(fail); + onError.emit(cob, errSlot, schemaPath + "/type"); + cob.labelBinding(ok); + } + + private static void emitTimestampCore(CodeBuilder cob, int instSlot, int errSlot, + String schemaPath, ErrorEmitter onError) { + var ok = cob.newLabel(); + var fail = cob.newLabel(); + + cob.aload(instSlot); + cob.instanceOf(CD_JsonString); + cob.ifeq(fail); + + cob.aload(instSlot); + cob.checkcast(CD_JsonString); + cob.invokeinterface(CD_JsonString, "string", MTD_String); + int strSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(strSlot); + + cob.ldc("^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:(\\d{2}|60)(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2}))$"); + cob.invokestatic(CD_Pattern, "compile", MTD_Pattern_String); + cob.aload(strSlot); + cob.invokevirtual(CD_Pattern, "matcher", MTD_Matcher_CharSequence); + cob.invokevirtual(CD_Matcher, "matches", MTD_boolean); + cob.ifeq(fail); + + cob.aload(strSlot); + cob.ldc(":60"); + cob.ldc(":59"); + cob.invokevirtual(CD_String, "replace", MTD_String_CharSeq_CharSeq); + cob.getstatic(CD_DateTimeFormatter, "ISO_OFFSET_DATE_TIME", CD_DateTimeFormatter); + cob.invokestatic(CD_OffsetDateTime, "parse", MTD_OffsetDateTime_CharSeq_DTF); + cob.pop(); + cob.goto_(ok); + + cob.labelBinding(fail); + onError.emit(cob, errSlot, schemaPath + "/type"); + cob.labelBinding(ok); + } +} diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitValues.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitValues.java new file mode 100644 index 0000000..7883797 --- /dev/null +++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/EmitValues.java @@ -0,0 +1,130 @@ +package json.java21.jtd.codegen; + +import java.lang.classfile.CodeBuilder; +import java.lang.classfile.TypeKind; + +import json.java21.jtd.JtdSchema; + +import static json.java21.jtd.codegen.Descriptors.*; + +/// Emits bytecode for JTD Values schema (object with homogeneous values). +final class EmitValues { + + private EmitValues() {} + + static void emit(CodeBuilder cob, JtdSchema.ValuesSchema v, + int instSlot, int errSlot, + String instPath, String schemaPath) { + var ok = cob.newLabel(); + var fail = cob.newLabel(); + + cob.aload(instSlot); + cob.instanceOf(CD_JsonObject); + cob.ifeq(fail); + + emitLoop(cob, v, instSlot, errSlot, instPath + "/", schemaPath + "/values"); + cob.goto_(ok); + + cob.labelBinding(fail); + EmitError.addError(cob, errSlot, instPath, schemaPath + "/values"); + cob.labelBinding(ok); + } + + /// Dynamic-path variant: parent instancePath from local variable. + static void emitDynamic(CodeBuilder cob, JtdSchema.ValuesSchema v, + int instSlot, int errSlot, + int pathSlot, String schemaPath) { + var ok = cob.newLabel(); + var fail = cob.newLabel(); + + cob.aload(instSlot); + cob.instanceOf(CD_JsonObject); + cob.ifeq(fail); + + // Build prefix: parentPath + "/" + cob.aload(pathSlot); + cob.ldc("/"); + cob.invokevirtual(CD_String, "concat", MTD_String_String); + int prefixSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(prefixSlot); + + emitLoopDynamic(cob, v, instSlot, errSlot, prefixSlot, schemaPath + "/values"); + cob.goto_(ok); + + cob.labelBinding(fail); + EmitError.addErrorDynamic(cob, errSlot, pathSlot, schemaPath + "/values"); + cob.labelBinding(ok); + } + + private static void emitLoop(CodeBuilder cob, JtdSchema.ValuesSchema v, + int instSlot, int errSlot, + String prefix, String childSchemaPath) { + cob.aload(instSlot); + cob.checkcast(CD_JsonObject); + cob.invokeinterface(CD_JsonObject, "members", MTD_Map); + int mapSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(mapSlot); + + emitEntryLoop(cob, v, mapSlot, errSlot, prefix, childSchemaPath, false, -1); + } + + private static void emitLoopDynamic(CodeBuilder cob, JtdSchema.ValuesSchema v, + int instSlot, int errSlot, + int prefixSlot, String childSchemaPath) { + cob.aload(instSlot); + cob.checkcast(CD_JsonObject); + cob.invokeinterface(CD_JsonObject, "members", MTD_Map); + int mapSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(mapSlot); + + emitEntryLoop(cob, v, mapSlot, errSlot, null, childSchemaPath, true, prefixSlot); + } + + private static void emitEntryLoop(CodeBuilder cob, JtdSchema.ValuesSchema v, + int mapSlot, int errSlot, + String staticPrefix, String childSchemaPath, + boolean dynamic, int prefixSlot) { + if (!dynamic) { + cob.ldc(staticPrefix); + prefixSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(prefixSlot); + } + + cob.aload(mapSlot); + cob.invokeinterface(CD_Map, "entrySet", MTD_Set); + cob.invokeinterface(CD_Set, "iterator", MTD_Iterator); + int iterSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(iterSlot); + + var loopStart = cob.newLabel(); + var loopEnd = cob.newLabel(); + + cob.labelBinding(loopStart); + cob.aload(iterSlot); + cob.invokeinterface(CD_Iterator, "hasNext", MTD_boolean); + cob.ifeq(loopEnd); + + cob.aload(iterSlot); + cob.invokeinterface(CD_Iterator, "next", MTD_Object); + cob.checkcast(CD_MapEntry); + int entrySlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(entrySlot); + + cob.aload(entrySlot); + cob.invokeinterface(CD_MapEntry, "getKey", MTD_Object); + cob.checkcast(CD_String); + int keySlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(keySlot); + + cob.aload(entrySlot); + cob.invokeinterface(CD_MapEntry, "getValue", MTD_Object); + cob.checkcast(CD_JsonValue); + int valSlot = cob.allocateLocal(TypeKind.REFERENCE); + cob.astore(valSlot); + + EmitNode.emitDeferredStr(cob, v.values(), valSlot, errSlot, prefixSlot, keySlot, childSchemaPath); + + cob.goto_(loopStart); + cob.labelBinding(loopEnd); + } +} diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/JtdCodegen.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/JtdCodegen.java new file mode 100644 index 0000000..93ce5c0 --- /dev/null +++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/JtdCodegen.java @@ -0,0 +1,73 @@ +package json.java21.jtd.codegen; + +import java.lang.classfile.*; +import java.lang.classfile.attribute.SourceFileAttribute; +import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDescs; +import java.lang.constant.MethodTypeDesc; +import java.lang.invoke.MethodHandles; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; + +import jdk.sandbox.java.util.json.JsonValue; +import json.java21.jtd.*; + +/// Compiles a JTD schema into a bytecode-generated [JtdValidator]. +/// +/// The generated class targets Java 21 (class file version 65) and +/// contains only the checks the schema requires. +/// +/// Entry point for the `JtdValidator.compileGenerated()` reflection call. +public final class JtdCodegen { + + static final Logger LOG = Logger.getLogger(JtdCodegen.class.getName()); + private static final AtomicLong COUNTER = new AtomicLong(); + + private JtdCodegen() {} + + /// Result of compilation including the validator and generated class statistics. + public record CompileResult(JtdValidator validator, int classfileBytes) {} + + /// Public factory invoked by [JtdValidator.compileGenerated] via reflection. + public static JtdValidator compile(JsonValue schema) { + return compileWithStats(schema).validator(); + } + + /// Compiles the schema and returns both the validator and the generated + /// classfile size in bytes. Useful for benchmarking and diagnostics. + public static CompileResult compileWithStats(JsonValue schema) { + final var jtd = new Jtd(); + final var compiled = jtd.compileToSchema(schema); + final var schemaJson = schema.toString(); + + final var className = "json/java21/jtd/codegen/Generated_" + COUNTER.incrementAndGet(); + final var classDesc = ClassDesc.ofInternalName(className); + + LOG.fine(() -> "Generating validator class: " + className); + + final var bytes = ClassFile.of().build(classDesc, clb -> { + clb.withVersion(ClassFile.JAVA_21_VERSION, 0); + clb.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_FINAL); + clb.withSuperclass(Descriptors.CD_Object); + clb.withInterfaceSymbols(Descriptors.CD_JtdValidator); + clb.with(SourceFileAttribute.of("JtdCodegen")); + + clb.withField("schemaJson", Descriptors.CD_String, + ClassFile.ACC_PRIVATE | ClassFile.ACC_FINAL); + + EmitScaffold.emitConstructor(clb, classDesc); + EmitScaffold.emitToString(clb, classDesc); + EmitScaffold.emitValidateMethod(clb, classDesc, compiled); + }); + + try { + final var lookup = MethodHandles.lookup(); + final var clazz = lookup.defineClass(bytes); + final var ctor = clazz.getConstructor(String.class); + final var validator = (JtdValidator) ctor.newInstance(schemaJson); + return new CompileResult(validator, bytes.length); + } catch (Exception e) { + throw new RuntimeException("Failed to load generated validator: " + className, e); + } + } +} diff --git a/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/BenchmarkTest.java b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/BenchmarkTest.java new file mode 100644 index 0000000..5317caa --- /dev/null +++ b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/BenchmarkTest.java @@ -0,0 +1,206 @@ +package json.java21.jtd.codegen; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonValue; +import json.java21.jtd.JtdValidator; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; + +/// Benchmarks codegen vs interpreter paths across schemas of increasing +/// complexity. Reports classfile size and throughput (validations/sec). +/// +/// Not a microbenchmark framework -- results are indicative, not definitive. +/// Run with `-Djava.util.logging.ConsoleHandler.level=INFO` to see output. +class BenchmarkTest extends CodegenTestBase { + + private static final int WARMUP_ITERATIONS = 50_000; + private static final int MEASURED_ITERATIONS = 200_000; + + record Schema(String name, String schemaJson, String validJson, String invalidJson) {} + + static Schema[] schemas() { + return new Schema[] { + new Schema("simple-type", + """ + {"type":"string"}""", + "\"hello\"", + "42"), + + new Schema("enum-5", + """ + {"enum":["alpha","beta","gamma","delta","epsilon"]}""", + "\"gamma\"", + "\"zeta\""), + + new Schema("nullable-int", + """ + {"type":"uint8","nullable":true}""", + "null", + "\"nope\""), + + new Schema("properties-3", + """ + {"properties":{"name":{"type":"string"},"age":{"type":"uint8"},"active":{"type":"boolean"}}}""", + """ + {"name":"Alice","age":30,"active":true}""", + """ + {"name":"Alice","age":"old","active":"yes"}"""), + + new Schema("props-with-optional", + """ + {"properties":{"name":{"type":"string"}},"optionalProperties":{"email":{"type":"string"},"phone":{"type":"string"}}}""", + """ + {"name":"Alice","email":"a@b.com"}""", + """ + {"name":42,"email":123}"""), + + new Schema("elements-of-type", + """ + {"elements":{"type":"string"}}""", + """ + ["a","b","c","d","e"]""", + """ + ["a",1,"b",2,"c"]"""), + + new Schema("values-of-type", + """ + {"values":{"type":"uint32"}}""", + """ + {"x":1,"y":2,"z":3}""", + """ + {"x":1,"y":"bad","z":-1}"""), + + new Schema("nested-elements-of-props", + """ + {"elements":{"properties":{"id":{"type":"uint32"},"label":{"type":"string"}}}}""", + """ + [{"id":1,"label":"a"},{"id":2,"label":"b"},{"id":3,"label":"c"}]""", + """ + [{"id":"x","label":42},{"id":2},{"label":"c","extra":true}]"""), + + new Schema("discriminator-2-variants", + """ + {"discriminator":"kind","mapping":{"dog":{"properties":{"breed":{"type":"string"}}},"cat":{"properties":{"indoor":{"type":"boolean"}}}}}""", + """ + {"kind":"dog","breed":"poodle"}""", + """ + {"kind":"dog","breed":42}"""), + + new Schema("ref-with-definitions", + """ + {"definitions":{"addr":{"properties":{"street":{"type":"string"},"zip":{"type":"string"}}}},"properties":{"home":{"ref":"addr"},"work":{"ref":"addr"}}}""", + """ + {"home":{"street":"1 Main","zip":"12345"},"work":{"street":"2 Oak","zip":"67890"}}""", + """ + {"home":{"street":1,"zip":2},"work":"invalid"}"""), + + new Schema("deep-nesting", + """ + {"elements":{"values":{"properties":{"tags":{"elements":{"type":"string"}},"count":{"type":"uint32"}}}}}""", + """ + [{"a":{"tags":["x","y"],"count":5}},{"b":{"tags":[],"count":0}}]""", + """ + [{"a":{"tags":["x",1],"count":-1}}]"""), + + new Schema("worked-example-rfc8927", + """ + {"properties":{"name":{"type":"string"},"age":{"type":"uint8"},"phones":{"elements":{"properties":{"type":{"enum":["home","work","mobile"]},"number":{"type":"string"}}}},"tags":{"elements":{"type":"string"}}},"optionalProperties":{"email":{"type":"string"},"address":{"properties":{"street":{"type":"string"},"city":{"type":"string"},"zip":{"type":"string"}}}}}""", + """ + {"name":"Alice","age":30,"phones":[{"type":"home","number":"555-1234"},{"type":"work","number":"555-5678"}],"tags":["vip","active"],"email":"alice@example.com","address":{"street":"1 Main","city":"NY","zip":"10001"}}""", + """ + {"name":42,"age":300,"phones":[{"type":"fax","number":123}],"tags":[1,2],"email":false,"address":{"street":1,"city":2,"zip":3},"extra":"bad"}""") + }; + } + + @Test + void benchmarkAll() { + LOG.info("========== JTD Benchmark: Codegen vs Interpreter =========="); + LOG.info(String.format("Warmup: %,d iterations, Measured: %,d iterations", + WARMUP_ITERATIONS, MEASURED_ITERATIONS)); + LOG.info(""); + + final var results = new LinkedHashMap(); + + for (final var s : schemas()) { + LOG.info("--- Schema: " + s.name + " ---"); + LOG.info(" JSON: " + s.schemaJson.substring(0, Math.min(80, s.schemaJson.length())) + "..."); + + final var schema = Json.parse(s.schemaJson); + final var validDoc = Json.parse(s.validJson); + final var invalidDoc = Json.parse(s.invalidJson); + + final var codegenResult = JtdCodegen.compileWithStats(schema); + final var codegen = codegenResult.validator(); + final var classfileBytes = codegenResult.classfileBytes(); + final var interpreter = JtdValidator.compile(schema); + + assertThat(codegen.validate(validDoc).isValid()).isTrue(); + assertThat(codegen.validate(invalidDoc).isValid()).isFalse(); + assertThat(interpreter.validate(validDoc).isValid()).isTrue(); + assertThat(interpreter.validate(invalidDoc).isValid()).isFalse(); + + LOG.info(" Classfile size: " + classfileBytes + " bytes"); + LOG.info(" Schema JSON size: " + s.schemaJson.length() + " chars"); + + final var codegenValidNs = measure(codegen, validDoc); + final var codegenInvalidNs = measure(codegen, invalidDoc); + final var interpValidNs = measure(interpreter, validDoc); + final var interpInvalidNs = measure(interpreter, invalidDoc); + + final var speedupValid = (double) interpValidNs / codegenValidNs; + final var speedupInvalid = (double) interpInvalidNs / codegenInvalidNs; + + LOG.info(String.format(" Valid doc: codegen %,d ns/op, interp %,d ns/op (%.1fx)", + codegenValidNs, interpValidNs, speedupValid)); + LOG.info(String.format(" Invalid doc: codegen %,d ns/op, interp %,d ns/op (%.1fx)", + codegenInvalidNs, interpInvalidNs, speedupInvalid)); + LOG.info(""); + + results.put(s.name, new BenchResult(classfileBytes, s.schemaJson.length(), + codegenValidNs, interpValidNs, codegenInvalidNs, interpInvalidNs)); + } + + LOG.info("========== Summary =========="); + LOG.info(String.format("%-30s %8s %8s | %10s %10s %6s | %10s %10s %6s", + "Schema", "Class B", "JSON Ch", + "CG val ns", "Int val ns", "x", + "CG inv ns", "Int inv ns", "x")); + LOG.info("-".repeat(120)); + + for (final var entry : results.entrySet()) { + final var r = entry.getValue(); + LOG.info(String.format("%-30s %,8d %,8d | %,10d %,10d %5.1fx | %,10d %,10d %5.1fx", + entry.getKey(), r.classfileBytes, r.schemaJsonChars, + r.codegenValidNs, r.interpValidNs, (double) r.interpValidNs / r.codegenValidNs, + r.codegenInvalidNs, r.interpInvalidNs, (double) r.interpInvalidNs / r.codegenInvalidNs)); + } + + LOG.info(""); + final var avgSpeedupValid = results.values().stream() + .mapToDouble(r -> (double) r.interpValidNs / r.codegenValidNs) + .average().orElse(0); + final var avgSpeedupInvalid = results.values().stream() + .mapToDouble(r -> (double) r.interpInvalidNs / r.codegenInvalidNs) + .average().orElse(0); + LOG.info(String.format("Average speedup: valid=%.1fx, invalid=%.1fx", avgSpeedupValid, avgSpeedupInvalid)); + } + + private long measure(JtdValidator validator, JsonValue doc) { + IntStream.range(0, WARMUP_ITERATIONS).forEach(_ -> validator.validate(doc)); + + final var start = System.nanoTime(); + IntStream.range(0, MEASURED_ITERATIONS).forEach(_ -> validator.validate(doc)); + final var elapsed = System.nanoTime() - start; + + return elapsed / MEASURED_ITERATIONS; + } + + record BenchResult(int classfileBytes, int schemaJsonChars, + long codegenValidNs, long interpValidNs, + long codegenInvalidNs, long interpInvalidNs) {} +} diff --git a/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CodegenSpecConformanceTest.java b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CodegenSpecConformanceTest.java new file mode 100644 index 0000000..17a4870 --- /dev/null +++ b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CodegenSpecConformanceTest.java @@ -0,0 +1,92 @@ +package json.java21.jtd.codegen; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonArray; +import jdk.sandbox.java.util.json.JsonObject; +import jdk.sandbox.java.util.json.JsonString; +import jdk.sandbox.java.util.json.JsonValue; +import json.java21.jtd.JtdValidationError; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Comparator; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/// Runs the official json-typedef-spec validation test suite against +/// the codegen path and cross-validates against the interpreter. +/// +/// Test data: `jtd-spec-validation.json` from +/// +class CodegenSpecConformanceTest extends CodegenTestBase { + + static Stream cases() throws IOException { + // Extract test suite from ZIP (same data as IT tests, avoids committing large JSON) + try (final var raw = JtdTestDataExtractor.getValidationTestDataStream()) { + final var jsonText = new String(raw.readAllBytes(), StandardCharsets.UTF_8); + final var root = Json.parse(jsonText); + assert root instanceof JsonObject : "expected top-level object"; + final var obj = (JsonObject) root; + + return obj.members().entrySet().stream() + .map(entry -> Arguments.of( + entry.getKey(), + entry.getValue())); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("cases") + void codegenMatchesSpecSuite(String name, JsonValue caseValue) { + LOG.info("SPEC-CODEGEN: " + name); + + if (name.contains("recursive schema")) { + LOG.info("SKIP: recursive refs require method-call emission (not yet implemented)"); + return; + } + + final var caseObj = (JsonObject) caseValue; + final var schema = caseObj.members().get("schema"); + final var instance = caseObj.members().get("instance"); + final var expectedErrors = (JsonArray) caseObj.members().get("errors"); + + final var codegen = JtdCodegen.compile(schema); + final var result = codegen.validate(instance); + + final var expected = expectedErrors.elements().stream() + .map(e -> { + final var errObj = (JsonObject) e; + final var ip = toJsonPointer((JsonArray) errObj.members().get("instancePath")); + final var sp = toJsonPointer((JsonArray) errObj.members().get("schemaPath")); + return new JtdValidationError(ip, sp); + }) + .sorted(ERR_CMP) + .toList(); + + final var actual = result.errors().stream() + .sorted(ERR_CMP) + .toList(); + + assertThat(actual) + .as("errors for: " + name) + .containsExactlyElementsOf(expected); + } + + private static String toJsonPointer(JsonArray tokens) { + if (tokens.elements().isEmpty()) return ""; + final var sb = new StringBuilder(); + for (final var token : tokens.elements()) { + sb.append('/'); + sb.append(((JsonString) token).string()); + } + return sb.toString(); + } + + private static final Comparator ERR_CMP = + Comparator.comparing(JtdValidationError::instancePath) + .thenComparing(JtdValidationError::schemaPath); +} diff --git a/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CodegenTestBase.java b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CodegenTestBase.java new file mode 100644 index 0000000..e610ae6 --- /dev/null +++ b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CodegenTestBase.java @@ -0,0 +1,54 @@ +package json.java21.jtd.codegen; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; + +import java.util.Locale; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.Logger; + +/// Base class for all codegen tests. +/// +/// Mirrors the JTD module's JUL setup so tests behave similarly when run +/// standalone from this module. +public class CodegenTestBase { + + static final Logger LOG = Logger.getLogger("json.java21.jtd.codegen"); + + @BeforeAll + static void configureJul() { + Logger root = Logger.getLogger(""); + String levelProp = System.getProperty("java.util.logging.ConsoleHandler.level"); + Level targetLevel = Level.INFO; + if (levelProp != null) { + try { + targetLevel = Level.parse(levelProp.trim()); + } catch (IllegalArgumentException ex) { + try { + targetLevel = Level.parse(levelProp.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + System.err.println("Unrecognized logging level from 'java.util.logging.ConsoleHandler.level': " + levelProp); + } + } + } + if (root.getLevel() == null || root.getLevel().intValue() > targetLevel.intValue()) { + root.setLevel(targetLevel); + } + for (Handler handler : root.getHandlers()) { + Level handlerLevel = handler.getLevel(); + if (handlerLevel == null || handlerLevel.intValue() > targetLevel.intValue()) { + handler.setLevel(targetLevel); + } + } + } + + @BeforeEach + void announce(TestInfo testInfo) { + final String cls = testInfo.getTestClass().map(Class::getSimpleName).orElse("UnknownTest"); + final String name = testInfo.getTestMethod().map(java.lang.reflect.Method::getName) + .orElseGet(testInfo::getDisplayName); + LOG.info(() -> "TEST: " + cls + "#" + name); + } +} diff --git a/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CrossValidationTest.java b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CrossValidationTest.java new file mode 100644 index 0000000..121580b --- /dev/null +++ b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CrossValidationTest.java @@ -0,0 +1,263 @@ +package json.java21.jtd.codegen; + +import jdk.sandbox.java.util.json.Json; +import json.java21.jtd.JtdValidationError; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/// Cross-validates the interpreter and codegen paths produce identical +/// RFC 8927 `(instancePath, schemaPath)` error sets for the same +/// schema/instance pairs. +/// +/// Every case from [json.java21.jtd.JtdValidatorTest] is replicated here +/// plus additional cases from the RFC 8927 spec worked examples. +class CrossValidationTest extends CodegenTestBase { + + record Case(String name, String schema, String instance) {} + + static Stream cases() { + return Stream.of( + // -- Empty -- + args("empty accepts null", "{}", "null"), + args("empty accepts number", "{}", "42"), + args("empty accepts string", "{}", "\"hi\""), + args("empty accepts array", "{}", "[1,2]"), + args("empty accepts object", "{}", "{\"a\":1}"), + + // -- Type: boolean -- + args("boolean valid true", "{\"type\":\"boolean\"}", "true"), + args("boolean valid false", "{\"type\":\"boolean\"}", "false"), + args("boolean rejects number", "{\"type\":\"boolean\"}", "42"), + args("boolean rejects string", "{\"type\":\"boolean\"}", "\"hi\""), + + // -- Type: string -- + args("string valid", "{\"type\":\"string\"}", "\"hello\""), + args("string rejects number", "{\"type\":\"string\"}", "42"), + args("string rejects bool", "{\"type\":\"string\"}", "true"), + + // -- Type: float -- + args("float64 valid int", "{\"type\":\"float64\"}", "42"), + args("float64 valid decimal", "{\"type\":\"float64\"}", "3.14"), + args("float32 rejects string", "{\"type\":\"float32\"}", "\"hi\""), + + // -- Type: uint8 -- + args("uint8 valid 0", "{\"type\":\"uint8\"}", "0"), + args("uint8 valid 255", "{\"type\":\"uint8\"}", "255"), + args("uint8 valid 3.0", "{\"type\":\"uint8\"}", "3.0"), + args("uint8 rejects 256", "{\"type\":\"uint8\"}", "256"), + args("uint8 rejects -1", "{\"type\":\"uint8\"}", "-1"), + args("uint8 rejects 3.5", "{\"type\":\"uint8\"}", "3.5"), + args("uint8 rejects string", "{\"type\":\"uint8\"}", "\"hi\""), + + // -- Type: int8 -- + args("int8 valid -128", "{\"type\":\"int8\"}", "-128"), + args("int8 valid 127", "{\"type\":\"int8\"}", "127"), + args("int8 rejects 128", "{\"type\":\"int8\"}", "128"), + + // -- Type: int32 -- + args("int32 valid max", "{\"type\":\"int32\"}", "2147483647"), + args("int32 valid min", "{\"type\":\"int32\"}", "-2147483648"), + args("int32 rejects overflow", "{\"type\":\"int32\"}", "2147483648"), + args("int32 rejects decimal", "{\"type\":\"int32\"}", "3.14"), + + // -- Type: uint32 -- + args("uint32 valid max", "{\"type\":\"uint32\"}", "4294967295"), + args("uint32 rejects overflow", "{\"type\":\"uint32\"}", "4294967296"), + + // -- Type: timestamp -- + args("timestamp valid UTC", "{\"type\":\"timestamp\"}", "\"1990-12-31T23:59:59Z\""), + args("timestamp valid offset", "{\"type\":\"timestamp\"}", "\"2024-01-15T10:30:00+05:00\""), + args("timestamp valid leap second", "{\"type\":\"timestamp\"}", "\"1990-12-31T23:59:60Z\""), + args("timestamp rejects bad", "{\"type\":\"timestamp\"}", "\"not-a-date\""), + args("timestamp rejects number", "{\"type\":\"timestamp\"}", "42"), + + // -- Enum -- + args("enum valid", "{\"enum\":[\"a\",\"b\",\"c\"]}", "\"a\""), + args("enum rejects unknown", "{\"enum\":[\"a\",\"b\"]}", "\"c\""), + args("enum rejects number", "{\"enum\":[\"a\"]}", "42"), + + // -- Nullable -- + args("nullable string accepts null", "{\"type\":\"string\",\"nullable\":true}", "null"), + args("nullable string accepts string", "{\"type\":\"string\",\"nullable\":true}", "\"hi\""), + args("nullable string rejects number", "{\"type\":\"string\",\"nullable\":true}", "42"), + + // -- Elements -- + args("elements valid", "{\"elements\":{\"type\":\"string\"}}", "[\"a\",\"b\"]"), + args("elements empty", "{\"elements\":{\"type\":\"string\"}}", "[]"), + args("elements rejects non-array", "{\"elements\":{\"type\":\"string\"}}", "42"), + args("elements child errors", "{\"elements\":{\"type\":\"string\"}}", "[\"ok\",42,\"fine\",true]"), + + // -- Properties -- + args("props valid", + "{\"properties\":{\"name\":{\"type\":\"string\"},\"age\":{\"type\":\"uint8\"}}}", + "{\"name\":\"Alice\",\"age\":30}"), + args("props missing required", + "{\"properties\":{\"name\":{\"type\":\"string\"},\"age\":{\"type\":\"uint8\"}}}", + "{\"name\":\"Alice\"}"), + args("props additional rejected", + "{\"properties\":{\"name\":{\"type\":\"string\"}}}", + "{\"name\":\"Alice\",\"extra\":true}"), + args("props child error", + "{\"properties\":{\"age\":{\"type\":\"uint8\"}}}", + "{\"age\":\"not a number\"}"), + args("props rejects non-object", + "{\"properties\":{\"x\":{\"type\":\"string\"}}}", + "42"), + + // -- Optional properties -- + args("optional-only rejects non-object", + "{\"optionalProperties\":{\"email\":{\"type\":\"string\"}}}", + "42"), + args("optional absent ok", + "{\"optionalProperties\":{\"email\":{\"type\":\"string\"}}}", + "{}"), + args("optional present valid", + "{\"optionalProperties\":{\"email\":{\"type\":\"string\"}}}", + "{\"email\":\"a@b\"}"), + args("optional present invalid", + "{\"optionalProperties\":{\"email\":{\"type\":\"string\"}}}", + "{\"email\":42}"), + + // -- Values -- + args("values valid", "{\"values\":{\"type\":\"string\"}}", "{\"a\":\"x\",\"b\":\"y\"}"), + args("values rejects non-object", "{\"values\":{\"type\":\"string\"}}", "42"), + args("values child error", "{\"values\":{\"type\":\"string\"}}", "{\"a\":\"ok\",\"b\":42}"), + + // -- Discriminator -- + args("disc valid", + "{\"discriminator\":\"kind\",\"mapping\":{\"a\":{\"properties\":{\"x\":{\"type\":\"string\"}}}}}", + "{\"kind\":\"a\",\"x\":\"ok\"}"), + args("disc not object", + "{\"discriminator\":\"kind\",\"mapping\":{\"a\":{\"properties\":{\"x\":{\"type\":\"string\"}}}}}", + "42"), + args("disc missing tag", + "{\"discriminator\":\"kind\",\"mapping\":{\"a\":{\"properties\":{\"x\":{\"type\":\"string\"}}}}}", + "{\"x\":1}"), + args("disc tag not string", + "{\"discriminator\":\"kind\",\"mapping\":{\"a\":{\"properties\":{\"x\":{\"type\":\"string\"}}}}}", + "{\"kind\":42}"), + args("disc tag not in mapping", + "{\"discriminator\":\"kind\",\"mapping\":{\"a\":{\"properties\":{\"x\":{\"type\":\"string\"}}}}}", + "{\"kind\":\"unknown\"}"), + args("disc variant error", + "{\"discriminator\":\"kind\",\"mapping\":{\"a\":{\"properties\":{\"x\":{\"type\":\"string\"}}}}}", + "{\"kind\":\"a\",\"x\":42}"), + + // -- Ref -- + args("ref valid", + "{\"definitions\":{\"addr\":{\"type\":\"string\"}},\"ref\":\"addr\"}", + "\"hello\""), + args("ref invalid", + "{\"definitions\":{\"addr\":{\"type\":\"string\"}},\"ref\":\"addr\"}", + "42"), + + // -- Nested: elements of properties -- + args("elements of properties valid", + "{\"elements\":{\"properties\":{\"n\":{\"type\":\"string\"}}}}", + "[{\"n\":\"ok\"}]"), + args("elements of properties invalid child", + "{\"elements\":{\"properties\":{\"n\":{\"type\":\"string\"}}}}", + "[{\"n\":42}]"), + args("elements of properties missing", + "{\"elements\":{\"properties\":{\"n\":{\"type\":\"string\"}}}}", + "[{}]"), + + // -- Nested: additional properties inside array elements -- + args("elements of properties additional rejected", + "{\"elements\":{\"properties\":{\"n\":{\"type\":\"string\"}}}}", + "[{\"n\":\"ok\",\"extra\":true}]"), + + // -- Nested: non-object inside elements of properties -- + args("elements of properties non-object", + "{\"elements\":{\"properties\":{\"n\":{\"type\":\"string\"}}}}", + "[42]"), + + // -- Nested: discriminator inside values -- + args("values of discriminator valid", + "{\"values\":{\"discriminator\":\"kind\",\"mapping\":{\"a\":{\"properties\":{\"x\":{\"type\":\"string\"}}}}}}", + "{\"k1\":{\"kind\":\"a\",\"x\":\"ok\"}}"), + args("values of discriminator invalid", + "{\"values\":{\"discriminator\":\"kind\",\"mapping\":{\"a\":{\"properties\":{\"x\":{\"type\":\"string\"}}}}}}", + "{\"k1\":{\"kind\":\"a\",\"x\":42}}"), + + // -- Nested: elements of elements -- + args("elements of elements valid", + "{\"elements\":{\"elements\":{\"type\":\"string\"}}}", + "[[\"a\",\"b\"]]"), + args("elements of elements invalid", + "{\"elements\":{\"elements\":{\"type\":\"string\"}}}", + "[[\"a\",42]]"), + + // -- Nested: values of values -- + args("values of values valid", + "{\"values\":{\"values\":{\"type\":\"string\"}}}", + "{\"a\":{\"b\":\"ok\"}}"), + args("values of values invalid", + "{\"values\":{\"values\":{\"type\":\"string\"}}}", + "{\"a\":{\"b\":42}}"), + + // -- Discriminator with multi-variant mapping -- + args("disc multi-variant valid dog", + "{\"discriminator\":\"kind\",\"mapping\":{\"dog\":{\"properties\":{\"breed\":{\"type\":\"string\"}}},\"cat\":{\"properties\":{\"indoor\":{\"type\":\"boolean\"}}}}}", + "{\"kind\":\"dog\",\"breed\":\"poodle\"}"), + args("disc multi-variant valid cat", + "{\"discriminator\":\"kind\",\"mapping\":{\"dog\":{\"properties\":{\"breed\":{\"type\":\"string\"}}},\"cat\":{\"properties\":{\"indoor\":{\"type\":\"boolean\"}}}}}", + "{\"kind\":\"cat\",\"indoor\":true}"), + + // -- Worked example from spec -- + args("worked example", + "{\"properties\":{\"name\":{\"type\":\"string\"},\"age\":{\"type\":\"uint8\"},\"tags\":{\"elements\":{\"type\":\"string\"}}},\"optionalProperties\":{\"email\":{\"type\":\"string\"}}}", + "{\"name\":\"Alice\",\"age\":300,\"tags\":[\"a\",42],\"extra\":true}") + ); + } + + private static Arguments args(String name, String schema, String instance) { + return Arguments.of(name, schema, instance); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("cases") + void interpreterAndCodegenAgree(String name, String schemaJson, String instanceJson) { + final var schema = Json.parse(schemaJson); + final var instance = Json.parse(instanceJson); + + final var interpreter = json.java21.jtd.JtdValidator.compile(schema); + final var codegen = JtdCodegen.compile(schema); + + final var interpResult = interpreter.validate(instance); + final var codegenResult = codegen.validate(instance); + + assertThat(codegenResult.isValid()) + .as("isValid for: " + name) + .isEqualTo(interpResult.isValid()); + + final var interpErrors = sorted(interpResult.errors()); + final var codegenErrors = sorted(codegenResult.errors()); + + assertThat(codegenErrors) + .as("error set for: " + name) + .containsExactlyElementsOf(interpErrors); + } + + private static List sorted(List errors) { + return errors.stream() + .sorted(Comparator.comparing(JtdValidationError::instancePath) + .thenComparing(JtdValidationError::schemaPath)) + .toList(); + } + + @org.junit.jupiter.api.Test + void toStringReturnsOriginalSchemaJson() { + LOG.info("CROSS-VALIDATE: toString"); + final var v = JtdCodegen.compile(Json.parse("{\"type\": \"string\"}")); + assertThat(v.toString()).contains("type"); + assertThat(v.toString()).contains("string"); + } +} diff --git a/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/JtdTestDataExtractor.java b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/JtdTestDataExtractor.java new file mode 100644 index 0000000..fa12957 --- /dev/null +++ b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/JtdTestDataExtractor.java @@ -0,0 +1,68 @@ +package json.java21.jtd.codegen; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.logging.Logger; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/// Shared utility for extracting the JTD test suite from the embedded ZIP file. +/// Used by both unit tests and integration tests to avoid committing large JSON files. +final class JtdTestDataExtractor { + + private static final Logger LOG = Logger.getLogger("json.java21.jtd.codegen"); + // Reference the ZIP file from the sibling json-java21-jtd module + private static final Path ZIP_FILE = Paths.get("../json-java21-jtd/src/test/resources/jtd-test-suite.zip"); + private static final Path TARGET_DIR = Paths.get("target/test-data"); + private static final Path VALIDATION_FILE = TARGET_DIR.resolve("json-typedef-spec-2025-09-27/tests/validation.json"); + + private JtdTestDataExtractor() { + // Utility class + } + + /// Ensures the test suite is extracted and returns the path to validation.json. + /// Extraction happens at most once per build (target/ is cleaned between builds). + static synchronized Path ensureValidationTestData() throws IOException { + if (Files.exists(VALIDATION_FILE)) { + LOG.fine(() -> "JTD test suite already extracted at: " + VALIDATION_FILE); + return VALIDATION_FILE; + } + + if (!Files.exists(ZIP_FILE)) { + throw new RuntimeException("JTD test suite ZIP not found: " + ZIP_FILE.toAbsolutePath()); + } + + LOG.info(() -> "Extracting JTD test suite from: " + ZIP_FILE); + Files.createDirectories(TARGET_DIR); + + try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(ZIP_FILE))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (!entry.isDirectory() && entry.getName().startsWith("json-typedef-spec-")) { + Path outputPath = TARGET_DIR.resolve(entry.getName()); + Files.createDirectories(outputPath.getParent()); + Files.copy(zis, outputPath, StandardCopyOption.REPLACE_EXISTING); + } + zis.closeEntry(); + } + } + + if (!Files.exists(VALIDATION_FILE)) { + throw new RuntimeException("Extraction completed but validation.json not found: " + VALIDATION_FILE); + } + + LOG.info(() -> "JTD test suite extracted successfully"); + return VALIDATION_FILE; + } + + /// Returns an InputStream for the validation test data, extracting if necessary. + /// Suitable for use with classpath-style resource loading patterns. + static InputStream getValidationTestDataStream() throws IOException { + Path dataFile = ensureValidationTestData(); + return Files.newInputStream(dataFile); + } +} diff --git a/json-java21-jtd/JTD_CODEGEN_SPEC.md b/json-java21-jtd/JTD_CODEGEN_SPEC.md new file mode 100644 index 0000000..f4d98e7 --- /dev/null +++ b/json-java21-jtd/JTD_CODEGEN_SPEC.md @@ -0,0 +1,706 @@ +# JTD Code Generation Specification + +A language-independent specification for compiling RFC 8927 JSON Type Definition +schemas into target-language source code that validates JSON documents. The +generated code contains exactly the checks the schema requires -- no +interpreter, no AST, no runtime stack, no dead code. + +## 1. Terminology + +| Term | Meaning | +|---|---| +| **schema** | A JSON object conforming to RFC 8927. | +| **instance** | The JSON value being validated at runtime. | +| **form** | One of the 8 mutually-exclusive schema shapes defined in RFC 8927 plus the nullable modifier. | +| **AST node** | An immutable, tagged value representing one compiled schema form. Used during generation, discarded after. | +| **error** | A pair of JSON Pointers: `(instancePath, schemaPath)`. | +| **definitions** | A flat string-keyed map of named AST nodes, resolved at compile time. Each becomes a generated function. | + +## 2. Overview + +A JTD code generator operates in two phases: + +1. **Parse**: Read the JTD schema JSON and compile it into an intermediate + AST of immutable nodes (Section 3). +2. **Emit**: Walk the AST and emit target-language source code. Each AST + node maps to a specific code pattern. The AST is discarded after + emission (Section 5). + +The generated code is a standalone validation function. When executed against +a JSON instance, it produces the same `(instancePath, schemaPath)` error +pairs that RFC 8927 Section 3.3 specifies. + +## 3. Intermediate AST + +The AST is used only during generation. It is not present in the output. + +### 3.1 Node Types + +``` +Node = + | Empty -- {} + | Ref { name: String } -- {"ref": "..."} + | Type { type: TypeKeyword } -- {"type": "..."} + | Enum { values: List } -- {"enum": [...]} + | Elements { schema: Node } -- {"elements": ...} + | Properties { required: Map, -- {"properties": ...} + optional: Map, -- {"optionalProperties": ...} + additional: Boolean } -- {"additionalProperties": ...} + | Values { schema: Node } -- {"values": ...} + | Discrim { tag: String, mapping: Map} -- {"discriminator":...,"mapping":...} + | Nullable { inner: Node } -- any form + "nullable": true +``` + +`TypeKeyword` is one of the 12 strings defined in RFC 8927 Section 2.2.3: + +``` +TypeKeyword = boolean | string | timestamp + | int8 | uint8 | int16 | uint16 | int32 | uint32 + | float32 | float64 +``` + +### 3.2 Compilation Algorithm + +``` +compile(json, isRoot=true, definitions) -> Node: + + REQUIRE json is a JSON object + + IF isRoot: + IF json has key "definitions": + REQUIRE json["definitions"] is a JSON object + -- Pass 1: register all keys as placeholders for forward refs + FOR EACH key in json["definitions"]: + definitions[key] = PLACEHOLDER + -- Pass 2: compile each definition + FOR EACH key in json["definitions"]: + definitions[key] = compile(json["definitions"][key], isRoot=false, definitions) + ELSE: + REQUIRE json does NOT have key "definitions" + + -- Detect form + forms = [] + IF json has "ref": forms += "ref" + IF json has "type": forms += "type" + IF json has "enum": forms += "enum" + IF json has "elements": forms += "elements" + IF json has "values": forms += "values" + IF json has "discriminator": forms += "discriminator" + IF json has "properties" OR json has "optionalProperties": + forms += "properties" + + REQUIRE |forms| <= 1 + + -- Compile form + node = MATCH forms: + [] -> Empty + ["ref"] -> compileRef(json, definitions) + ["type"] -> compileType(json) + ["enum"] -> compileEnum(json) + ["elements"] -> compileElements(json, definitions) + ["properties"] -> compileProperties(json, definitions) + ["values"] -> compileValues(json, definitions) + ["discriminator"]-> compileDiscriminator(json, definitions) + + -- Nullable modifier wraps any form + IF json has "nullable" AND json["nullable"] == true: + node = Nullable { inner: node } + + RETURN node +``` + +### 3.3 Form-Specific Compilation + +**Ref**: +``` +compileRef(json, definitions): + name = json["ref"] -- must be a string + REQUIRE name IN definitions -- forward refs are valid (placeholder exists) + RETURN Ref { name } +``` + +**Type**: +``` +compileType(json): + t = json["type"] -- must be a string + REQUIRE t IN TypeKeyword + RETURN Type { type: t } +``` + +**Enum**: +``` +compileEnum(json): + values = json["enum"] -- must be a non-empty array of strings + REQUIRE no duplicates in values + RETURN Enum { values } +``` + +**Elements**: +``` +compileElements(json, definitions): + inner = compile(json["elements"], isRoot=false, definitions) + RETURN Elements { schema: inner } +``` + +**Properties**: +``` +compileProperties(json, definitions): + req = {} + opt = {} + IF json has "properties": + FOR EACH (key, schema) in json["properties"]: + req[key] = compile(schema, isRoot=false, definitions) + IF json has "optionalProperties": + FOR EACH (key, schema) in json["optionalProperties"]: + opt[key] = compile(schema, isRoot=false, definitions) + REQUIRE keys(req) INTERSECT keys(opt) == {} + additional = json.get("additionalProperties", false) + RETURN Properties { required: req, optional: opt, additional } +``` + +**Values**: +``` +compileValues(json, definitions): + inner = compile(json["values"], isRoot=false, definitions) + RETURN Values { schema: inner } +``` + +**Discriminator**: +``` +compileDiscriminator(json, definitions): + tag = json["discriminator"] -- must be a string + REQUIRE json has "mapping" + mapping = {} + FOR EACH (key, schema) in json["mapping"]: + node = compile(schema, isRoot=false, definitions) + REQUIRE node is Properties -- not Nullable, not any other form + REQUIRE tag NOT IN node.required + REQUIRE tag NOT IN node.optional + mapping[key] = node + RETURN Discrim { tag, mapping } +``` + +### 3.4 Compile-Time Invariants + +After compilation, the following are guaranteed: +- Every `Ref.name` resolves to an entry in `definitions`. +- Every `Discrim.mapping` value is a `Properties` node (not nullable). +- No `Properties` node has overlapping required/optional keys. +- The AST is immutable. No node is modified after construction. + +## 4. Type Checking Reference + +Exact semantics for each `TypeKeyword`. The code generator emits exactly +this check, inlined, for each type keyword it encounters. + +### 4.1 boolean + +``` +value is a JSON boolean (true or false) +``` + +Target-language expression examples: +- JavaScript: `typeof v === "boolean"` +- Java: `v instanceof JsonBoolean` +- Python: `isinstance(v, bool)` + +### 4.2 string + +``` +value is a JSON string +``` + +Target-language expression examples: +- JavaScript: `typeof v === "string"` +- Java: `v instanceof JsonString` +- Python: `isinstance(v, str)` + +### 4.3 timestamp + +``` +value is a JSON string +AND value matches the RFC 3339 date-time production + (regex: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:(\d{2}|60)(\.\d+)?(Z|[+-]\d{2}:\d{2})$) +AND the date-time is parseable (accounting for leap seconds by + normalizing :60 to :59 before parsing) +``` + +Target-language expression examples: +- JavaScript: `typeof v === "string" && !Number.isNaN(Date.parse(v))` (simplified; + a full implementation needs the regex for leap-second support) +- Java: regex match + `OffsetDateTime.parse(normalized)` + +### 4.4 float32, float64 + +``` +value is a JSON number (any finite number; no range check) +``` + +RFC 8927 does not distinguish float32 from float64 at the validation level. +Both accept any JSON number. + +Target-language expression examples: +- JavaScript: `typeof v === "number" && Number.isFinite(v)` +- Java: `v instanceof JsonNumber` + +### 4.5 Integer types + +All integer types share the same two-step check: + +``` +value is a JSON number +AND value has zero fractional part (floor(value) == value) +AND value is within the type's range (inclusive) +``` + +| Type | Min | Max | +|---|---|---| +| int8 | -128 | 127 | +| uint8 | 0 | 255 | +| int16 | -32768 | 32767 | +| uint16 | 0 | 65535 | +| int32 | -2147483648 | 2147483647 | +| uint32 | 0 | 4294967295 | + +Note: `3.0` is a valid int8. `3.5` is not. This is value-based, not +syntax-based. + +Target-language expression examples: +- JavaScript (uint8): `typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 255` +- Java (uint8): `v instanceof JsonNumber n && n.toDouble() == Math.floor(n.toDouble()) && n.toLong() >= 0 && n.toLong() <= 255` + +## 5. Emission Rules + +The code generator walks the AST and emits target-language source code. +Each AST node maps to a specific code pattern. The central rule: + +**Emit only what the schema requires. If the schema does not mention a +form, the generated code does not contain any logic for that form.** + +### 5.1 Generated Code Structure + +The generator emits: + +1. **One function per definition** -- named `validate_`, taking + `(instance, errors, instancePath)` as parameters. Only emitted if the + schema has definitions. + +2. **One exported `validate(instance)` function** -- the entry point. Creates + the error list, calls the root validation logic, returns the error list. + +3. **No helpers, no libraries, no imports.** Every check is inlined. If the + schema uses only `"type": "string"`, the generated code contains one + `typeof` check and nothing else. + +### 5.2 Node-to-Code Mapping + +#### Empty + +Emit nothing. No check. No code. + +If an Empty node is a required property value, the generated code checks +that the key exists but does not validate the value: + +```javascript +// Schema: {"properties": {"data": {}}} +if (!("data" in obj)) e.push({instancePath: p, schemaPath: sp + "/properties/data"}); +// No else branch -- empty schema accepts any value +``` + +#### Nullable + +Emit a null guard before the inner check: + +```javascript +// Schema: {"type": "string", "nullable": true} +if (v !== null) { + if (typeof v !== "string") e.push({instancePath: p, schemaPath: sp + "/type"}); +} +``` + +If the inner node is Empty, the nullable wraps nothing -- emit only the +null guard (which passes everything, so emit nothing at all). + +#### Type + +Emit the type-specific check inlined. No helper function. + +```javascript +// "type": "string" +if (typeof v !== "string") e.push({instancePath: p, schemaPath: sp + "/type"}); + +// "type": "uint8" +if (typeof v !== "number" || !Number.isInteger(v) || v < 0 || v > 255) + e.push({instancePath: p, schemaPath: sp + "/type"}); + +// "type": "boolean" +if (typeof v !== "boolean") e.push({instancePath: p, schemaPath: sp + "/type"}); + +// "type": "float64" +if (typeof v !== "number" || !Number.isFinite(v)) + e.push({instancePath: p, schemaPath: sp + "/type"}); +``` + +#### Enum + +Emit a set-membership check. For small enums, inline the array. For large +enums, a code generator MAY hoist the array to module scope as a constant. + +```javascript +// "enum": ["a", "b", "c"] +if (typeof v !== "string" || !["a","b","c"].includes(v)) + e.push({instancePath: p, schemaPath: sp + "/enum"}); +``` + +Note: the string type guard is required because RFC 8927 specifies that +non-string values fail enum validation. + +#### Elements + +Emit an array type guard, then a loop. The loop body is the generated +check for the element schema. + +```javascript +// "elements": {"type": "string"} +if (!Array.isArray(v)) { + e.push({instancePath: p, schemaPath: sp + "/elements"}); +} else { + for (let i = 0; i < v.length; i++) { + if (typeof v[i] !== "string") + e.push({instancePath: p + "/" + i, schemaPath: sp + "/elements/type"}); + } +} +``` + +If the element schema is a complex type (Properties, Discrim), emit a +function call in the loop body instead of inlining. + +For nested arrays (arrays of arrays), a code generator MAY inline nested +loops up to a configurable depth (e.g. 3 levels) for performance, falling +back to function calls beyond that depth. + +#### Properties + +Emit an object type guard, then: +1. One presence check per required key. +2. Inlined value checks for each required and optional property. +3. A key-rejection loop if `additional == false`. + +```javascript +// Schema: {"properties":{"name":{"type":"string"}}, "optionalProperties":{"age":{"type":"uint8"}}} +if (v === null || typeof v !== "object" || Array.isArray(v)) { + e.push({instancePath: p, schemaPath: sp + "/properties"}); +} else { + // Required properties + if (!("name" in v)) e.push({instancePath: p, schemaPath: sp + "/properties/name"}); + else if (typeof v["name"] !== "string") + e.push({instancePath: p + "/name", schemaPath: sp + "/properties/name/type"}); + + // Optional properties + if ("age" in v) { + const a = v["age"]; + if (typeof a !== "number" || !Number.isInteger(a) || a < 0 || a > 255) + e.push({instancePath: p + "/age", schemaPath: sp + "/optionalProperties/age/type"}); + } + + // Additional properties (only emitted when additional == false) + for (const k in v) { + if (k !== "name" && k !== "age") + e.push({instancePath: p + "/" + k, schemaPath: sp}); + } +} +``` + +If `additional` is `true`, the for-in loop is **not emitted at all**. + +If a property value's schema is a complex type (Properties, Elements, etc.), +emit a function call instead of inlining. If it is a leaf (Type, Enum, +Empty), inline it. + +#### Values + +Emit an object type guard, then a for-in loop. The loop body is the +generated check for the value schema. + +```javascript +// "values": {"type": "string"} +if (v === null || typeof v !== "object" || Array.isArray(v)) { + e.push({instancePath: p, schemaPath: sp + "/values"}); +} else { + for (const k in v) { + if (typeof v[k] !== "string") + e.push({instancePath: p + "/" + k, schemaPath: sp + "/values/type"}); + } +} +``` + +#### Discriminator + +Emit a 5-step sequential check, then a switch/if-else dispatching to the +variant validator. + +```javascript +// "discriminator": "type", "mapping": {"a": {...}, "b": {...}} +if (v === null || typeof v !== "object" || Array.isArray(v)) { + e.push({instancePath: p, schemaPath: sp + "/discriminator"}); +} else if (!("type" in v)) { + e.push({instancePath: p, schemaPath: sp + "/discriminator"}); +} else if (typeof v["type"] !== "string") { + e.push({instancePath: p + "/type", schemaPath: sp + "/discriminator"}); +} else if (v["type"] === "a") { + validate_variant_a(v, e, p, sp + "/mapping/a"); +} else if (v["type"] === "b") { + validate_variant_b(v, e, p, sp + "/mapping/b"); +} else { + e.push({instancePath: p + "/type", schemaPath: sp + "/mapping"}); +} +``` + +Each variant validator is a generated Properties check. The discriminator +tag field is excluded from additional-properties checking and from +property validation in the variant (it was already validated by the +discriminator check). + +#### Ref + +Emit a function call to the generated definition validator: + +```javascript +// "ref": "address" +validate_address(v, e, p, sp); +``` + +Each definition becomes a generated function. The function body is the +emitted code for the definition's AST node. + +### 5.3 Inlining Policy + +A code generator SHOULD inline checks for leaf nodes (Type, Enum, Empty) +directly into their parent's generated code. + +A code generator SHOULD emit separate functions for: +- Each definition (called via Ref). +- Each Properties or Discrim node that appears as the child of Elements, + Values, or other container nodes. +- Each discriminator variant. + +A code generator MUST NOT emit helper functions, type-checking utilities, +or library imports that are not required by the specific schema being +compiled. + +### 5.4 Recursive Schemas + +Recursive refs (a definition that ultimately references itself) are legal +in RFC 8927. In generated code, this becomes recursive function calls: + +```javascript +// Schema: {"definitions":{"node":{"properties":{"next":{"ref":"node","nullable":true}}}}, +// "ref":"node"} +function validate_node(v, e, p, sp) { + if (v === null || typeof v !== "object" || Array.isArray(v)) { + e.push({instancePath: p, schemaPath: sp}); + return; + } + if (!("next" in v)) { + e.push({instancePath: p, schemaPath: sp + "/properties/next"}); + } else if (v["next"] !== null) { + validate_node(v["next"], e, p + "/next", sp + "/properties/next"); + } +} + +export function validate(instance) { + const e = []; + validate_node(instance, e, "", ""); + return e; +} +``` + +The target-language call stack provides the implicit work stack. For most +real-world schemas, recursion depth is bounded by the document's structure. + +### 5.5 Discriminator Tag Exemption + +When emitting a variant Properties check inside a discriminator, the +code generator MUST: +- Exclude the tag field from additional-properties rejection. +- Not emit a value check for the tag field (it was already validated + as a string by the discriminator check). + +This means the generated known-key set in the for-in loop includes the +tag field name, and no property check is emitted for it. + +## 6. Error Format + +Errors follow RFC 8927 Section 3.3, which defines error indicators as +pairs of JSON Pointers: + +``` +Error = { + instancePath: String, -- JSON Pointer (RFC 6901) into the instance + schemaPath: String -- JSON Pointer (RFC 6901) into the schema +} +``` + +The `instancePath` points to the value that failed. The `schemaPath` points +to the schema keyword that caused the failure. + +### 6.1 Schema Path Construction + +The schema path is built at generation time and baked into the generated +code as string literals. Each emission rule appends to the schema path: + +| Form | Appended path component(s) | +|---|---| +| Type | `/type` | +| Enum | `/enum` | +| Elements (type guard) | `/elements` | +| Elements (child) | `/elements` | +| Properties (type guard) | `/properties` (or `/optionalProperties` if schema has no `properties` member) | +| Properties (missing key) | `/properties/` | +| Properties (additional) | (nothing -- error at current path) | +| Properties (child req) | `/properties/` | +| Properties (child opt) | `/optionalProperties/` | +| Values (type guard) | `/values` | +| Values (child) | `/values` | +| Discrim (not object) | `/discriminator` | +| Discrim (tag missing) | `/discriminator` | +| Discrim (tag not string) | `/discriminator` | +| Discrim (tag not in map) | `/mapping` | +| Discrim (variant) | `/mapping/` | + +Schema paths are string literals in the generated code. They do not change +at runtime. + +### 6.2 Instance Path Construction + +Instance paths are built at runtime via string concatenation: + +| Descent into | Appended to instancePath | +|---|---| +| Array element at index `i` | `"/" + i` | +| Object property with key `k` | `"/" + k` | +| Discriminator tag value | `"/" + tagFieldName` | +| Discriminator variant | (nothing -- same object) | +| Ref target | (nothing -- transparent) | + +## 7. Conformance + +Generated code conforms to this spec if: + +1. For any valid RFC 8927 schema and any JSON instance, the generated + `validate(instance)` function returns the same set of + `(instancePath, schemaPath)` error pairs that RFC 8927 Section 3.3 + specifies. + +2. The generated code passes the official JTD validation test suite + (`validation.json` from `json-typedef-spec`) when used as the + validation engine. + +3. The code generator rejects invalid schemas at generation time per the + constraints in Section 3.4. + +4. The generated code contains no dead code: no helper functions, loops, + branches, or checks that the schema does not require. + +5. Validation does not short-circuit. All errors are collected in a + single pass. + +## 8. Worked Example + +Schema: +```json +{ + "properties": { + "name": { "type": "string" }, + "age": { "type": "uint8" }, + "tags": { "elements": { "type": "string" } } + }, + "optionalProperties": { + "email": { "type": "string" } + } +} +``` + +### Compiled AST (intermediate, discarded after emission) + +``` +Properties { + required: { + "name" -> Type { type: "string" }, + "age" -> Type { type: "uint8" }, + "tags" -> Elements { schema: Type { type: "string" } } + }, + optional: { + "email" -> Type { type: "string" } + }, + additional: false +} +``` + +### Generated Code (JavaScript ES2020) + +```javascript +export function validate(instance) { + const e = []; + if (instance === null || typeof instance !== "object" || Array.isArray(instance)) { + e.push({instancePath: "", schemaPath: "/properties"}); + return e; + } + + if (!("name" in instance)) e.push({instancePath: "", schemaPath: "/properties/name"}); + else if (typeof instance["name"] !== "string") + e.push({instancePath: "/name", schemaPath: "/properties/name/type"}); + + if (!("age" in instance)) e.push({instancePath: "", schemaPath: "/properties/age"}); + else { + const v = instance["age"]; + if (typeof v !== "number" || !Number.isInteger(v) || v < 0 || v > 255) + e.push({instancePath: "/age", schemaPath: "/properties/age/type"}); + } + + if (!("tags" in instance)) e.push({instancePath: "", schemaPath: "/properties/tags"}); + else if (!Array.isArray(instance["tags"])) + e.push({instancePath: "/tags", schemaPath: "/properties/tags/elements"}); + else { + const arr = instance["tags"]; + for (let i = 0; i < arr.length; i++) { + if (typeof arr[i] !== "string") + e.push({instancePath: "/tags/" + i, schemaPath: "/properties/tags/elements/type"}); + } + } + + if ("email" in instance && typeof instance["email"] !== "string") + e.push({instancePath: "/email", schemaPath: "/optionalProperties/email/type"}); + + for (const k in instance) { + if (k !== "name" && k !== "age" && k !== "tags" && k !== "email") + e.push({instancePath: "/" + k, schemaPath: ""}); + } + + return e; +} +``` + +No helper functions. No dead code. Every line corresponds to a specific +constraint in the schema. + +### Validation of example instance + +Instance: +```json +{ "name": "Alice", "age": 300, "tags": ["a", 42], "extra": true } +``` + +Errors produced: +```json +[ + { "instancePath": "/age", "schemaPath": "/properties/age/type" }, + { "instancePath": "/tags/1", "schemaPath": "/properties/tags/elements/type" }, + { "instancePath": "/extra", "schemaPath": "" } +] +``` + +- `age`: 300 is a number with zero fractional part, but 300 > 255 (uint8 max). +- `tags/1`: 42 is not a string. +- `extra`: not in required or optional properties, and `additionalProperties` + defaults to `false`. diff --git a/json-java21-jtd/JTD_STACK_MACHINE_SPEC.md b/json-java21-jtd/JTD_STACK_MACHINE_SPEC.md new file mode 100644 index 0000000..c942811 --- /dev/null +++ b/json-java21-jtd/JTD_STACK_MACHINE_SPEC.md @@ -0,0 +1,792 @@ +# JTD Stack-Machine Interpreter Specification + +A language-independent specification for compiling RFC 8927 JSON Type Definition +schemas into an immutable AST, and validating JSON documents against that AST +using an explicit work stack. + +This spec describes an **interpreter**: a generic validation engine that walks +a compiled AST at runtime. The AST is built once, then reused to validate +any number of JSON documents. + +## 1. Terminology + +| Term | Meaning | +|---|---| +| **schema** | A JSON object conforming to RFC 8927. | +| **instance** | The JSON value being validated. | +| **form** | One of the 8 mutually-exclusive schema shapes defined in RFC 8927 plus the nullable modifier. | +| **AST node** | An immutable, tagged value representing one compiled schema form. | +| **frame** | A tuple of (AST node, instance, path state) representing one unit of work. | +| **work stack** | A LIFO collection of frames. Validation is complete when the stack is empty. | +| **error** | A pair of JSON Pointers: `(instancePath, schemaPath)`. | +| **definitions** | A flat string-keyed map of named AST nodes, resolved at compile time. | +| **root** | A compiled schema document: its top-level AST node plus its definitions map. | + +## 2. Compile Phase + +### 2.1 Input + +A JSON object (the schema) and, optionally, a base URI for multi-root +resolution (see Section 7). + +### 2.2 Output + +An immutable **Root**: + +``` +Root = { + schema: Node, + definitions: Map -- immutable, keyed by definition name +} +``` + +### 2.3 AST Node Types + +A Node is a tagged union (sum type / sealed interface / discriminated union) +with exactly 9 variants. Implementations MUST represent these as immutable +value types (records, data classes, frozen structs, etc.). + +``` +Node = + | Empty -- {} + | Ref { name: String } -- {"ref": "..."} + | Type { type: TypeKeyword } -- {"type": "..."} + | Enum { values: List } -- {"enum": [...]} + | Elements { schema: Node } -- {"elements": ...} + | Properties { required: Map, -- {"properties": ...} + optional: Map, -- {"optionalProperties": ...} + additional: Boolean } -- {"additionalProperties": ...} + | Values { schema: Node } -- {"values": ...} + | Discrim { tag: String, mapping: Map} -- {"discriminator":...,"mapping":...} + | Nullable { inner: Node } -- any form + "nullable": true +``` + +`TypeKeyword` is one of the 12 strings defined in RFC 8927 Section 2.2.3: + +``` +TypeKeyword = boolean | string | timestamp + | int8 | uint8 | int16 | uint16 | int32 | uint32 + | float32 | float64 +``` + +### 2.4 Compilation Algorithm + +``` +compile(json, isRoot=true, definitions) -> Node: + + REQUIRE json is a JSON object + + IF isRoot: + IF json has key "definitions": + REQUIRE json["definitions"] is a JSON object + -- Pass 1: register all keys as placeholders for forward refs + FOR EACH key in json["definitions"]: + definitions[key] = PLACEHOLDER + -- Pass 2: compile each definition + FOR EACH key in json["definitions"]: + definitions[key] = compile(json["definitions"][key], isRoot=false, definitions) + ELSE: + REQUIRE json does NOT have key "definitions" + + -- Detect form + forms = [] + IF json has "ref": forms += "ref" + IF json has "type": forms += "type" + IF json has "enum": forms += "enum" + IF json has "elements": forms += "elements" + IF json has "values": forms += "values" + IF json has "discriminator": forms += "discriminator" + IF json has "properties" OR json has "optionalProperties": + forms += "properties" + + REQUIRE |forms| <= 1 + + -- Compile form + node = MATCH forms: + [] -> Empty + ["ref"] -> compileRef(json, definitions) + ["type"] -> compileType(json) + ["enum"] -> compileEnum(json) + ["elements"] -> compileElements(json, definitions) + ["properties"] -> compileProperties(json, definitions) + ["values"] -> compileValues(json, definitions) + ["discriminator"]-> compileDiscriminator(json, definitions) + + -- Nullable modifier wraps any form + IF json has "nullable" AND json["nullable"] == true: + node = Nullable { inner: node } + + RETURN node +``` + +### 2.5 Form-Specific Compilation + +**Ref**: +``` +compileRef(json, definitions): + name = json["ref"] -- must be a string + REQUIRE name IN definitions -- forward refs are valid (placeholder exists) + RETURN Ref { name } +``` + +**Type**: +``` +compileType(json): + t = json["type"] -- must be a string + REQUIRE t IN TypeKeyword + RETURN Type { type: t } +``` + +**Enum**: +``` +compileEnum(json): + values = json["enum"] -- must be a non-empty array of strings + REQUIRE no duplicates in values + RETURN Enum { values } +``` + +**Elements**: +``` +compileElements(json, definitions): + inner = compile(json["elements"], isRoot=false, definitions) + RETURN Elements { schema: inner } +``` + +**Properties**: +``` +compileProperties(json, definitions): + req = {} + opt = {} + IF json has "properties": + FOR EACH (key, schema) in json["properties"]: + req[key] = compile(schema, isRoot=false, definitions) + IF json has "optionalProperties": + FOR EACH (key, schema) in json["optionalProperties"]: + opt[key] = compile(schema, isRoot=false, definitions) + REQUIRE keys(req) INTERSECT keys(opt) == {} + additional = json.get("additionalProperties", false) + RETURN Properties { required: req, optional: opt, additional } +``` + +**Values**: +``` +compileValues(json, definitions): + inner = compile(json["values"], isRoot=false, definitions) + RETURN Values { schema: inner } +``` + +**Discriminator**: +``` +compileDiscriminator(json, definitions): + tag = json["discriminator"] -- must be a string + REQUIRE json has "mapping" + mapping = {} + FOR EACH (key, schema) in json["mapping"]: + node = compile(schema, isRoot=false, definitions) + REQUIRE node is Properties -- not Nullable, not any other form + REQUIRE tag NOT IN node.required + REQUIRE tag NOT IN node.optional + mapping[key] = node + RETURN Discrim { tag, mapping } +``` + +### 2.6 Compile-Time Invariants + +After compilation, the following are guaranteed: +- Every `Ref.name` resolves to an entry in `definitions`. +- Every `Discrim.mapping` value is a `Properties` node (not nullable). +- No `Properties` node has overlapping required/optional keys. +- The AST is immutable. No node is modified after construction. + +## 3. Runtime Phase: The Work Stack + +### 3.1 Data Structures + +**Frame** -- one unit of pending work: +``` +Frame = { + node: Node, -- which AST node to validate against + instance: JsonValue, -- which piece of the document to inspect + instancePath: String, -- JSON Pointer into the document (e.g. "/foo/0/bar") + schemaPath: String, -- JSON Pointer into the schema (e.g. "/properties/foo") + discriminatorTag: String? -- carried from Discrim to variant Properties +} +``` + +**Error** -- one validation failure: +``` +Error = { + instancePath: String, -- JSON Pointer into the document + schemaPath: String -- JSON Pointer into the schema +} +``` + +**State** -- the complete validation state: +``` +State = { + stack: Stack, -- LIFO work stack + errors: List, -- accumulated errors + definitions: Map -- from the compiled Root +} +``` + +### 3.2 Main Loop + +``` +validate(root: Root, instance: JsonValue) -> List: + state = { + stack: [ Frame(root.schema, instance, "", "", null) ], + errors: [], + definitions: root.definitions + } + + WHILE state.stack is not empty: + frame = state.stack.pop() + step(frame, state) + + RETURN state.errors +``` + +The loop is iterative. There is no recursion. The stack depth is bounded by +the document's structural depth, not by the schema's complexity. This +prevents stack overflow on deeply nested documents. + +Validation does **not** short-circuit. All frames are processed. All errors +are collected. + +### 3.3 The Step Function + +``` +step(frame, state): + node = frame.node + + -- Nullable check: intercepts before any form logic + IF node is Nullable: + IF frame.instance is null: + RETURN -- null is valid; nothing to push + ELSE: + -- Unwrap and re-step with the inner node + step(Frame(node.inner, frame.instance, frame.instancePath, + frame.schemaPath, frame.discriminatorTag), state) + RETURN + + MATCH node: + Empty -> pass(frame, state) + Ref -> stepRef(frame, state) + Type -> stepType(frame, state) + Enum -> stepEnum(frame, state) + Elements -> stepElements(frame, state) + Properties -> stepProperties(frame, state) + Values -> stepValues(frame, state) + Discrim -> stepDiscriminator(frame, state) +``` + +## 4. Step Functions + +Each step function validates the current level, then pushes child frames +for descent. If the current level fails its type guard, no children are +pushed. + +### 4.1 Empty + +``` +pass(frame, state): + -- Accept anything. Push nothing. +``` + +### 4.2 Ref + +``` +stepRef(frame, state): + target = state.definitions[frame.node.name] + -- Replace the Ref node with its target and re-step. + -- The frame's paths are preserved (the ref is transparent). + step(Frame(target, frame.instance, frame.instancePath, + frame.schemaPath, frame.discriminatorTag), state) +``` + +Recursive refs (a definition that references itself) are legal in RFC 8927. +This works naturally because each ref resolution pushes work onto the stack +(indirectly, via the resolved node's step function). The explicit stack +bounds memory usage to the document's depth, not the schema's recursion +depth. + +### 4.3 Type + +``` +stepType(frame, state): + ok = MATCH frame.node.type: + "boolean" -> frame.instance is a JSON boolean + "string" -> frame.instance is a JSON string + "timestamp" -> frame.instance is a JSON string + AND matches RFC 3339 (with leap-second normalization) + "float32" -> frame.instance is a JSON number + "float64" -> frame.instance is a JSON number + "int8" -> isIntInRange(frame.instance, -128, 127) + "uint8" -> isIntInRange(frame.instance, 0, 255) + "int16" -> isIntInRange(frame.instance, -32768, 32767) + "uint16" -> isIntInRange(frame.instance, 0, 65535) + "int32" -> isIntInRange(frame.instance, -2147483648, 2147483647) + "uint32" -> isIntInRange(frame.instance, 0, 4294967295) + + IF NOT ok: + state.errors += Error(frame.instancePath, frame.schemaPath + "/type") +``` + +Helper: +``` +isIntInRange(value, min, max): + REQUIRE value is a JSON number + REQUIRE value has zero fractional part (e.g. 3.0 is integer, 3.5 is not) + REQUIRE min <= value <= max +``` + +### 4.4 Enum + +``` +stepEnum(frame, state): + IF frame.instance is NOT a JSON string: + state.errors += Error(frame.instancePath, frame.schemaPath + "/enum") + RETURN + + IF frame.instance.stringValue NOT IN frame.node.values: + state.errors += Error(frame.instancePath, frame.schemaPath + "/enum") +``` + +### 4.5 Elements + +``` +stepElements(frame, state): + IF frame.instance is NOT a JSON array: + state.errors += Error(frame.instancePath, frame.schemaPath + "/elements") + RETURN + + -- Push one child frame per array element + FOR i = 0 TO length(frame.instance) - 1: + child = Frame( + node: frame.node.schema, + instance: frame.instance[i], + instancePath: frame.instancePath + "/" + str(i), + schemaPath: frame.schemaPath + "/elements", + discriminatorTag: null + ) + state.stack.push(child) +``` + +### 4.6 Properties + +This is the only form with three concerns: missing-key checks, child +descent, and additional-key rejection. + +``` +stepProperties(frame, state): + IF frame.instance is NOT a JSON object: + -- RFC 8927 §3.3.6: point to "properties" if it exists, else "optionalProperties" + LET guardKey = "properties" IF frame.node.required is non-empty ELSE "optionalProperties" + state.errors += Error(frame.instancePath, frame.schemaPath + "/" + guardKey) + RETURN + + obj = frame.instance + + -- 1. Missing required properties + FOR EACH (key, _) IN frame.node.required: + IF key NOT IN obj: + state.errors += Error(frame.instancePath, frame.schemaPath + "/properties/" + key) + + -- 2. Additional properties check + IF NOT frame.node.additional: + FOR EACH key IN keys(obj): + IF key NOT IN frame.node.required + AND key NOT IN frame.node.optional + AND key != frame.discriminatorTag: -- discriminator tag exemption + state.errors += Error(frame.instancePath + "/" + key, frame.schemaPath) + + -- 3. Push child frames for required properties (if present in instance) + FOR EACH (key, childNode) IN frame.node.required: + IF key == frame.discriminatorTag: SKIP -- already validated by Discrim + IF key IN obj: + state.stack.push(Frame( + node: childNode, + instance: obj[key], + instancePath: frame.instancePath + "/" + key, + schemaPath: frame.schemaPath + "/properties/" + key, + discriminatorTag: null + )) + + -- 4. Push child frames for optional properties (if present in instance) + FOR EACH (key, childNode) IN frame.node.optional: + IF key == frame.discriminatorTag: SKIP + IF key IN obj: + state.stack.push(Frame( + node: childNode, + instance: obj[key], + instancePath: frame.instancePath + "/" + key, + schemaPath: frame.schemaPath + "/optionalProperties/" + key, + discriminatorTag: null + )) +``` + +### 4.7 Values + +``` +stepValues(frame, state): + IF frame.instance is NOT a JSON object: + state.errors += Error(frame.instancePath, frame.schemaPath + "/values") + RETURN + + FOR EACH (key, value) IN frame.instance: + state.stack.push(Frame( + node: frame.node.schema, + instance: value, + instancePath: frame.instancePath + "/" + key, + schemaPath: frame.schemaPath + "/values", + discriminatorTag: null + )) +``` + +### 4.8 Discriminator + +The discriminator form is a 5-step sequential check. If any step fails, +no child frames are pushed. + +``` +stepDiscriminator(frame, state): + -- Step 1: Must be an object + IF frame.instance is NOT a JSON object: + state.errors += Error(frame.instancePath, frame.schemaPath + "/discriminator") + RETURN + + obj = frame.instance + + -- Step 2: Tag property must exist + IF frame.node.tag NOT IN obj: + state.errors += Error(frame.instancePath, frame.schemaPath + "/discriminator") + RETURN + + tagValue = obj[frame.node.tag] + + -- Step 3: Tag must be a string + IF tagValue is NOT a JSON string: + state.errors += Error(frame.instancePath + "/" + frame.node.tag, + frame.schemaPath + "/discriminator") + RETURN + + tagString = tagValue.stringValue + + -- Step 4: Tag value must be in mapping + IF tagString NOT IN frame.node.mapping: + state.errors += Error(frame.instancePath + "/" + frame.node.tag, + frame.schemaPath + "/mapping") + RETURN + + -- Step 5: Push variant frame with discriminator tag exemption + variantNode = frame.node.mapping[tagString] + state.stack.push(Frame( + node: variantNode, + instance: obj, -- same object, NOT the tag value + instancePath: frame.instancePath, + schemaPath: frame.schemaPath + "/mapping/" + tagString, + discriminatorTag: frame.node.tag -- passed to Properties for exemption + )) +``` + +## 5. Type Checking Reference + +Exact semantics for each `TypeKeyword`. + +### 5.1 boolean + +``` +value is a JSON boolean (true or false) +``` + +### 5.2 string + +``` +value is a JSON string +``` + +### 5.3 timestamp + +``` +value is a JSON string +AND value matches the RFC 3339 date-time production + (regex: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:(\d{2}|60)(\.\d+)?(Z|[+-]\d{2}:\d{2})$) +AND the date-time is parseable (accounting for leap seconds by + normalizing :60 to :59 before parsing) +``` + +### 5.4 float32, float64 + +``` +value is a JSON number (any finite number; no range check) +``` + +Note: RFC 8927 does not distinguish float32 from float64 at the validation +level. Both accept any JSON number. + +### 5.5 Integer types + +All integer types share the same two-step check: + +``` +value is a JSON number +AND value has zero fractional part (floor(value) == value) +AND value is within the type's range (inclusive) +``` + +| Type | Min | Max | +|---|---|---| +| int8 | -128 | 127 | +| uint8 | 0 | 255 | +| int16 | -32768 | 32767 | +| uint16 | 0 | 65535 | +| int32 | -2147483648 | 2147483647 | +| uint32 | 0 | 4294967295 | + +Note: `3.0` is a valid int8. `3.5` is not. This is value-based, not +syntax-based. + +## 6. Multi-Root Support + +RFC 8927 defines a single-document schema. This section extends it to +support multiple schema documents that reference each other. + +### 6.1 Root Registry + +``` +Roots = Map -- immutable after compilation +``` + +### 6.2 Compile-Time Resolution + +Compilation uses a LIFO work stack of document URIs: + +``` +compileAll(initialJson, initialUri) -> Roots: + work = Stack + built = Map + + work.push(normalize(initialUri)) + + WHILE work is not empty: + uri = work.pop() + IF uri IN built: CONTINUE -- dedup: at-most-once per URI + + json = fetch(uri, initialJson) -- fetch or use initialJson if uri matches + root = compileRoot(json) + + -- Scan for remote refs + FOR EACH Ref node in root.schema (recursively): + targetUri = resolveUri(node.name, uri) + IF targetUri.document != uri AND targetUri.document NOT IN built: + work.push(targetUri.document) + + built[uri] = root + + RETURN freeze(built) -- immutable +``` + +### 6.3 Runtime with Multiple Roots + +In the initial implementation, runtime validation uses only the primary root. +Remote refs are compiled and stored but not traversed at runtime. This +preserves compatibility with single-document behavior. + +Future extensions resolve remote refs at runtime by looking up the target +root in the Roots registry and pushing frames that reference nodes from +different roots. + +## 7. Error Format + +Errors follow RFC 8927 Section 3.3, which defines error indicators as +pairs of JSON Pointers: + +``` +Error = { + instancePath: String, -- JSON Pointer (RFC 6901) into the instance + schemaPath: String -- JSON Pointer (RFC 6901) into the schema +} +``` + +The `instancePath` points to the value that failed. The `schemaPath` points +to the schema keyword that caused the failure. + +Implementations MAY enrich errors with additional information (character +offsets, human-readable breadcrumb trails, etc.) but MUST always include +the two JSON Pointer fields. + +### 7.1 Schema Path Construction + +Each step function appends to `schemaPath` as it descends: + +| Form | Appended path component(s) | +|---|---| +| Type | `/type` | +| Enum | `/enum` | +| Elements (type guard) | `/elements` | +| Elements (child) | `/elements` | +| Properties (type guard) | `/properties` (or `/optionalProperties` if schema has no `properties` member) | +| Properties (missing key) | `/properties/` | +| Properties (additional) | (nothing -- error at current path) | +| Properties (child req) | `/properties/` | +| Properties (child opt) | `/optionalProperties/` | +| Values (type guard) | `/values` | +| Values (child) | `/values` | +| Discrim (not object) | `/discriminator` | +| Discrim (tag missing) | `/discriminator` | +| Discrim (tag not string) | `/discriminator` | +| Discrim (tag not in map) | `/mapping` | +| Discrim (variant) | `/mapping/` | + +### 7.2 Instance Path Construction + +| Descent into | Appended to instancePath | +|---|---| +| Array element at index `i` | `/` | +| Object property with key `k` | `/` | +| Discriminator tag value | `/` | +| Discriminator variant | (nothing -- same object) | +| Ref target | (nothing -- transparent) | + +## 8. Implementation Notes + +### 8.1 Stack Ordering + +The work stack is LIFO (depth-first). The last child pushed is the first +validated. For properties, this means children are validated in reverse +insertion order. The error *set* is the same regardless of stack ordering; +only the error *order* may differ. RFC 8927 does not specify error ordering. + +### 8.2 Discriminator Tag Exemption + +When a Discriminator pushes a variant Properties frame, it passes the tag +field name as `discriminatorTag`. The Properties step function uses this to: +- Skip the tag field when checking additional properties. +- Skip the tag field when pushing child frames (it was already validated + by the Discriminator step). + +This is the only case where state flows between step functions. + +### 8.3 No Short-Circuit + +Validation processes all frames even after encountering errors. This +ensures all errors are reported in a single pass. An implementation MAY +offer an optional `maxErrors` parameter to bound error collection. + +### 8.4 Immutability + +The AST is immutable after compilation. The Frame is immutable. The only +mutable state during validation is the work stack and the error list. +This makes the validation loop trivially thread-safe since the stack and +error list are created per `validate()` call. + +### 8.5 Memory + +Each frame is a small tuple (5 fields). Stack depth equals document nesting +depth. Memory is O(depth * breadth) in the worst case (a wide object with +many properties each needing validation). + +The explicit stack replaces the language's call stack, preventing stack +overflow on deeply nested or recursive documents. + +## 9. Conformance + +An implementation conforms to this spec if: + +1. It compiles any valid RFC 8927 schema into the AST defined in Section 2.3. +2. It rejects invalid schemas at compile time per the constraints in Section 2.6. +3. It validates any JSON instance against a compiled AST using the step + functions defined in Section 4, producing the error paths defined in + Section 7. +4. It passes the official JTD validation test suite (`validation.json` from + `json-typedef-spec`). +5. It passes the official JTD invalid schema test suite (`invalid_schemas.json`). + +## 10. Worked Example + +Schema: +```json +{ + "properties": { + "name": { "type": "string" }, + "age": { "type": "uint8" }, + "tags": { "elements": { "type": "string" } } + }, + "optionalProperties": { + "email": { "type": "string" } + } +} +``` + +Instance: +```json +{ + "name": "Alice", + "age": 300, + "tags": ["a", 42], + "extra": true +} +``` + +### Compiled AST + +``` +Properties { + required: { + "name" -> Type { type: "string" }, + "age" -> Type { type: "uint8" }, + "tags" -> Elements { schema: Type { type: "string" } } + }, + optional: { + "email" -> Type { type: "string" } + }, + additional: false +} +``` + +### Validation Trace + +``` +Stack: [Frame(Properties, root, "", "")] +Pop: Frame(Properties, root, "", "") + -> Type guard: root is object? YES + -> Missing keys: all 3 required keys present? YES + -> Additional keys: "extra" not in required/optional -> ERROR("/extra", "") + -> Push children: + Frame(Type("string"), "Alice", "/name", "/properties/name") + Frame(Type("uint8"), 300, "/age", "/properties/age") + Frame(Elements, [...], "/tags", "/properties/tags") + +Stack: [Elements, Type("uint8"), Type("string")] +Pop: Frame(Elements, ["a",42], "/tags", "/properties/tags") + -> Type guard: is array? YES + -> Push children: + Frame(Type("string"), "a", "/tags/0", "/properties/tags/elements") + Frame(Type("string"), 42, "/tags/1", "/properties/tags/elements") + +Stack: [Type("string")/42, Type("string")/"a", Type("uint8"), Type("string")] +Pop: Frame(Type("string"), 42, "/tags/1", "/properties/tags/elements") + -> 42 is not a string -> ERROR("/tags/1", "/properties/tags/elements/type") + +Pop: Frame(Type("string"), "a", "/tags/0", "/properties/tags/elements") + -> "a" is a string -> OK + +Pop: Frame(Type("uint8"), 300, "/age", "/properties/age") + -> 300 is number, zero fractional, but 300 > 255 -> ERROR("/age", "/properties/age/type") + +Pop: Frame(Type("string"), "Alice", "/name", "/properties/name") + -> "Alice" is a string -> OK + +Stack empty. Done. +``` + +### Errors Collected + +``` +[ + { instancePath: "/extra", schemaPath: "" }, + { instancePath: "/tags/1", schemaPath: "/properties/tags/elements/type" }, + { instancePath: "/age", schemaPath: "/properties/age/type" } +] +``` diff --git a/json-java21-jtd/PROBE_TESTS_SUMMARY.md b/json-java21-jtd/PROBE_TESTS_SUMMARY.md new file mode 100644 index 0000000..5617786 --- /dev/null +++ b/json-java21-jtd/PROBE_TESTS_SUMMARY.md @@ -0,0 +1,209 @@ +# Probe Tests Summary + +This document summarizes all the probe tests added to identify potential issues in the JTD implementation. + +## Test Files Added + +### 1. `ErrorFormatComplianceProbe.java` +**Purpose**: Verify RFC 8927 error format compliance + +**Key Probes**: +- Error format should be `{instancePath, schemaPath}` pairs (not enriched strings) +- Schema paths should point to schema keywords (`/type`, `/properties/foo`, etc.) +- Instance paths should be RFC 6901 JSON Pointers +- Multiple errors should ALL be collected +- Error consistency across different violations + +**Expected Issues to Find**: +- Implementation returns enriched strings like `[off=N ptr=# via=#] message` +- No RFC 6901 compliance for instance paths +- Schema paths not tracked separately + +### 2. `DiscriminatorEdgeCaseProbe.java` +**Purpose**: Test discriminator schema edge cases + +**Key Probes**: +- Missing vs non-string discriminator error distinction +- Discriminator tag exemption from additionalProperties +- Discriminator key redefinition in properties (compile-time check) +- Discriminator key in optionalProperties (compile-time check) +- Empty discriminator mapping +- Discriminator with additionalProperties: true +- Nested discriminator +- Discriminator error short-circuiting +- Discriminator with null/empty string values +- Multiple discriminator values with common properties +- Conflicting property types across variants + +**Expected Issues to Find**: +- Missing vs non-string discriminator errors may be conflated +- Discriminator key not properly exempted from additionalProperties +- Variant validation may continue after discriminator error + +### 3. `PropertiesEdgeCaseProbe.java` +**Purpose**: Test properties form edge cases + +**Key Probes**: +- Empty properties with additionalProperties: false +- Empty properties without additionalProperties (default behavior) +- Special characters in property names (dots, slashes, spaces, null char) +- Multiple additional properties all reported +- Required property with null value vs missing property +- Optional property with various values +- Overlapping required and optional keys (compile-time check) +- Nested properties with different additionalProperties settings +- Property validation order +- Empty string property names +- Very deep nesting (50 levels) +- Large number of properties (100) +- Property name collisions with prototype pollution concerns + +**Expected Issues to Find**: +- Empty properties may not properly reject additional properties +- Special characters in property names may not be handled correctly +- Deep nesting performance issues +- Prototype pollution vulnerability + +### 4. `TypeValidationEdgeCaseProbe.java` +**Purpose**: Test type validation edge cases + +**Key Probes**: +- Integer boundary values (exact min/max for all integer types) +- Integer fractional detection (3.0 is int, 3.1 is not) +- Scientific notation handling +- Float types accept any number +- Timestamp format variations +- Invalid timestamp formats +- Boolean type strictness +- String type strictness +- Very large integers +- Zero values for all integer types +- Negative values for unsigned types +- No type coercion +- BigDecimal values exceeding long precision + +**Expected Issues to Find**: +- Integer validation may not properly handle fractional values +- Scientific notation may not be handled correctly +- Timestamp regex may be too strict or too lenient +- Very large numbers may cause precision issues + +### 5. `RefEdgeCaseProbe.java` +**Purpose**: Test ref schema edge cases + +**Key Probes**: +- Forward reference resolution +- Mutual recursion +- Deeply nested refs (50 levels) +- Ref in elements context +- Ref in values context +- Ref in properties context +- Ref to empty schema +- Ref to discriminator +- Ref to nullable schema +- Ref to elements/values/properties/enum/type +- Unused definitions +- Multiple refs to same definition +- Complex recursive ref (binary tree) +- Ref in optionalProperties +- Multi-level ref resolution + +**Expected Issues to Find**: +- Forward references may not resolve correctly +- Deep nesting may cause stack issues +- Circular references may not be handled + +### 6. `ElementsEdgeCaseProbe.java` +**Purpose**: Test elements form edge cases + +**Key Probes**: +- Empty array validation +- Single element array +- Nested elements (2D/3D arrays) +- Elements with properties schema +- Elements with discriminator +- Elements error collection (all elements validated) +- Elements with strict nested objects +- Large array performance (1000 elements) +- Array with null elements +- Array with nullable elements +- Mixed valid and invalid elements +- Empty schema in elements +- Elements with ref +- Elements error path construction +- Nested elements error path +- Elements with object additional properties +- Multiple arrays in same schema +- Elements with values schema +- Elements with enum +- Sparse array +- Elements with complex nested structure +- Array type guard +- Elements with timestamp/boolean/float types +- Elements with integer boundaries + +**Expected Issues to Find**: +- Error path construction for nested arrays +- Large array performance +- Error collection for all invalid elements + +### 7. `NullableEdgeCaseProbe.java` +**Purpose**: Test nullable modifier edge cases + +**Key Probes**: +- Nullable type accepts null +- Non-nullable type rejects null +- Nullable explicit false +- Nullable on empty schema +- Nullable on enum/elements/properties/values/discriminator/ref +- Nested nullable +- Nullable property value +- Nullable required property +- Nullable optional property +- Nullable array element +- Nullable values in object +- Nullable with all integer types +- Nullable with float types +- Nullable with timestamp/boolean +- Nullable error messages +- Nullable must be boolean compilation check +- Nullable in definitions +- Nullable with complex nested schema +- Nullable discriminator mapping value (compile-time check) +- Multiple nullable fields +- Nullable with additionalProperties + +**Expected Issues to Find**: +- Nullable discriminator mapping may not be rejected at compile time +- Nullable compilation may accept non-boolean values + +## Running the Tests + +```bash +# Run all probe tests +$(command -v mvnd || command -v mvn || command -v ./mvnw) test -pl json-java21-jtd -Dtest="*Probe" + +# Run specific probe test +$(command -v mvnd || command -v mvn || command -v ./mvnw) test -pl json-java21-jtd -Dtest=ErrorFormatComplianceProbe + +# Run with detailed logging +$(command -v mvnd || command -v mvn || command -v ./mvnw) test -pl json-java21-jtd -Dtest="*Probe" -Djava.util.logging.ConsoleHandler.level=FINE +``` + +## Expected Outcomes + +These tests are designed to **document current behavior** and **expose deviations** from the specification. They do not fix any code. + +Key deviations expected: +1. **Error format**: Returns enriched strings instead of RFC 8927 error indicators +2. **Schema paths**: Not tracked or reported per RFC 8927 +3. **Discriminator**: May conflate missing vs non-string discriminator errors +4. **Empty properties**: May not properly reject additional properties by default +5. **Nullable discriminator mapping**: May not be rejected at compile time + +## Notes + +- All probe tests extend `JtdTestBase` for consistent logging +- Tests use INFO level logging at method start as per AGENTS.md +- Tests document both passing and failing assertions +- Tests include comments explaining the expected vs actual behavior diff --git a/json-java21-jtd/README.md b/json-java21-jtd/README.md index f034c7a..ca901dc 100644 --- a/json-java21-jtd/README.md +++ b/json-java21-jtd/README.md @@ -186,6 +186,62 @@ Validation errors include standardized information: ./mvnw test -pl json-java21-jtd -am -Djava.util.logging.ConsoleHandler.level=FINE ``` +## Functional Validator API + +A schema can be compiled into a reusable `JtdValidator` -- a functional interface +(`JsonValue -> JtdValidationResult`) suitable for stream pipelines: + +```java +import json.java21.jtd.JtdValidator; +import json.java21.jtd.JtdValidationResult; +import jdk.sandbox.java.util.json.*; + +String schemaJson = """ + { "type": "string" } + """; +JsonValue schema = Json.parse(schemaJson); + +// Compile to a reusable validator (interpreter path, always available) +JtdValidator validator = JtdValidator.compile(schema); + +JtdValidationResult result = validator.validate(Json.parse("\"hello\"")); +assert result.isValid(); + +// Use in a stream pipeline +List docs = ...; +List invalid = docs.stream() + .filter(doc -> !validator.validate(doc).isValid()) + .toList(); +``` + +Errors follow RFC 8927 exactly -- each error is an `(instancePath, schemaPath)` pair: + +```java +JtdValidationResult result = validator.validate(Json.parse("42")); +result.errors().forEach(e -> + System.out.println(e.instancePath() + " -> " + e.schemaPath())); +// Output: "" -> "/type" +``` + +### Generated Validators (optional, JDK 24+) + +When the `json-java21-jtd-codegen` module is on the classpath **and** the build +runs on JDK 24+, the factory can generate optimised bytecode validators that +contain only the checks the schema requires -- no interpreter, no AST, no +runtime stack: + +```java +// Throws if codegen module is not on the classpath +JtdValidator fast = JtdValidator.compileGenerated(schema); +``` + +The generated classfiles target Java 21 (class version 65) so they run on any +JDK 21+ runtime. The `toString()` of a generated validator returns the original +JTD schema JSON. + +If you do not need the generated path, the interpreter path (`JtdValidator.compile`) +works everywhere with zero extra dependencies. + ## Architecture The validator uses a stack-based approach for efficient validation: @@ -196,6 +252,8 @@ The validator uses a stack-based approach for efficient validation: - **Comprehensive Testing**: Full RFC 8927 compliance test suite See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed implementation information. +See [JTD_STACK_MACHINE_SPEC.md](JTD_STACK_MACHINE_SPEC.md) for the interpreter specification. +See [JTD_CODEGEN_SPEC.md](JTD_CODEGEN_SPEC.md) for the code generation specification. ## RFC 8927 Compliance @@ -214,6 +272,7 @@ This implementation is fully compliant with RFC 8927: - **Stack-based validation** prevents StackOverflowError - **Early exit** on first validation error - **Immutable design** enables safe concurrent use +- **Optional codegen** (JDK 24+ build) eliminates interpreter overhead for hot-path validation ## License diff --git a/json-java21-jtd/src/main/java/json/java21/jtd/Frame.java b/json-java21-jtd/src/main/java/json/java21/jtd/Frame.java index a99442c..07d098d 100644 --- a/json-java21-jtd/src/main/java/json/java21/jtd/Frame.java +++ b/json-java21-jtd/src/main/java/json/java21/jtd/Frame.java @@ -2,11 +2,24 @@ import jdk.sandbox.java.util.json.JsonValue; -/// Stack frame for iterative validation with path and offset tracking -record Frame(JtdSchema schema, JsonValue instance, String ptr, Crumbs crumbs, String discriminatorKey) { - /// Constructor for normal validation without discriminator context +/// Stack frame for iterative validation with path and offset tracking. +/// +/// - `ptr` is the JSON Pointer into the **instance** (RFC 6901). +/// - `schemaPath` is the JSON Pointer into the **schema** (RFC 8927 §3.3). +/// - `crumbs` is a human-readable breadcrumb trail for the enriched error format. +/// - `discriminatorKey` carries the tag field name from a Discriminator to its +/// variant Properties step (see JTD_STACK_MACHINE_SPEC.md §4.8). +record Frame(JtdSchema schema, JsonValue instance, String ptr, Crumbs crumbs, + String schemaPath, String discriminatorKey) { + + /// Constructor for normal validation without discriminator context or schema path Frame(JtdSchema schema, JsonValue instance, String ptr, Crumbs crumbs) { - this(schema, instance, ptr, crumbs, null); + this(schema, instance, ptr, crumbs, "", null); + } + + /// Constructor with schema path but no discriminator + Frame(JtdSchema schema, JsonValue instance, String ptr, Crumbs crumbs, String schemaPath) { + this(schema, instance, ptr, crumbs, schemaPath, null); } @Override @@ -14,6 +27,6 @@ public String toString() { final var kind = schema.getClass().getSimpleName(); final var tag = (schema instanceof JtdSchema.RefSchema r) ? "(ref=" + r.ref() + ")" : ""; return "Frame[schema=" + kind + tag + ", instance=" + instance + ", ptr=" + ptr + - ", crumbs=" + crumbs + ", discriminatorKey=" + discriminatorKey + "]"; + ", schemaPath=" + schemaPath + ", crumbs=" + crumbs + ", discriminatorKey=" + discriminatorKey + "]"; } } diff --git a/json-java21-jtd/src/main/java/json/java21/jtd/InterpreterValidator.java b/json-java21-jtd/src/main/java/json/java21/jtd/InterpreterValidator.java new file mode 100644 index 0000000..eaaea63 --- /dev/null +++ b/json-java21-jtd/src/main/java/json/java21/jtd/InterpreterValidator.java @@ -0,0 +1,254 @@ +package json.java21.jtd; + +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Logger; + +/// Stack-machine interpreter wrapped as a [JtdValidator]. +/// +/// Produces RFC 8927 `(instancePath, schemaPath)` error pairs by walking +/// the compiled [JtdSchema] AST with an explicit work stack. +final class InterpreterValidator implements JtdValidator { + + private static final Logger LOG = Logger.getLogger(InterpreterValidator.class.getName()); + + private final JtdSchema schema; + private final String schemaJson; + + InterpreterValidator(JtdSchema schema, Jtd jtd, String schemaJson) { + this.schema = schema; + this.schemaJson = schemaJson; + } + + @Override + public JtdValidationResult validate(JsonValue instance) { + LOG.fine(() -> "InterpreterValidator validating instance"); + final var errors = new ArrayList(); + final var stack = new java.util.ArrayDeque(); + + stack.push(new Frame(schema, instance, "", Crumbs.root(), "", null)); + + while (!stack.isEmpty()) { + final var frame = stack.pop(); + stepRfc8927(frame, stack, errors); + } + + return errors.isEmpty() + ? JtdValidationResult.success() + : JtdValidationResult.failure(errors); + } + + @Override + public String toString() { + return schemaJson; + } + + // ------------------------------------------------------------------ + // RFC 8927 step function producing (instancePath, schemaPath) pairs + // ------------------------------------------------------------------ + + private void stepRfc8927(Frame frame, java.util.Deque stack, List errors) { + final var node = frame.schema(); + + if (node instanceof JtdSchema.NullableSchema nullable) { + if (frame.instance() instanceof jdk.sandbox.java.util.json.JsonNull) return; + stepRfc8927(new Frame(nullable.wrapped(), frame.instance(), frame.ptr(), + frame.crumbs(), frame.schemaPath(), frame.discriminatorKey()), stack, errors); + return; + } + + switch (node) { + case JtdSchema.EmptySchema empty -> { /* accepts anything */ } + case JtdSchema.RefSchema ref -> { + final var resolved = ref.target(); + stepRfc8927(new Frame(resolved, frame.instance(), frame.ptr(), + frame.crumbs(), "/definitions/" + ref.ref(), frame.discriminatorKey()), stack, errors); + } + case JtdSchema.TypeSchema type -> stepType(frame, type, errors); + case JtdSchema.EnumSchema enumS -> stepEnum(frame, enumS, errors); + case JtdSchema.ElementsSchema elems -> stepElements(frame, elems, stack, errors); + case JtdSchema.PropertiesSchema props -> stepProperties(frame, props, stack, errors); + case JtdSchema.ValuesSchema vals -> stepValues(frame, vals, stack, errors); + case JtdSchema.DiscriminatorSchema disc -> stepDiscriminator(frame, disc, stack, errors); + case JtdSchema.NullableSchema n -> throw new AssertionError("unreachable: handled above"); + } + } + + private void stepType(Frame frame, JtdSchema.TypeSchema type, List errors) { + final var instance = frame.instance(); + final var ok = switch (type.type()) { + case "boolean" -> instance instanceof jdk.sandbox.java.util.json.JsonBoolean; + case "string" -> instance instanceof jdk.sandbox.java.util.json.JsonString; + case "timestamp" -> isTimestamp(instance); + case "float32", "float64" -> instance instanceof jdk.sandbox.java.util.json.JsonNumber; + case "int8" -> isIntInRange(instance, -128, 127); + case "uint8" -> isIntInRange(instance, 0, 255); + case "int16" -> isIntInRange(instance, -32768, 32767); + case "uint16" -> isIntInRange(instance, 0, 65535); + case "int32" -> isIntInRange(instance, Integer.MIN_VALUE, Integer.MAX_VALUE); + case "uint32" -> isIntInRange(instance, 0, 4294967295L); + default -> false; + }; + if (!ok) { + errors.add(new JtdValidationError(frame.ptr(), frame.schemaPath() + "/type")); + } + } + + private void stepEnum(Frame frame, JtdSchema.EnumSchema enumS, List errors) { + if (!(frame.instance() instanceof jdk.sandbox.java.util.json.JsonString str) + || !enumS.values().contains(str.string())) { + errors.add(new JtdValidationError(frame.ptr(), frame.schemaPath() + "/enum")); + } + } + + private void stepElements(Frame frame, JtdSchema.ElementsSchema elems, + java.util.Deque stack, List errors) { + if (!(frame.instance() instanceof jdk.sandbox.java.util.json.JsonArray arr)) { + errors.add(new JtdValidationError(frame.ptr(), frame.schemaPath() + "/elements")); + return; + } + final var childSchemaPath = frame.schemaPath() + "/elements"; + int i = 0; + for (final var element : arr.elements()) { + stack.push(new Frame(elems.elements(), element, + frame.ptr() + "/" + i, + frame.crumbs().withArrayIndex(i), + childSchemaPath, null)); + i++; + } + } + + private void stepProperties(Frame frame, JtdSchema.PropertiesSchema props, + java.util.Deque stack, List errors) { + if (!(frame.instance() instanceof jdk.sandbox.java.util.json.JsonObject obj)) { + final var guardPath = props.properties().isEmpty() ? "/optionalProperties" : "/properties"; + errors.add(new JtdValidationError(frame.ptr(), frame.schemaPath() + guardPath)); + return; + } + + final var members = obj.members(); + final var discKey = frame.discriminatorKey(); + final var sp = frame.schemaPath(); + + for (final var entry : props.properties().entrySet()) { + final var key = entry.getKey(); + if (!members.containsKey(key)) { + errors.add(new JtdValidationError(frame.ptr(), sp + "/properties/" + key)); + } + } + + if (!props.additionalProperties()) { + for (final var key : members.keySet()) { + if (!props.properties().containsKey(key) + && !props.optionalProperties().containsKey(key) + && !key.equals(discKey)) { + errors.add(new JtdValidationError(frame.ptr() + "/" + key, sp)); + } + } + } + + for (final var entry : props.properties().entrySet()) { + final var key = entry.getKey(); + if (key.equals(discKey)) continue; + final var value = members.get(key); + if (value != null) { + stack.push(new Frame(entry.getValue(), value, + frame.ptr() + "/" + key, + frame.crumbs().withObjectField(key), + sp + "/properties/" + key, null)); + } + } + + for (final var entry : props.optionalProperties().entrySet()) { + final var key = entry.getKey(); + if (key.equals(discKey)) continue; + final var value = members.get(key); + if (value != null) { + stack.push(new Frame(entry.getValue(), value, + frame.ptr() + "/" + key, + frame.crumbs().withObjectField(key), + sp + "/optionalProperties/" + key, null)); + } + } + } + + private void stepValues(Frame frame, JtdSchema.ValuesSchema vals, + java.util.Deque stack, List errors) { + if (!(frame.instance() instanceof jdk.sandbox.java.util.json.JsonObject obj)) { + errors.add(new JtdValidationError(frame.ptr(), frame.schemaPath() + "/values")); + return; + } + final var childSchemaPath = frame.schemaPath() + "/values"; + for (final var entry : obj.members().entrySet()) { + stack.push(new Frame(vals.values(), entry.getValue(), + frame.ptr() + "/" + entry.getKey(), + frame.crumbs().withObjectField(entry.getKey()), + childSchemaPath, null)); + } + } + + private void stepDiscriminator(Frame frame, JtdSchema.DiscriminatorSchema disc, + java.util.Deque stack, List errors) { + if (!(frame.instance() instanceof jdk.sandbox.java.util.json.JsonObject obj)) { + errors.add(new JtdValidationError(frame.ptr(), frame.schemaPath() + "/discriminator")); + return; + } + + final var members = obj.members(); + final var sp = frame.schemaPath(); + + if (!members.containsKey(disc.discriminator())) { + errors.add(new JtdValidationError(frame.ptr(), sp + "/discriminator")); + return; + } + + final var tagValue = members.get(disc.discriminator()); + if (!(tagValue instanceof jdk.sandbox.java.util.json.JsonString tagStr)) { + errors.add(new JtdValidationError( + frame.ptr() + "/" + disc.discriminator(), + sp + "/discriminator")); + return; + } + + final var variant = disc.mapping().get(tagStr.string()); + if (variant == null) { + errors.add(new JtdValidationError( + frame.ptr() + "/" + disc.discriminator(), + sp + "/mapping")); + return; + } + + stack.push(new Frame(variant, frame.instance(), frame.ptr(), + frame.crumbs(), + sp + "/mapping/" + tagStr.string(), + disc.discriminator())); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private static boolean isTimestamp(JsonValue instance) { + if (!(instance instanceof jdk.sandbox.java.util.json.JsonString str)) return false; + final var value = str.string(); + if (!JtdSchema.TypeSchema.RFC3339.matcher(value).matches()) return false; + try { + final var normalized = value.replace(":60", ":59"); + java.time.OffsetDateTime.parse(normalized, java.time.format.DateTimeFormatter.ISO_OFFSET_DATE_TIME); + return true; + } catch (Exception e) { + return false; + } + } + + private static boolean isIntInRange(JsonValue instance, long min, long max) { + if (!(instance instanceof jdk.sandbox.java.util.json.JsonNumber num)) return false; + final var d = num.toDouble(); + if (d != Math.floor(d)) return false; + if (d > Long.MAX_VALUE || d < Long.MIN_VALUE) return false; + final var l = num.toLong(); + return l >= min && l <= max; + } +} diff --git a/json-java21-jtd/src/main/java/json/java21/jtd/Jtd.java b/json-java21-jtd/src/main/java/json/java21/jtd/Jtd.java index 148c44b..7c8c4d6 100644 --- a/json-java21-jtd/src/main/java/json/java21/jtd/Jtd.java +++ b/json-java21-jtd/src/main/java/json/java21/jtd/Jtd.java @@ -57,6 +57,16 @@ public void compile(JsonValue schema) { compileSchema(schema); } + /// Compiles a JTD schema and returns the immutable AST. + /// + /// @param schema The JTD schema as a JsonValue + /// @return the compiled [JtdSchema] AST + /// @throws IllegalArgumentException if the schema is invalid + public JtdSchema compileToSchema(JsonValue schema) { + definitions.clear(); + return compileSchema(schema); + } + /// Validates a JSON instance against a JTD schema /// @param schema The JTD schema as a JsonValue /// @param instance The JSON instance to validate @@ -253,7 +263,7 @@ void pushChildFrames(Frame frame, java.util.Deque stack) { JtdSchema variantSchema = discSchema.mapping().get(discriminatorValueStr); if (variantSchema != null) { - Frame variantFrame = new Frame(variantSchema, instance, frame.ptr(), frame.crumbs(), discSchema.discriminator()); + Frame variantFrame = new Frame(variantSchema, instance, frame.ptr(), frame.crumbs(), frame.schemaPath(), discSchema.discriminator()); stack.push(variantFrame); LOG.finer(() -> "Pushed discriminator variant frame for " + discriminatorValueStr + " with discriminator key: " + discSchema.discriminator()); } @@ -264,7 +274,7 @@ void pushChildFrames(Frame frame, java.util.Deque stack) { try { JtdSchema resolved = refSchema.target(); Frame resolvedFrame = new Frame(resolved, instance, frame.ptr(), - frame.crumbs(), frame.discriminatorKey()); + frame.crumbs(), frame.schemaPath(), frame.discriminatorKey()); pushChildFrames(resolvedFrame, stack); LOG.finer(() -> "Pushed ref schema resolved to " + resolved.getClass().getSimpleName() + " for ref: " + refSchema.ref()); diff --git a/json-java21-jtd/src/main/java/json/java21/jtd/JtdSchema.java b/json-java21-jtd/src/main/java/json/java21/jtd/JtdSchema.java index 6826e46..fcfc8ff 100644 --- a/json-java21-jtd/src/main/java/json/java21/jtd/JtdSchema.java +++ b/json-java21-jtd/src/main/java/json/java21/jtd/JtdSchema.java @@ -9,7 +9,7 @@ /// JTD Schema interface - validates JSON instances against JTD schemas /// Following RFC 8927 specification with eight mutually-exclusive schema forms -sealed interface JtdSchema { +public sealed interface JtdSchema { /// Core frame-based validation that all schema variants must implement. /// @param frame Current validation frame @@ -68,7 +68,7 @@ public boolean validateWithFrame(Frame frame, java.util.List errors, boo /// Ref schema - references a definition in the schema's definitions record RefSchema(String ref, java.util.Map definitions) implements JtdSchema { - JtdSchema target() { + public JtdSchema target() { JtdSchema schema = definitions.get(ref); if (schema == null) { throw new IllegalStateException("Ref not resolved: " + ref); @@ -86,7 +86,7 @@ public Jtd.Result validate(JsonValue instance) { public boolean validateWithFrame(Frame frame, java.util.List errors, boolean verboseErrors) { JtdSchema resolved = target(); Frame resolvedFrame = new Frame(resolved, frame.instance(), frame.ptr(), - frame.crumbs(), frame.discriminatorKey()); + frame.crumbs(), frame.schemaPath(), frame.discriminatorKey()); return resolved.validateWithFrame(resolvedFrame, errors, verboseErrors); } @@ -99,7 +99,7 @@ public String toString() { /// Type schema - validates specific primitive types record TypeSchema(String type) implements JtdSchema { /// RFC 3339 timestamp pattern with leap second support - private static final java.util.regex.Pattern RFC3339 = java.util.regex.Pattern.compile( + static final java.util.regex.Pattern RFC3339 = java.util.regex.Pattern.compile( "^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:(\\d{2}|60)(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2}))$" ); diff --git a/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidationError.java b/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidationError.java new file mode 100644 index 0000000..6691259 --- /dev/null +++ b/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidationError.java @@ -0,0 +1,15 @@ +package json.java21.jtd; + +/// RFC 8927 validation error indicator: a pair of JSON Pointers. +/// +/// - `instancePath` points to the value in the JSON document that failed. +/// - `schemaPath` points to the keyword in the JTD schema that caused the failure. +/// +/// Both paths follow RFC 6901 (JSON Pointer) notation. +public record JtdValidationError(String instancePath, String schemaPath) { + + @Override + public String toString() { + return "{instancePath=\"" + instancePath + "\", schemaPath=\"" + schemaPath + "\"}"; + } +} diff --git a/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidationResult.java b/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidationResult.java new file mode 100644 index 0000000..5fc9af4 --- /dev/null +++ b/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidationResult.java @@ -0,0 +1,21 @@ +package json.java21.jtd; + +import java.util.Collections; +import java.util.List; + +/// Result of validating a JSON instance against a JTD schema. +/// +/// When `isValid()` is true the error list is empty. +/// When `isValid()` is false at least one [JtdValidationError] is present. +public record JtdValidationResult(boolean isValid, List errors) { + + private static final JtdValidationResult SUCCESS = new JtdValidationResult(true, List.of()); + + public static JtdValidationResult success() { + return SUCCESS; + } + + public static JtdValidationResult failure(List errors) { + return new JtdValidationResult(false, Collections.unmodifiableList(errors)); + } +} diff --git a/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidator.java b/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidator.java new file mode 100644 index 0000000..915901f --- /dev/null +++ b/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidator.java @@ -0,0 +1,77 @@ +package json.java21.jtd; + +import jdk.sandbox.java.util.json.JsonValue; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Objects; +import java.util.logging.Logger; + +/// Functional interface for validating a JSON instance against a compiled JTD schema. +/// +/// Obtain an instance via the static factory methods: +/// - [#compile(JsonValue)] -- interpreter path, always available. +/// - [#compileGenerated(JsonValue)] -- bytecode-generated path, requires +/// `json-java21-jtd-codegen` on the classpath (JDK 24+ build). +@FunctionalInterface +public interface JtdValidator { + + Logger LOG = Logger.getLogger(JtdValidator.class.getName()); + + /// Validates an instance against the compiled schema. + /// + /// @param instance the JSON value to validate + /// @return the validation result with RFC 8927 error pairs + JtdValidationResult validate(JsonValue instance); + + // ------------------------------------------------------------------ + // Factory: interpreter path (always available) + // ------------------------------------------------------------------ + + /// Compiles a JTD schema into a reusable validator using the stack-machine + /// interpreter. Works on any JDK 21+ runtime with zero extra dependencies. + /// + /// @param schema the JTD schema as a parsed [JsonValue] + /// @return a reusable [JtdValidator] + /// @throws IllegalArgumentException if the schema is invalid per RFC 8927 + static JtdValidator compile(JsonValue schema) { + Objects.requireNonNull(schema, "schema must not be null"); + final var jtd = new Jtd(); + final var compiled = jtd.compileToSchema(schema); + return new InterpreterValidator(compiled, jtd, schema.toString()); + } + + // ------------------------------------------------------------------ + // Factory: codegen path (optional, requires extra module) + // ------------------------------------------------------------------ + + String CODEGEN_CLASS = "json.java21.jtd.codegen.JtdCodegen"; + String CODEGEN_METHOD = "compile"; + + /// Compiles a JTD schema into a bytecode-generated validator. + /// Requires the `json-java21-jtd-codegen` module on the classpath. + /// + /// @param schema the JTD schema as a parsed [JsonValue] + /// @return a reusable [JtdValidator] backed by generated bytecode + /// @throws UnsupportedOperationException if the codegen module is not on the classpath + /// @throws IllegalArgumentException if the schema is invalid per RFC 8927 + static JtdValidator compileGenerated(JsonValue schema) { + Objects.requireNonNull(schema, "schema must not be null"); + try { + final var clazz = Class.forName(CODEGEN_CLASS); + final var method = clazz.getMethod(CODEGEN_METHOD, JsonValue.class); + return (JtdValidator) method.invoke(null, schema); + } catch (ClassNotFoundException e) { + throw new UnsupportedOperationException( + "Codegen module not on classpath. Add json-java21-jtd-codegen dependency, " + + "or use JtdValidator.compile() for the interpreter path.", e); + } catch (InvocationTargetException e) { + final var cause = e.getCause(); + if (cause instanceof IllegalArgumentException iae) throw iae; + if (cause instanceof RuntimeException re) throw re; + throw new RuntimeException("Codegen compilation failed", cause); + } catch (ReflectiveOperationException e) { + throw new UnsupportedOperationException("Codegen module found but incompatible", e); + } + } +} diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/CompilerSpecIT.java b/json-java21-jtd/src/test/java/json/java21/jtd/CompilerSpecIT.java index 58ae42f..a96abf6 100644 --- a/json-java21-jtd/src/test/java/json/java21/jtd/CompilerSpecIT.java +++ b/json-java21-jtd/src/test/java/json/java21/jtd/CompilerSpecIT.java @@ -84,41 +84,8 @@ private Stream runInvalidSchemaCompilationTests() throws Exception } private void extractTestData() throws IOException { - // Check if test data is already extracted - if (Files.exists(INVALID_SCHEMAS_FILE)) { - LOG.fine(() -> "JTD invalid schemas test suite already extracted at: " + INVALID_SCHEMAS_FILE); - return; - } - - // Extract the embedded test suite - Path zipFile = Paths.get("src/test/resources/jtd-test-suite.zip"); - Path targetDir = Paths.get("target/test-data"); - - if (!Files.exists(zipFile)) { - throw new RuntimeException("JTD test suite ZIP not found: " + zipFile.toAbsolutePath()); - } - - LOG.info(() -> "Extracting JTD test suite from: " + zipFile); - - // Create target directory - Files.createDirectories(targetDir); - - // Extract ZIP file - try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(zipFile))) { - java.util.zip.ZipEntry entry; - while ((entry = zis.getNextEntry()) != null) { - if (!entry.isDirectory() && entry.getName().startsWith("json-typedef-spec-")) { - Path outputPath = targetDir.resolve(entry.getName()); - Files.createDirectories(outputPath.getParent()); - Files.copy(zis, outputPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING); - } - zis.closeEntry(); - } - } - - if (!Files.exists(INVALID_SCHEMAS_FILE)) { - throw new RuntimeException("Extraction completed but test file not found: " + INVALID_SCHEMAS_FILE); - } + // Use shared extractor to ensure test data is available + JtdTestDataExtractor.ensureValidationTestData(); } @SuppressWarnings("SameParameterValue") diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/DiscriminatorEdgeCaseProbe.java b/json-java21-jtd/src/test/java/json/java21/jtd/DiscriminatorEdgeCaseProbe.java new file mode 100644 index 0000000..1008065 --- /dev/null +++ b/json-java21-jtd/src/test/java/json/java21/jtd/DiscriminatorEdgeCaseProbe.java @@ -0,0 +1,384 @@ +package json.java21.jtd; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonValue; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +/// Probes for Discriminator schema edge cases and potential issues +/// +/// Areas to probe: +/// 1. Discriminator field handling when present vs missing +/// 2. Discriminator field exemption from additionalProperties +/// 3. Discriminator in optionalProperties vs properties +/// 4. Empty discriminator object validation +/// 5. Discriminator with empty mapping value +public class DiscriminatorEdgeCaseProbe extends JtdTestBase { + + /// Test: Missing discriminator field should produce correct error + /// RFC 8927 §3.3.8: Step 2 - check if discriminator property exists + /// This should be distinguishable from "discriminator present but not string" + @Test + public void probeMissingVsNonStringDiscriminatorErrors() { + JsonValue schema = Json.parse(""" + { + "discriminator": "kind", + "mapping": { + "type1": {"properties": {"value": {"type": "string"}}} + } + } + """); + + // Case 1: Missing discriminator + JsonValue missing = Json.parse("{\"value\": \"test\"}"); + Jtd.Result missingResult = new Jtd().validate(schema, missing); + + assertFalse(missingResult.isValid(), "Should fail when discriminator missing"); + String missingError = missingResult.errors().get(0); + LOG.info(() -> "Missing discriminator error: " + missingError); + + // Case 2: Discriminator present but not string + JsonValue nonString = Json.parse("{\"kind\": 123}"); + Jtd.Result nonStringResult = new Jtd().validate(schema, nonString); + + assertFalse(nonStringResult.isValid(), "Should fail when discriminator not string"); + String nonStringError = nonStringResult.errors().get(0); + LOG.info(() -> "Non-string discriminator error: " + nonStringError); + + // Probe: Are these errors distinguishable? + // RFC says they should have different schemaPaths + // Missing: schema at discriminator form level + // Non-string: schemaPath "/discriminator" + + // Implementation currently conflates these (both say "must be a string") + // This test documents that behavior + } + + /// Test: Discriminator field should be exempt from additionalProperties only once + /// RFC 8927 §2.2.8: "The discriminator tag is exempt from additionalProperties enforcement" + @Test + public void probeDiscriminatorExemptionFromAdditionalProperties() { + JsonValue schema = Json.parse(""" + { + "discriminator": "type", + "mapping": { + "data": { + "properties": { + "value": {"type": "string"} + }, + "additionalProperties": false + } + } + } + """); + + // Valid: discriminator + defined property + JsonValue valid = Json.parse("{\"type\": \"data\", \"value\": \"test\"}"); + Jtd.Result validResult = new Jtd().validate(schema, valid); + assertTrue(validResult.isValid(), "Should accept discriminator + defined properties"); + + // Invalid: discriminator + extra property + JsonValue invalid = Json.parse("{\"type\": \"data\", \"value\": \"test\", \"extra\": \"bad\"}"); + Jtd.Result invalidResult = new Jtd().validate(schema, invalid); + + assertFalse(invalidResult.isValid(), "Should reject extra properties beyond discriminator"); + LOG.info(() -> "Additional property error: " + invalidResult.errors().get(0)); + + // Probe: Error should mention 'extra', not 'type' + String error = invalidResult.errors().get(0); + assertThat(error).as("Error should mention the extra property, not discriminator") + .contains("extra"); + assertThat(error).as("Error should NOT mention the discriminator field") + .doesNotContain("type"); + } + + /// Test: Discriminator field defined in mapping's properties + /// RFC 8927 §2.2.8: Discriminator key cannot be redefined in properties + @Test + public void probeDiscriminatorKeyRedefinitionCompilation() { + // This should FAIL compilation per RFC 8927 + JsonValue schema = Json.parse(""" + { + "discriminator": "kind", + "mapping": { + "person": { + "properties": { + "kind": {"type": "string"}, + "name": {"type": "string"} + } + } + } + } + """); + + Jtd validator = new Jtd(); + + // Probe: Does compilation reject this? + // Per RFC: "Mapped schemas cannot define the discriminator key in properties" + try { + validator.compile(schema); + LOG.warning(() -> "COMPILATION BUG: Should have rejected discriminator key redefinition"); + // If we get here, compilation allowed an invalid schema + } catch (IllegalArgumentException e) { + LOG.info(() -> "Correctly rejected discriminator key redefinition: " + e.getMessage()); + } + } + + /// Test: Discriminator field defined in mapping's optionalProperties + /// RFC 8927 §2.2.8: Discriminator key cannot be redefined in optionalProperties either + @Test + public void probeDiscriminatorKeyInOptionalPropertiesCompilation() { + JsonValue schema = Json.parse(""" + { + "discriminator": "type", + "mapping": { + "data": { + "optionalProperties": { + "type": {"type": "string"} + } + } + } + } + """); + + Jtd validator = new Jtd(); + + // Probe: Does compilation reject this? + try { + validator.compile(schema); + LOG.warning(() -> "COMPILATION BUG: Should have rejected discriminator key in optionalProperties"); + } catch (IllegalArgumentException e) { + LOG.info(() -> "Correctly rejected discriminator key in optionalProperties: " + e.getMessage()); + } + } + + /// Test: Discriminator with empty properties mapping + /// Valid per RFC 8927: mapping value can be {"properties": {}} + @Test + public void probeDiscriminatorWithEmptyPropertiesMapping() { + JsonValue schema = Json.parse(""" + { + "discriminator": "kind", + "mapping": { + "empty": {"properties": {}} + } + } + """); + + JsonValue instance = Json.parse("{\"kind\": \"empty\"}"); + Jtd.Result result = new Jtd().validate(schema, instance); + + LOG.info(() -> "Empty properties mapping result: " + result.isValid()); + + // Per RFC 8927: Empty properties should accept any properties except + // additional ones (if additionalProperties is false, default is false) + // But discriminator field IS the only allowed field + + // Probe: Is this valid? + // Should be valid because discriminator is allowed and there are no required properties + if (!result.isValid()) { + LOG.warning(() -> "VALIDATION BUG: Empty properties with discriminator should be valid, got: " + result.errors()); + } + } + + /// Test: Discriminator with additionalProperties: true + @Test + public void probeDiscriminatorWithAdditionalPropertiesTrue() { + JsonValue schema = Json.parse(""" + { + "discriminator": "type", + "mapping": { + "open": { + "properties": {}, + "additionalProperties": true + } + } + } + """); + + // Should accept any properties including extras + JsonValue instance = Json.parse("{\"type\": \"open\", \"anything\": \"goes\"}"); + Jtd.Result result = new Jtd().validate(schema, instance); + + LOG.info(() -> "AdditionalProperties: true result: " + result.isValid()); + + // Probe: This should definitely be valid + assertTrue(result.isValid(), "With additionalProperties: true, extra fields should be allowed"); + } + + /// Test: Nested discriminator in properties + /// Discriminator inside a properties schema that is itself nested + @Test + public void probeNestedDiscriminator() { + JsonValue schema = Json.parse(""" + { + "properties": { + "data": { + "discriminator": "kind", + "mapping": { + "a": {"properties": {"value": {"type": "string"}}}, + "b": {"properties": {"count": {"type": "int32"}}} + } + } + } + } + """); + + JsonValue valid = Json.parse("{\"data\": {\"kind\": \"a\", \"value\": \"test\"}}"); + Jtd.Result result = new Jtd().validate(schema, valid); + + LOG.info(() -> "Nested discriminator result: " + result.isValid()); + assertTrue(result.isValid(), "Nested discriminator should work"); + } + + /// Test: Discriminator validation stops on first discriminator error + /// RFC says if discriminator fails, we shouldn't validate variant schema + @Test + public void probeDiscriminatorErrorShortCircuitsVariantValidation() { + JsonValue schema = Json.parse(""" + { + "discriminator": "type", + "mapping": { + "data": { + "properties": { + "requiredField": {"type": "string"} + } + } + } + } + """); + + // Invalid discriminator + missing required field + JsonValue instance = Json.parse("{\"type\": \"unknown\", \"other\": \"value\"}"); + Jtd.Result result = new Jtd().validate(schema, instance); + + LOG.info(() -> "Errors for unknown discriminator: " + result.errors().size()); + + // Should only report discriminator error, not missing required field error + // because we never reached variant validation + assertFalse(result.isValid()); + + // Probe: Are there multiple errors or just the discriminator error? + if (result.errors().size() > 1) { + LOG.info(() -> "Implementation validates variant even with invalid discriminator"); + } else { + LOG.info(() -> "Implementation correctly short-circuits on discriminator error"); + } + } + + /// Test: Discriminator with null value + @Test + public void probeDiscriminatorWithNullValue() { + JsonValue schema = Json.parse(""" + { + "discriminator": "kind", + "mapping": { + "data": {"properties": {}} + } + } + """); + + JsonValue instance = Json.parse("{\"kind\": null}"); + Jtd.Result result = new Jtd().validate(schema, instance); + + LOG.info(() -> "Discriminator with null result: " + result.isValid()); + + // Null is not a string, so should fail + assertFalse(result.isValid(), "Discriminator with null value should be invalid"); + } + + /// Test: Discriminator with empty string value + @Test + public void probeDiscriminatorWithEmptyString() { + JsonValue schema = Json.parse(""" + { + "discriminator": "kind", + "mapping": { + "": {"properties": {}} + } + } + """); + + JsonValue instance = Json.parse("{\"kind\": \"\"}"); + Jtd.Result result = new Jtd().validate(schema, instance); + + LOG.info(() -> "Discriminator with empty string result: " + result.isValid()); + + // Empty string is a valid key in the mapping, so this should be valid + assertTrue(result.isValid(), "Empty string discriminator should work if in mapping"); + } + + /// Test: Multiple discriminator values with same required property name + @Test + public void probeDiscriminatorVariantsWithCommonProperties() { + JsonValue schema = Json.parse(""" + { + "discriminator": "type", + "mapping": { + "person": { + "properties": { + "name": {"type": "string"} + } + }, + "company": { + "properties": { + "name": {"type": "string"} + } + } + } + } + """); + + // Both variants have a "name" property + JsonValue person = Json.parse("{\"type\": \"person\", \"name\": \"Alice\"}"); + JsonValue company = Json.parse("{\"type\": \"company\", \"name\": \"Acme\"}"); + + Jtd validator = new Jtd(); + + Jtd.Result personResult = validator.validate(schema, person); + Jtd.Result companyResult = validator.validate(schema, company); + + LOG.info(() -> "Person result: " + personResult.isValid()); + LOG.info(() -> "Company result: " + companyResult.isValid()); + + assertTrue(personResult.isValid(), "Person variant should validate"); + assertTrue(companyResult.isValid(), "Company variant should validate"); + } + + /// Test: Discriminator with properties that conflict across variants + /// Same property name but different types in different variants + @Test + public void probeDiscriminatorWithConflictingPropertyTypes() { + JsonValue schema = Json.parse(""" + { + "discriminator": "type", + "mapping": { + "version1": { + "properties": { + "value": {"type": "string"} + } + }, + "version2": { + "properties": { + "value": {"type": "int32"} + } + } + } + } + """); + + Jtd validator = new Jtd(); + + JsonValue v1Valid = Json.parse("{\"type\": \"version1\", \"value\": \"text\"}"); + JsonValue v1Invalid = Json.parse("{\"type\": \"version1\", \"value\": 123}"); + JsonValue v2Valid = Json.parse("{\"type\": \"version2\", \"value\": 456}"); + JsonValue v2Invalid = Json.parse("{\"type\": \"version2\", \"value\": \"text\"}"); + + assertTrue(validator.validate(schema, v1Valid).isValid(), "v1 string should be valid"); + assertFalse(validator.validate(schema, v1Invalid).isValid(), "v1 int should be invalid"); + assertTrue(validator.validate(schema, v2Valid).isValid(), "v2 int should be valid"); + assertFalse(validator.validate(schema, v2Invalid).isValid(), "v2 string should be invalid"); + } +} diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/ElementsEdgeCaseProbe.java b/json-java21-jtd/src/test/java/json/java21/jtd/ElementsEdgeCaseProbe.java new file mode 100644 index 0000000..9008ce3 --- /dev/null +++ b/json-java21-jtd/src/test/java/json/java21/jtd/ElementsEdgeCaseProbe.java @@ -0,0 +1,530 @@ +package json.java21.jtd; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonValue; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +/// Probes for Elements form edge cases and potential issues +/// +/// Areas to probe: +/// 1. Empty arrays +/// 2. Nested elements +/// 3. Elements with complex schemas +/// 4. Large arrays +/// 5. Array element error reporting +public class ElementsEdgeCaseProbe extends JtdTestBase { + + /// Test: Empty array validation + @Test + public void probeEmptyArray() { + JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\"}}"); + + JsonValue emptyArray = Json.parse("[]"); + Jtd.Result result = new Jtd().validate(schema, emptyArray); + + LOG.info(() -> "Empty array validation: " + result.isValid()); + assertTrue(result.isValid(), "Empty array should be valid"); + assertThat(result.errors()).isEmpty(); + } + + /// Test: Single element array + @Test + public void probeSingleElementArray() { + JsonValue schema = Json.parse("{\"elements\": {\"type\": \"int32\"}}"); + + assertTrue(new Jtd().validate(schema, Json.parse("[42]")).isValid()); + assertFalse(new Jtd().validate(schema, Json.parse("[\"not-int\"]")).isValid()); + + LOG.info(() -> "Single element array: passed"); + } + + /// Test: Nested elements (2D array) + @Test + public void probeNestedElements2D() { + JsonValue schema = Json.parse("{\"elements\": {\"elements\": {\"type\": \"string\"}}}"); + + // Valid 2D array + assertTrue(new Jtd().validate(schema, Json.parse("[[\"a\", \"b\"], [\"c\"]]")).isValid()); + + // Invalid inner element + Jtd.Result result = new Jtd().validate(schema, Json.parse("[[\"a\"], [123]]")); + assertFalse(result.isValid()); + + LOG.info(() -> "Nested elements 2D: " + !result.isValid()); + } + + /// Test: Deeply nested elements (3D array) + @Test + public void probeNestedElements3D() { + JsonValue schema = Json.parse(""" + { + "elements": { + "elements": { + "elements": {"type": "int32"} + } + } + } + """); + + // Valid 3D array + JsonValue valid = Json.parse("[[[1, 2], [3]], [[4]]]"); + assertTrue(new Jtd().validate(schema, valid).isValid()); + + // Invalid at deepest level + JsonValue invalid = Json.parse("[[[1, \"bad\"], [3]]]"); + Jtd.Result result = new Jtd().validate(schema, invalid); + assertFalse(result.isValid()); + + LOG.info(() -> "Nested elements 3D: " + !result.isValid()); + } + + /// Test: Elements with properties schema + @Test + public void probeElementsWithProperties() { + JsonValue schema = Json.parse(""" + { + "elements": { + "properties": { + "name": {"type": "string"}, + "age": {"type": "int32"} + } + } + } + """); + + // Valid array of objects + JsonValue valid = Json.parse(""" + [ + {"name": "Alice", "age": 30}, + {"name": "Bob", "age": 25} + ] + """); + assertTrue(new Jtd().validate(schema, valid).isValid()); + + // Invalid: missing required property + JsonValue invalid = Json.parse("[{\"name\": \"Charlie\"}]"); + Jtd.Result result = new Jtd().validate(schema, invalid); + + LOG.info(() -> "Elements with properties: " + !result.isValid()); + } + + /// Test: Elements with discriminator + @Test + public void probeElementsWithDiscriminator() { + JsonValue schema = Json.parse(""" + { + "elements": { + "discriminator": "type", + "mapping": { + "a": {"properties": {"value": {"type": "string"}}}, + "b": {"properties": {"count": {"type": "int32"}}} + } + } + } + """); + + // Valid heterogeneous array + JsonValue valid = Json.parse(""" + [ + {"type": "a", "value": "test"}, + {"type": "b", "count": 5} + ] + """); + assertTrue(new Jtd().validate(schema, valid).isValid()); + + LOG.info(() -> "Elements with discriminator: passed"); + } + + /// Test: Elements error collection (all elements validated) + @Test + public void probeElementsErrorCollection() { + JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\"}}"); + + // All elements invalid + JsonValue instance = Json.parse("[1, 2, 3, 4, 5]"); + Jtd.Result result = new Jtd().validate(schema, instance); + + LOG.info(() -> "Elements error count: " + result.errors().size()); + + assertFalse(result.isValid()); + + // All 5 should be reported + assertThat(result.errors().size()) + .as("Should report errors for all invalid elements") + .isGreaterThanOrEqualTo(5); + } + + /// Test: Elements with additionalProperties in nested object + @Test + public void probeElementsWithStrictNestedObjects() { + JsonValue schema = Json.parse(""" + { + "elements": { + "properties": { + "id": {"type": "int32"} + }, + "additionalProperties": false + } + } + """); + + // Valid + assertTrue(new Jtd().validate(schema, Json.parse("[{\"id\": 1}]")).isValid()); + + // Invalid: extra property in element + Jtd.Result result = new Jtd().validate(schema, Json.parse("[{\"id\": 1, \"extra\": \"bad\"}]")); + assertFalse(result.isValid()); + + LOG.info(() -> "Elements with strict nested: " + !result.isValid()); + } + + /// Test: Large array performance probe + @Test + public void probeLargeArray() { + JsonValue schema = Json.parse("{\"elements\": {\"type\": \"int32\"}}"); + + // Build large array + StringBuilder arrayBuilder = new StringBuilder("["); + int count = 1000; + for (int i = 0; i < count; i++) { + if (i > 0) arrayBuilder.append(", "); + arrayBuilder.append(i); + } + arrayBuilder.append("]"); + + JsonValue instance = Json.parse(arrayBuilder.toString()); + + long start = System.currentTimeMillis(); + Jtd.Result result = new Jtd().validate(schema, instance); + long elapsed = System.currentTimeMillis() - start; + + LOG.info(() -> "Large array (" + count + " elements): valid=" + result.isValid() + ", time=" + elapsed + "ms"); + + assertTrue(result.isValid()); + assertThat(elapsed).isLessThan(5000); // Should complete in reasonable time + } + + /// Test: Array with null elements + @Test + public void probeArrayWithNullElements() { + JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\"}}"); + + // Null element should be invalid (string type rejects null) + JsonValue instance = Json.parse("[\"valid\", null, \"also-valid\"]"); + Jtd.Result result = new Jtd().validate(schema, instance); + + LOG.info(() -> "Array with null element: " + !result.isValid()); + assertFalse(result.isValid()); + } + + /// Test: Array with nullable elements + @Test + public void probeArrayWithNullableElements() { + JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\", \"nullable\": true}}"); + + // Null element should now be valid + JsonValue instance = Json.parse("[\"valid\", null, \"also-valid\"]"); + Jtd.Result result = new Jtd().validate(schema, instance); + + LOG.info(() -> "Array with nullable elements: " + result.isValid()); + assertTrue(result.isValid()); + } + + /// Test: Mixed valid and invalid elements + @Test + public void probeMixedValidInvalidElements() { + JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\"}}"); + + JsonValue instance = Json.parse("[\"a\", \"b\", 123, \"c\", 456]"); + Jtd.Result result = new Jtd().validate(schema, instance); + + LOG.info(() -> "Mixed elements error count: " + result.errors().size()); + + assertFalse(result.isValid()); + + // Should have 2 errors (for the 2 invalid elements) + List errors = result.errors(); + long errorCount = errors.stream().filter(e -> e.contains("expected string")).count(); + + LOG.info(() -> "Type errors found: " + errorCount); + assertThat(errorCount).isGreaterThanOrEqualTo(2); + } + + /// Test: Empty schema in elements (accepts anything) + @Test + public void probeElementsWithEmptySchema() { + JsonValue schema = Json.parse("{\"elements\": {}}"); + + // Should accept any elements + assertTrue(new Jtd().validate(schema, Json.parse("[1, \"two\", true, null, []]")).isValid()); + assertTrue(new Jtd().validate(schema, Json.parse("[]")).isValid()); + + LOG.info(() -> "Elements with empty schema: passed"); + } + + /// Test: Elements with ref + @Test + public void probeElementsWithRef() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "item": {"type": "int32"} + }, + "elements": {"ref": "item"} + } + """); + + assertTrue(new Jtd().validate(schema, Json.parse("[1, 2, 3]")).isValid()); + assertFalse(new Jtd().validate(schema, Json.parse("[1, \"two\", 3]")).isValid()); + + LOG.info(() -> "Elements with ref: passed"); + } + + /// Test: Elements error path construction + @Test + public void probeElementsErrorPaths() { + JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\"}}"); + + JsonValue instance = Json.parse("[\"a\", 123]"); + Jtd.Result result = new Jtd().validate(schema, instance); + + String error = result.errors().get(0); + LOG.info(() -> "Elements error: " + error); + + // Error should indicate array index + assertThat(error) + .as("Error should reference array element") + .containsAnyOf("1", "elements"); + } + + /// Test: Nested elements error path + @Test + public void probeNestedElementsErrorPath() { + JsonValue schema = Json.parse("{\"elements\": {\"elements\": {\"type\": \"string\"}}}"); + + JsonValue instance = Json.parse("[[\"a\"], [\"b\", 123]]"); + Jtd.Result result = new Jtd().validate(schema, instance); + + String error = result.errors().get(0); + LOG.info(() -> "Nested elements error: " + error); + + // Should indicate nested position + assertThat(error) + .as("Error should indicate nested array position") + .containsAnyOf("1", "elements"); + } + + /// Test: Array with object having additional properties + @Test + public void probeElementsWithObjectAdditionalProperties() { + JsonValue schema = Json.parse(""" + { + "elements": { + "properties": { + "id": {"type": "int32"} + }, + "additionalProperties": false + } + } + """); + + JsonValue instance = Json.parse(""" + [ + {"id": 1}, + {"id": 2, "extra": "bad"}, + {"id": 3} + ] + """); + + Jtd.Result result = new Jtd().validate(schema, instance); + + LOG.info(() -> "Elements with object having extra props: " + !result.isValid()); + assertFalse(result.isValid()); + } + + /// Test: Multiple arrays in same schema + @Test + public void probeMultipleArrays() { + JsonValue schema = Json.parse(""" + { + "properties": { + "names": {"elements": {"type": "string"}}, + "ages": {"elements": {"type": "int32"}} + } + } + """); + + // Valid + JsonValue valid = Json.parse(""" + { + "names": ["Alice", "Bob"], + "ages": [30, 25] + } + """); + assertTrue(new Jtd().validate(schema, valid).isValid()); + + // Invalid in second array + JsonValue invalid = Json.parse(""" + { + "names": ["Alice", "Bob"], + "ages": [30, "twenty-five"] + } + """); + Jtd.Result result = new Jtd().validate(schema, invalid); + assertFalse(result.isValid()); + + LOG.info(() -> "Multiple arrays: " + !result.isValid()); + } + + /// Test: Elements with values (object values schema) + @Test + public void probeElementsWithValuesSchema() { + JsonValue schema = Json.parse(""" + { + "elements": { + "values": {"type": "int32"} + } + } + """); + + // Array of objects with int values + JsonValue valid = Json.parse("[\"a\": 1, \"b\": 2}, {\"c\": 3}]"); + assertTrue(new Jtd().validate(schema, valid).isValid()); + + LOG.info(() -> "Elements with values schema: passed"); + } + + /// Test: Elements with enum + @Test + public void probeElementsWithEnum() { + JsonValue schema = Json.parse("{\"elements\": {\"enum\": [\"red\", \"green\", \"blue\"]}}"); + + assertTrue(new Jtd().validate(schema, Json.parse("[\"red\", \"blue\"]")).isValid()); + assertFalse(new Jtd().validate(schema, Json.parse("[\"red\", \"yellow\"]")).isValid()); + + LOG.info(() -> "Elements with enum: passed"); + } + + /// Test: Sparse array (with undefined/null gaps) + @Test + public void probeSparseArray() { + // JSON doesn't really have sparse arrays, but let's check null handling + JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\", \"nullable\": true}}"); + + // Explicit null elements + JsonValue instance = Json.parse("[\"a\", null, \"c\"]"); + Jtd.Result result = new Jtd().validate(schema, instance); + + LOG.info(() -> "Array with nulls: " + result.isValid()); + assertTrue(result.isValid()); + } + + /// Test: Elements with complex nested structure + @Test + public void probeElementsWithComplexStructure() { + JsonValue schema = Json.parse(""" + { + "elements": { + "properties": { + "users": { + "elements": { + "properties": { + "name": {"type": "string"}, + "roles": { + "elements": {"enum": [\"admin\", \"user\"]} + } + } + } + } + } + } + } + """); + + // Valid complex structure + JsonValue valid = Json.parse(""" + [ + { + "users": [ + {"name": "Alice", "roles": ["admin", "user"]}, + {"name": "Bob", "roles": ["user"]} + ] + } + ] + """); + + assertTrue(new Jtd().validate(schema, valid).isValid()); + + LOG.info(() -> "Complex nested elements: passed"); + } + + /// Test: Array type guard (non-array rejection) + @Test + public void probeElementsTypeGuard() { + JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\"}}"); + + // Non-array values should be rejected + assertFalse(new Jtd().validate(schema, Json.parse("\"not-array\"")).isValid()); + assertFalse(new Jtd().validate(schema, Json.parse("123")).isValid()); + assertFalse(new Jtd().validate(schema, Json.parse("{}")).isValid()); + assertFalse(new Jtd().validate(schema, Json.parse("null")).isValid()); + + LOG.info(() -> "Elements type guard: passed"); + } + + /// Test: Elements with timestamp type + @Test + public void probeElementsWithTimestamp() { + JsonValue schema = Json.parse("{\"elements\": {\"type\": \"timestamp\"}}"); + + // Valid timestamps + assertTrue(new Jtd().validate(schema, Json.parse("[\"2023-01-01T00:00:00Z\"]")).isValid()); + + // Invalid timestamp + assertFalse(new Jtd().validate(schema, Json.parse("[\"invalid\"]")).isValid()); + + LOG.info(() -> "Elements with timestamp: passed"); + } + + /// Test: Elements with boolean type + @Test + public void probeElementsWithBoolean() { + JsonValue schema = Json.parse("{\"elements\": {\"type\": \"boolean\"}}"); + + assertTrue(new Jtd().validate(schema, Json.parse("[true, false, true]")).isValid()); + assertFalse(new Jtd().validate(schema, Json.parse("[true, \"false\"]")).isValid()); + + LOG.info(() -> "Elements with boolean: passed"); + } + + /// Test: Elements with float type + @Test + public void probeElementsWithFloat() { + JsonValue schema = Json.parse("{\"elements\": {\"type\": \"float64\"}}"); + + assertTrue(new Jtd().validate(schema, Json.parse("[1.5, -3.14, 1e10]")).isValid()); + assertFalse(new Jtd().validate(schema, Json.parse("[1.5, \"not-float\"]")).isValid()); + + LOG.info(() -> "Elements with float: passed"); + } + + /// Test: Elements with integer types and boundary values + @Test + public void probeElementsWithIntegerBoundaries() { + JsonValue schema = Json.parse("{\"elements\": {\"type\": \"uint8\"}}"); + + // At boundary + assertTrue(new Jtd().validate(schema, Json.parse("[0, 127, 255]")).isValid()); + + // Outside boundary + assertFalse(new Jtd().validate(schema, Json.parse("[256]")).isValid()); + assertFalse(new Jtd().validate(schema, Json.parse("[-1]")).isValid()); + + LOG.info(() -> "Elements with uint8 boundaries: passed"); + } +} diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/ErrorFormatComplianceProbe.java b/json-java21-jtd/src/test/java/json/java21/jtd/ErrorFormatComplianceProbe.java new file mode 100644 index 0000000..de61e65 --- /dev/null +++ b/json-java21-jtd/src/test/java/json/java21/jtd/ErrorFormatComplianceProbe.java @@ -0,0 +1,360 @@ +package json.java21.jtd; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonValue; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Probes for RFC 8927 error format compliance issues +/// Tests that verify the EXACT error format per RFC 8927 Section 3.3 +/// +/// Current implementation issues to probe: +/// 1. Error format should be {instancePath, schemaPath} pairs, not enriched strings +/// 2. Schema paths must point to schema keywords (e.g., "/type", "/properties/foo") +/// 3. Instance paths must be RFC 6901 JSON Pointers +/// 4. Error indicators must be collected for ALL violations (multiple errors) +public class ErrorFormatComplianceProbe extends JtdTestBase { + + /// Test: Error format should contain RFC 8927 error indicators + /// Expected: Each error has instancePath and schemaPath + /// Actual: Implementation returns List of enriched messages + /// + /// This test documents the deviation from RFC 8927 error format + @Test + public void probeErrorFormatIsRfc8927Compliant() { + JsonValue schema = Json.parse("{\"type\": \"string\"}"); + JsonValue instance = Json.parse("123"); + + Jtd validator = new Jtd(); + Jtd.Result result = validator.validate(schema, instance); + + assertFalse(result.isValid(), "Should fail validation"); + + List errors = result.errors(); + assertThat(errors).isNotEmpty(); + + // Probe: Check if errors contain RFC 8927 format or enriched strings + String firstError = errors.get(0); + + // RFC 8927 format would be something like: + // { "instancePath": "", "schemaPath": "/type" } + // But implementation returns: "[off=N ptr=# via=#] expected string, got JsonNumber" + + LOG.info(() -> "Probing error format: " + firstError); + + // This assertion documents the current behavior + assertThat(firstError) + .as("Current implementation returns enriched strings, not RFC error objects") + .contains("[off="); + + // These assertions will FAIL if/when we implement RFC format + // Currently they pass because we get enriched strings + assertThat(firstError) + .as("Error should contain schemaPath information") + .contains("ptr="); + } + + /// Test: Type validation errors should have correct schemaPath + /// Expected: schemaPath = "/type" for type violations + @Test + public void probeTypeErrorSchemaPath() { + JsonValue schema = Json.parse("{\"type\": \"int32\"}"); + JsonValue instance = Json.parse("\"not-a-number\""); + + Jtd validator = new Jtd(); + Jtd.Result result = validator.validate(schema, instance); + + assertFalse(result.isValid()); + + String error = result.errors().get(0); + LOG.info(() -> "Type error: " + error); + + // Probe: Does error contain schemaPath to the type keyword? + // Should be "/type" per RFC 8927 + assertThat(error) + .as("Error should reference the type schema location") + .contains("expected int32"); + } + + /// Test: Properties errors should have correct schemaPath + /// Expected: schemaPath = "/properties/" for missing required properties + @Test + public void probePropertiesErrorSchemaPath() { + JsonValue schema = Json.parse("{\"properties\": {\"name\": {\"type\": \"string\"}}}"); + JsonValue instance = Json.parse("{}"); + + Jtd validator = new Jtd(); + Jtd.Result result = validator.validate(schema, instance); + + assertFalse(result.isValid()); + + String error = result.errors().get(0); + LOG.info(() -> "Missing property error: " + error); + + // Probe: Should contain schemaPath to the missing property definition + assertThat(error) + .as("Error should reference the missing required property") + .contains("missing required property: 'name'"); + } + + /// Test: InstancePath should be RFC 6901 compliant + /// Expected: Root is "", children are "/key" or "/0" + /// Actual: Implementation uses "#" for root + @Test + public void probeInstancePathRfc6901Compliance() { + JsonValue schema = Json.parse("{\"type\": \"string\"}"); + JsonValue instance = Json.parse("\"test\""); + + Jtd validator = new Jtd(); + Jtd.Result result = validator.validate(schema, Json.parse("123")); + + assertFalse(result.isValid()); + + String error = result.errors().get(0); + LOG.info(() -> "Instance path in error: " + error); + + // Probe: Instance path format + // RFC 6901: empty string for root + // Implementation: "#" for root + assertThat(error) + .as("Implementation uses # for root, not RFC 6901 empty string") + .contains("ptr=#"); + } + + /// Test: Nested path construction + /// Expected: Nested errors have compound instancePaths like "/foo/0/bar" + @Test + public void probeNestedInstancePathConstruction() { + JsonValue schema = Json.parse(""" + { + "properties": { + "items": { + "elements": { + "properties": { + "value": {"type": "string"} + } + } + } + } + } + """); + + JsonValue instance = Json.parse(""" + { + "items": [ + {"value": "valid"}, + {"value": 123} + ] + } + """); + + Jtd validator = new Jtd(); + Jtd.Result result = validator.validate(schema, instance); + + assertFalse(result.isValid()); + + String error = result.errors().get(0); + LOG.info(() -> "Nested path error: " + error); + + // Probe: Should contain path to the nested invalid element + assertThat(error) + .as("Error should reference nested path") + .contains("items"); + } + + /// Test: Multiple errors should ALL be collected + /// RFC 8927 Section 3.3: errors are collected for all violations + @Test + public void probeMultipleErrorsCollected() { + JsonValue schema = Json.parse(""" + { + "properties": { + "a": {"type": "string"}, + "b": {"type": "string"}, + "c": {"type": "string"} + } + } + """); + + JsonValue instance = Json.parse(""" + { + "a": 1, + "b": 2, + "c": 3 + } + """); + + Jtd validator = new Jtd(); + Jtd.Result result = validator.validate(schema, instance); + + assertFalse(result.isValid()); + + List errors = result.errors(); + LOG.info(() -> "Multiple errors count: " + errors.size()); + + // Probe: Are all three type errors collected? + // Implementation should collect errors for all properties + assertThat(errors.size()) + .as("Should collect errors for all invalid properties") + .isGreaterThanOrEqualTo(3); + } + + /// Test: Error order is unspecified but all should be present + @Test + public void probeAllViolationsReported() { + JsonValue schema = Json.parse(""" + { + "properties": { + "name": {"type": "string"} + }, + "optionalProperties": { + "age": {"type": "int32"} + } + } + """); + + JsonValue instance = Json.parse(""" + { + "name": 123, + "age": "not-a-number" + } + """); + + Jtd validator = new Jtd(); + Jtd.Result result = validator.validate(schema, instance); + + assertFalse(result.isValid()); + + List errors = result.errors(); + + // Probe: Are both errors reported? + boolean hasNameError = errors.stream().anyMatch(e -> e.contains("name")); + boolean hasAgeError = errors.stream().anyMatch(e -> e.contains("age")); + + assertTrue(hasNameError, "Should report error for 'name' property"); + assertTrue(hasAgeError, "Should report error for 'age' property"); + } + + /// Test: Enum error schemaPath + /// Expected: schemaPath = "/enum" for enum violations + @Test + public void probeEnumErrorSchemaPath() { + JsonValue schema = Json.parse("{\"enum\": [\"a\", \"b\", \"c\"]}"); + JsonValue instance = Json.parse("\"d\""); + + Jtd validator = new Jtd(); + Jtd.Result result = validator.validate(schema, instance); + + assertFalse(result.isValid()); + + String error = result.errors().get(0); + LOG.info(() -> "Enum error: " + error); + + // Probe: Should reference the enum constraint + assertThat(error) + .as("Error should reference enum constraint") + .contains("not in enum"); + } + + /// Test: Elements error schemaPath + /// Expected: schemaPath = "/elements" for element validation failures + @Test + public void probeElementsErrorSchemaPath() { + JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\"}}"); + JsonValue instance = Json.parse("[1, 2, 3]"); + + Jtd validator = new Jtd(); + Jtd.Result result = validator.validate(schema, instance); + + assertFalse(result.isValid()); + + List errors = result.errors(); + LOG.info(() -> "Elements errors count: " + errors.size()); + + // Probe: Should have 3 errors (one per element) + // Note: Some implementations might short-circuit; this checks + assertThat(errors.size()) + .as("Should report errors for invalid elements") + .isGreaterThanOrEqualTo(1); + } + + /// Test: Discriminator error schemaPath + /// Expected: Different schemaPaths for different discriminator failures + @Test + public void probeDiscriminatorErrorSchemaPaths() { + // Test 1: Missing discriminator + JsonValue schema = Json.parse(""" + { + "discriminator": "kind", + "mapping": { + "person": {"properties": {"name": {"type": "string"}}} + } + } + """); + + JsonValue missingKind = Json.parse("{\"name\": \"John\"}"); + Jtd.Result result1 = new Jtd().validate(schema, missingKind); + + assertFalse(result1.isValid()); + LOG.info(() -> "Missing discriminator error: " + result1.errors().get(0)); + + // Test 2: Invalid discriminator value + JsonValue invalidKind = Json.parse("{\"kind\": \"invalid\", \"name\": \"John\"}"); + Jtd.Result result2 = new Jtd().validate(schema, invalidKind); + + assertFalse(result2.isValid()); + LOG.info(() -> "Invalid discriminator error: " + result2.errors().get(0)); + + // Test 3: Non-string discriminator + JsonValue nonStringKind = Json.parse("{\"kind\": 123}"); + Jtd.Result result3 = new Jtd().validate(schema, nonStringKind); + + assertFalse(result3.isValid()); + LOG.info(() -> "Non-string discriminator error: " + result3.errors().get(0)); + } + + /// Test: Empty schema produces no errors + /// RFC 8927 §3.3.1: Empty form accepts all instances and produces no errors + @Test + public void probeEmptySchemaNoErrors() { + JsonValue schema = Json.parse("{}"); + JsonValue instance = Json.parse("{\"anything\": \"goes\"}"); + + Jtd validator = new Jtd(); + Jtd.Result result = validator.validate(schema, instance); + + assertTrue(result.isValid()); + assertThat(result.errors()).isEmpty(); + + LOG.info(() -> "Empty schema validation produced no errors (correct)"); + } + + /// Test: Error messages should be consistent + /// Same violation should produce same error structure + @Test + public void probeErrorMessageConsistency() { + JsonValue schema = Json.parse("{\"type\": \"string\"}"); + + // Two identical violations + Jtd.Result result1 = new Jtd().validate(schema, Json.parse("1")); + Jtd.Result result2 = new Jtd().validate(schema, Json.parse("2")); + + assertFalse(result1.isValid()); + assertFalse(result2.isValid()); + + // Probe: Error structure should be consistent + String error1 = result1.errors().get(0); + String error2 = result2.errors().get(0); + + // Both should contain same error pattern + assertThat(error1).contains("expected string"); + assertThat(error2).contains("expected string"); + + LOG.info(() -> "Error consistency check passed"); + } +} diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/JtdSpecConformanceTest.java b/json-java21-jtd/src/test/java/json/java21/jtd/JtdSpecConformanceTest.java new file mode 100644 index 0000000..77a25bf --- /dev/null +++ b/json-java21-jtd/src/test/java/json/java21/jtd/JtdSpecConformanceTest.java @@ -0,0 +1,87 @@ +package json.java21.jtd; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonArray; +import jdk.sandbox.java.util.json.JsonObject; +import jdk.sandbox.java.util.json.JsonString; +import jdk.sandbox.java.util.json.JsonValue; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/// Runs the official json-typedef-spec validation test suite against +/// the interpreter path. This is the authoritative conformance test. +/// +/// Test data: `jtd-spec-validation.json` from +/// +class JtdSpecConformanceTest extends JtdTestBase { + + static Stream cases() throws IOException { + // Extract test suite from ZIP (same data as IT tests, avoids committing large JSON) + try (final var raw = JtdTestDataExtractor.getValidationTestDataStream()) { + final var jsonText = new String(raw.readAllBytes(), StandardCharsets.UTF_8); + final var root = Json.parse(jsonText); + assert root instanceof JsonObject : "expected top-level object"; + final var obj = (JsonObject) root; + + return obj.members().entrySet().stream() + .map(entry -> Arguments.of( + entry.getKey(), + entry.getValue())); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("cases") + void interpreterMatchesSpecSuite(String name, JsonValue caseValue) { + LOG.info("SPEC: " + name); + + final var caseObj = (JsonObject) caseValue; + final var schema = caseObj.members().get("schema"); + final var instance = caseObj.members().get("instance"); + final var expectedErrors = (JsonArray) caseObj.members().get("errors"); + + final var validator = JtdValidator.compile(schema); + final var result = validator.validate(instance); + + final var expected = expectedErrors.elements().stream() + .map(e -> { + final var errObj = (JsonObject) e; + final var ip = toJsonPointer((JsonArray) errObj.members().get("instancePath")); + final var sp = toJsonPointer((JsonArray) errObj.members().get("schemaPath")); + return new JtdValidationError(ip, sp); + }) + .sorted(ERR_CMP) + .toList(); + + final var actual = result.errors().stream() + .sorted(ERR_CMP) + .toList(); + + assertThat(actual) + .as("errors for: " + name) + .containsExactlyElementsOf(expected); + } + + private static String toJsonPointer(JsonArray tokens) { + if (tokens.elements().isEmpty()) return ""; + final var sb = new StringBuilder(); + for (final var token : tokens.elements()) { + sb.append('/'); + sb.append(((JsonString) token).string()); + } + return sb.toString(); + } + + private static final Comparator ERR_CMP = + Comparator.comparing(JtdValidationError::instancePath) + .thenComparing(JtdValidationError::schemaPath); +} diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/JtdSpecIT.java b/json-java21-jtd/src/test/java/json/java21/jtd/JtdSpecIT.java index c9be2c5..20fbac4 100644 --- a/json-java21-jtd/src/test/java/json/java21/jtd/JtdSpecIT.java +++ b/json-java21-jtd/src/test/java/json/java21/jtd/JtdSpecIT.java @@ -93,41 +93,8 @@ private Stream runValidationTests() throws Exception { } private void extractTestData() throws IOException { - // Check if test data is already extracted - if (Files.exists(VALIDATION_TEST_FILE)) { - LOG.fine(() -> "JTD test suite already extracted at: " + VALIDATION_TEST_FILE); - return; - } - - // Extract the embedded test suite - Path zipFile = Paths.get("src/test/resources/jtd-test-suite.zip"); - Path targetDir = Paths.get("target/test-data"); - - if (!Files.exists(zipFile)) { - throw new RuntimeException("JTD test suite ZIP not found: " + zipFile.toAbsolutePath()); - } - - LOG.info(() -> "Extracting JTD test suite from: " + zipFile); - - // Create target directory - Files.createDirectories(targetDir); - - // Extract ZIP file - try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(zipFile))) { - java.util.zip.ZipEntry entry; - while ((entry = zis.getNextEntry()) != null) { - if (!entry.isDirectory() && entry.getName().startsWith("json-typedef-spec-")) { - Path outputPath = targetDir.resolve(entry.getName()); - Files.createDirectories(outputPath.getParent()); - Files.copy(zis, outputPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING); - } - zis.closeEntry(); - } - } - - if (!Files.exists(VALIDATION_TEST_FILE)) { - throw new RuntimeException("Extraction completed but test file not found: " + VALIDATION_TEST_FILE); - } + // Use shared extractor to ensure test data is available + JtdTestDataExtractor.ensureValidationTestData(); } private JsonNode loadTestFile() throws IOException { diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/JtdTestDataExtractor.java b/json-java21-jtd/src/test/java/json/java21/jtd/JtdTestDataExtractor.java new file mode 100644 index 0000000..e50021d --- /dev/null +++ b/json-java21-jtd/src/test/java/json/java21/jtd/JtdTestDataExtractor.java @@ -0,0 +1,67 @@ +package json.java21.jtd; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.logging.Logger; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/// Shared utility for extracting the JTD test suite from the embedded ZIP file. +/// Used by both unit tests and integration tests to avoid committing large JSON files. +final class JtdTestDataExtractor { + + private static final Logger LOG = Logger.getLogger("json.java21.jtd"); + private static final Path ZIP_FILE = Paths.get("src/test/resources/jtd-test-suite.zip"); + private static final Path TARGET_DIR = Paths.get("target/test-data"); + private static final Path VALIDATION_FILE = TARGET_DIR.resolve("json-typedef-spec-2025-09-27/tests/validation.json"); + + private JtdTestDataExtractor() { + // Utility class + } + + /// Ensures the test suite is extracted and returns the path to validation.json. + /// Extraction happens at most once per build (target/ is cleaned between builds). + static synchronized Path ensureValidationTestData() throws IOException { + if (Files.exists(VALIDATION_FILE)) { + LOG.fine(() -> "JTD test suite already extracted at: " + VALIDATION_FILE); + return VALIDATION_FILE; + } + + if (!Files.exists(ZIP_FILE)) { + throw new RuntimeException("JTD test suite ZIP not found: " + ZIP_FILE.toAbsolutePath()); + } + + LOG.info(() -> "Extracting JTD test suite from: " + ZIP_FILE); + Files.createDirectories(TARGET_DIR); + + try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(ZIP_FILE))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (!entry.isDirectory() && entry.getName().startsWith("json-typedef-spec-")) { + Path outputPath = TARGET_DIR.resolve(entry.getName()); + Files.createDirectories(outputPath.getParent()); + Files.copy(zis, outputPath, StandardCopyOption.REPLACE_EXISTING); + } + zis.closeEntry(); + } + } + + if (!Files.exists(VALIDATION_FILE)) { + throw new RuntimeException("Extraction completed but validation.json not found: " + VALIDATION_FILE); + } + + LOG.info(() -> "JTD test suite extracted successfully"); + return VALIDATION_FILE; + } + + /// Returns an InputStream for the validation test data, extracting if necessary. + /// Suitable for use with classpath-style resource loading patterns. + static InputStream getValidationTestDataStream() throws IOException { + Path dataFile = ensureValidationTestData(); + return Files.newInputStream(dataFile); + } +} diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/JtdValidatorTest.java b/json-java21-jtd/src/test/java/json/java21/jtd/JtdValidatorTest.java new file mode 100644 index 0000000..719ea39 --- /dev/null +++ b/json-java21-jtd/src/test/java/json/java21/jtd/JtdValidatorTest.java @@ -0,0 +1,416 @@ +package json.java21.jtd; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonValue; +import org.junit.jupiter.api.Test; + +import java.util.logging.Logger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/// Tests for the [JtdValidator] functional interface and [InterpreterValidator]. +/// +/// Exercises the RFC 8927 `(instancePath, schemaPath)` error pair format +/// produced by the interpreter path. +class JtdValidatorTest extends JtdTestBase { + + private static final Logger LOG = Logger.getLogger(JtdValidatorTest.class.getName()); + + // ------------------------------------------------------------------ + // Factory smoke tests + // ------------------------------------------------------------------ + + @Test + void compileReturnsValidatorForTypeSchema() { + LOG.info("EXECUTING: compileReturnsValidatorForTypeSchema"); + final var validator = JtdValidator.compile(Json.parse("{\"type\": \"string\"}")); + assertThat(validator).isNotNull(); + assertThat(validator.validate(Json.parse("\"hello\"")).isValid()).isTrue(); + } + + @Test + void compileGeneratedThrowsWhenCodegenNotOnClasspath() { + LOG.info("EXECUTING: compileGeneratedThrowsWhenCodegenNotOnClasspath"); + assertThatThrownBy(() -> JtdValidator.compileGenerated(Json.parse("{\"type\": \"string\"}"))) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("Codegen module not on classpath"); + } + + // ------------------------------------------------------------------ + // Empty form + // ------------------------------------------------------------------ + + @Test + void emptySchemaAcceptsAnything() { + LOG.info("EXECUTING: emptySchemaAcceptsAnything"); + final var v = JtdValidator.compile(Json.parse("{}")); + assertThat(v.validate(Json.parse("null")).isValid()).isTrue(); + assertThat(v.validate(Json.parse("42")).isValid()).isTrue(); + assertThat(v.validate(Json.parse("\"hi\"")).isValid()).isTrue(); + assertThat(v.validate(Json.parse("[1,2]")).isValid()).isTrue(); + assertThat(v.validate(Json.parse("{\"a\":1}")).isValid()).isTrue(); + } + + // ------------------------------------------------------------------ + // Type form -- error paths + // ------------------------------------------------------------------ + + @Test + void typeStringRejectsNumberWithCorrectPaths() { + LOG.info("EXECUTING: typeStringRejectsNumberWithCorrectPaths"); + final var v = JtdValidator.compile(Json.parse("{\"type\": \"string\"}")); + final var result = v.validate(Json.parse("42")); + assertThat(result.isValid()).isFalse(); + assertThat(result.errors()).hasSize(1); + assertThat(result.errors().getFirst().instancePath()).isEqualTo(""); + assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/type"); + } + + @Test + void typeBooleanValid() { + LOG.info("EXECUTING: typeBooleanValid"); + final var v = JtdValidator.compile(Json.parse("{\"type\": \"boolean\"}")); + assertThat(v.validate(Json.parse("true")).isValid()).isTrue(); + assertThat(v.validate(Json.parse("false")).isValid()).isTrue(); + } + + @Test + void typeUint8OutOfRangeErrors() { + LOG.info("EXECUTING: typeUint8OutOfRangeErrors"); + final var v = JtdValidator.compile(Json.parse("{\"type\": \"uint8\"}")); + final var result = v.validate(Json.parse("300")); + assertThat(result.isValid()).isFalse(); + assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/type"); + } + + // ------------------------------------------------------------------ + // Enum form + // ------------------------------------------------------------------ + + @Test + void enumRejectsUnknownValueWithEnumPath() { + LOG.info("EXECUTING: enumRejectsUnknownValueWithEnumPath"); + final var v = JtdValidator.compile(Json.parse("{\"enum\": [\"a\", \"b\"]}")); + final var result = v.validate(Json.parse("\"c\"")); + assertThat(result.isValid()).isFalse(); + assertThat(result.errors().getFirst().instancePath()).isEqualTo(""); + assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/enum"); + } + + @Test + void enumRejectsNonStringWithEnumPath() { + LOG.info("EXECUTING: enumRejectsNonStringWithEnumPath"); + final var v = JtdValidator.compile(Json.parse("{\"enum\": [\"a\", \"b\"]}")); + final var result = v.validate(Json.parse("42")); + assertThat(result.isValid()).isFalse(); + assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/enum"); + } + + // ------------------------------------------------------------------ + // Elements form + // ------------------------------------------------------------------ + + @Test + void elementsRejectsNonArrayAtRootPath() { + LOG.info("EXECUTING: elementsRejectsNonArrayAtRootPath"); + final var v = JtdValidator.compile(Json.parse("{\"elements\": {\"type\": \"string\"}}")); + final var result = v.validate(Json.parse("42")); + assertThat(result.isValid()).isFalse(); + assertThat(result.errors().getFirst().instancePath()).isEqualTo(""); + assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/elements"); + } + + @Test + void elementsReportsChildErrorsWithCorrectPaths() { + LOG.info("EXECUTING: elementsReportsChildErrorsWithCorrectPaths"); + final var v = JtdValidator.compile(Json.parse("{\"elements\": {\"type\": \"string\"}}")); + final var result = v.validate(Json.parse("[\"ok\", 42, \"fine\", true]")); + assertThat(result.isValid()).isFalse(); + LOG.fine(() -> "Errors: " + result.errors()); + + assertThat(result.errors()).anyMatch(e -> + e.instancePath().equals("/1") && e.schemaPath().equals("/elements/type")); + assertThat(result.errors()).anyMatch(e -> + e.instancePath().equals("/3") && e.schemaPath().equals("/elements/type")); + } + + // ------------------------------------------------------------------ + // Properties form + // ------------------------------------------------------------------ + + @Test + void propertiesRejectsNonObjectWithPropertiesPath() { + LOG.info("EXECUTING: propertiesRejectsNonObjectWithPropertiesPath"); + final var v = JtdValidator.compile(Json.parse( + "{\"properties\": {\"name\": {\"type\": \"string\"}}}")); + final var result = v.validate(Json.parse("42")); + assertThat(result.isValid()).isFalse(); + assertThat(result.errors().getFirst().instancePath()).isEqualTo(""); + assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/properties"); + } + + @Test + void optionalPropertiesOnlyRejectsNonObjectWithOptionalPath() { + LOG.info("EXECUTING: optionalPropertiesOnlyRejectsNonObjectWithOptionalPath"); + final var v = JtdValidator.compile(Json.parse( + "{\"optionalProperties\": {\"email\": {\"type\": \"string\"}}}")); + final var result = v.validate(Json.parse("42")); + assertThat(result.isValid()).isFalse(); + assertThat(result.errors().getFirst().instancePath()).isEqualTo(""); + assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/optionalProperties"); + } + + @Test + void propertiesMissingRequiredKeyError() { + LOG.info("EXECUTING: propertiesMissingRequiredKeyError"); + final var schema = Json.parse(""" + {"properties": {"name": {"type": "string"}, "age": {"type": "uint8"}}} + """); + final var v = JtdValidator.compile(schema); + final var result = v.validate(Json.parse("{\"name\": \"Alice\"}")); + assertThat(result.isValid()).isFalse(); + assertThat(result.errors()).anyMatch(e -> + e.instancePath().equals("") && e.schemaPath().equals("/properties/age")); + } + + @Test + void propertiesAdditionalPropertyError() { + LOG.info("EXECUTING: propertiesAdditionalPropertyError"); + final var schema = Json.parse(""" + {"properties": {"name": {"type": "string"}}} + """); + final var v = JtdValidator.compile(schema); + final var result = v.validate(Json.parse("{\"name\": \"Alice\", \"extra\": true}")); + assertThat(result.isValid()).isFalse(); + assertThat(result.errors()).anyMatch(e -> + e.instancePath().equals("/extra") && e.schemaPath().equals("")); + } + + @Test + void propertiesChildValueError() { + LOG.info("EXECUTING: propertiesChildValueError"); + final var schema = Json.parse(""" + {"properties": {"age": {"type": "uint8"}}} + """); + final var v = JtdValidator.compile(schema); + final var result = v.validate(Json.parse("{\"age\": \"not a number\"}")); + assertThat(result.isValid()).isFalse(); + assertThat(result.errors()).anyMatch(e -> + e.instancePath().equals("/age") && e.schemaPath().equals("/properties/age/type")); + } + + @Test + void optionalPropertiesChildValueError() { + LOG.info("EXECUTING: optionalPropertiesChildValueError"); + final var schema = Json.parse(""" + {"optionalProperties": {"email": {"type": "string"}}} + """); + final var v = JtdValidator.compile(schema); + final var result = v.validate(Json.parse("{\"email\": 42}")); + assertThat(result.isValid()).isFalse(); + assertThat(result.errors()).anyMatch(e -> + e.instancePath().equals("/email") + && e.schemaPath().equals("/optionalProperties/email/type")); + } + + // ------------------------------------------------------------------ + // Values form + // ------------------------------------------------------------------ + + @Test + void valuesRejectsNonObjectAtRootPath() { + LOG.info("EXECUTING: valuesRejectsNonObjectAtRootPath"); + final var v = JtdValidator.compile(Json.parse("{\"values\": {\"type\": \"string\"}}")); + final var result = v.validate(Json.parse("42")); + assertThat(result.isValid()).isFalse(); + assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/values"); + } + + @Test + void valuesReportsChildErrors() { + LOG.info("EXECUTING: valuesReportsChildErrors"); + final var v = JtdValidator.compile(Json.parse("{\"values\": {\"type\": \"string\"}}")); + final var result = v.validate(Json.parse("{\"a\": \"ok\", \"b\": 42}")); + assertThat(result.isValid()).isFalse(); + assertThat(result.errors()).anyMatch(e -> + e.instancePath().equals("/b") && e.schemaPath().equals("/values/type")); + } + + // ------------------------------------------------------------------ + // Discriminator form + // ------------------------------------------------------------------ + + @Test + void discriminatorNotObjectError() { + LOG.info("EXECUTING: discriminatorNotObjectError"); + final var schema = Json.parse(""" + {"discriminator": "type", "mapping": {"a": {"properties": {"x": {"type": "string"}}}}} + """); + final var v = JtdValidator.compile(schema); + final var result = v.validate(Json.parse("42")); + assertThat(result.isValid()).isFalse(); + assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/discriminator"); + } + + @Test + void discriminatorMissingTagError() { + LOG.info("EXECUTING: discriminatorMissingTagError"); + final var schema = Json.parse(""" + {"discriminator": "type", "mapping": {"a": {"properties": {"x": {"type": "string"}}}}} + """); + final var v = JtdValidator.compile(schema); + final var result = v.validate(Json.parse("{\"x\": 1}")); + assertThat(result.isValid()).isFalse(); + assertThat(result.errors().getFirst().instancePath()).isEqualTo(""); + assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/discriminator"); + } + + @Test + void discriminatorTagNotStringError() { + LOG.info("EXECUTING: discriminatorTagNotStringError"); + final var schema = Json.parse(""" + {"discriminator": "type", "mapping": {"a": {"properties": {"x": {"type": "string"}}}}} + """); + final var v = JtdValidator.compile(schema); + final var result = v.validate(Json.parse("{\"type\": 42}")); + assertThat(result.isValid()).isFalse(); + assertThat(result.errors().getFirst().instancePath()).isEqualTo("/type"); + assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/discriminator"); + } + + @Test + void discriminatorTagNotInMappingError() { + LOG.info("EXECUTING: discriminatorTagNotInMappingError"); + final var schema = Json.parse(""" + {"discriminator": "type", "mapping": {"a": {"properties": {"x": {"type": "string"}}}}} + """); + final var v = JtdValidator.compile(schema); + final var result = v.validate(Json.parse("{\"type\": \"unknown\"}")); + assertThat(result.isValid()).isFalse(); + assertThat(result.errors().getFirst().instancePath()).isEqualTo("/type"); + assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/mapping"); + } + + @Test + void discriminatorVariantValidationErrors() { + LOG.info("EXECUTING: discriminatorVariantValidationErrors"); + final var schema = Json.parse(""" + {"discriminator": "type", "mapping": {"a": {"properties": {"x": {"type": "string"}}}}} + """); + final var v = JtdValidator.compile(schema); + final var result = v.validate(Json.parse("{\"type\": \"a\", \"x\": 42}")); + assertThat(result.isValid()).isFalse(); + assertThat(result.errors()).anyMatch(e -> + e.instancePath().equals("/x") + && e.schemaPath().equals("/mapping/a/properties/x/type")); + } + + // ------------------------------------------------------------------ + // Nullable + // ------------------------------------------------------------------ + + @Test + void nullableAcceptsNull() { + LOG.info("EXECUTING: nullableAcceptsNull"); + final var v = JtdValidator.compile(Json.parse("{\"type\": \"string\", \"nullable\": true}")); + assertThat(v.validate(Json.parse("null")).isValid()).isTrue(); + assertThat(v.validate(Json.parse("\"hi\"")).isValid()).isTrue(); + } + + @Test + void nullableStillRejectsWrongType() { + LOG.info("EXECUTING: nullableStillRejectsWrongType"); + final var v = JtdValidator.compile(Json.parse("{\"type\": \"string\", \"nullable\": true}")); + final var result = v.validate(Json.parse("42")); + assertThat(result.isValid()).isFalse(); + assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/type"); + } + + // ------------------------------------------------------------------ + // Ref form + // ------------------------------------------------------------------ + + @Test + void refValidatesViaDefinition() { + LOG.info("EXECUTING: refValidatesViaDefinition"); + final var schema = Json.parse(""" + {"definitions": {"addr": {"type": "string"}}, "ref": "addr"} + """); + final var v = JtdValidator.compile(schema); + assertThat(v.validate(Json.parse("\"hello\"")).isValid()).isTrue(); + final var result = v.validate(Json.parse("42")); + assertThat(result.isValid()).isFalse(); + assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/definitions/addr/type"); + } + + // ------------------------------------------------------------------ + // toString returns original schema JSON + // ------------------------------------------------------------------ + + @Test + void toStringReturnsSchemaJson() { + LOG.info("EXECUTING: toStringReturnsSchemaJson"); + final var schemaJson = "{\"type\": \"string\"}"; + final var v = JtdValidator.compile(Json.parse(schemaJson)); + assertThat(v.toString()).isNotEmpty(); + LOG.fine(() -> "toString: " + v); + } + + // ------------------------------------------------------------------ + // Functional interface usage (stream pipeline) + // ------------------------------------------------------------------ + + @Test + void usableInStreamPipeline() { + LOG.info("EXECUTING: usableInStreamPipeline"); + final var v = JtdValidator.compile(Json.parse("{\"type\": \"string\"}")); + final var docs = java.util.List.of( + Json.parse("\"a\""), Json.parse("42"), Json.parse("\"b\""), Json.parse("true")); + final var invalid = docs.stream() + .filter(doc -> !v.validate(doc).isValid()) + .toList(); + assertThat(invalid).hasSize(2); + } + + // ------------------------------------------------------------------ + // Worked example from JTD_STACK_MACHINE_SPEC.md §10 + // ------------------------------------------------------------------ + + @Test + void workedExampleFromSpec() { + LOG.info("EXECUTING: workedExampleFromSpec"); + final var schema = Json.parse(""" + { + "properties": { + "name": {"type": "string"}, + "age": {"type": "uint8"}, + "tags": {"elements": {"type": "string"}} + }, + "optionalProperties": { + "email": {"type": "string"} + } + } + """); + final var instance = Json.parse(""" + { + "name": "Alice", + "age": 300, + "tags": ["a", 42], + "extra": true + } + """); + final var v = JtdValidator.compile(schema); + final var result = v.validate(instance); + assertThat(result.isValid()).isFalse(); + + LOG.fine(() -> "Errors: " + result.errors()); + + assertThat(result.errors()).anyMatch(e -> + e.instancePath().equals("/extra") && e.schemaPath().equals("")); + assertThat(result.errors()).anyMatch(e -> + e.instancePath().equals("/age") && e.schemaPath().equals("/properties/age/type")); + assertThat(result.errors()).anyMatch(e -> + e.instancePath().equals("/tags/1") && e.schemaPath().equals("/properties/tags/elements/type")); + } +} diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/NullableEdgeCaseProbe.java b/json-java21-jtd/src/test/java/json/java21/jtd/NullableEdgeCaseProbe.java new file mode 100644 index 0000000..cecc32c --- /dev/null +++ b/json-java21-jtd/src/test/java/json/java21/jtd/NullableEdgeCaseProbe.java @@ -0,0 +1,615 @@ +package json.java21.jtd; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonValue; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +/// Probes for Nullable modifier edge cases and potential issues +/// +/// Areas to probe: +/// 1. Nullable on all form types +/// 2. Nullable false (explicit) +/// 3. Nested nullable +/// 4. Nullable with required properties +/// 5. Nullable ref +public class NullableEdgeCaseProbe extends JtdTestBase { + + /// Test: Nullable type accepts null + @Test + public void probeNullableTypeAcceptsNull() { + JsonValue schema = Json.parse("{\"type\": \"string\", \"nullable\": true}"); + + Jtd validator = new Jtd(); + + // Null should be valid + assertTrue(validator.validate(schema, Json.parse("null")).isValid()); + + // String should also be valid + assertTrue(validator.validate(schema, Json.parse("\"test\"")).isValid()); + + // Other types should be invalid + assertFalse(validator.validate(schema, Json.parse("123")).isValid()); + + LOG.info(() -> "Nullable type: passed"); + } + + /// Test: Non-nullable type rejects null + @Test + public void probeNonNullableTypeRejectsNull() { + JsonValue schema = Json.parse("{\"type\": \"string\"}"); + + Jtd validator = new Jtd(); + + // Null should be rejected + Jtd.Result result = validator.validate(schema, Json.parse("null")); + assertFalse(result.isValid()); + + LOG.info(() -> "Non-nullable type rejects null: " + !result.isValid()); + } + + /// Test: Nullable explicit false + @Test + public void probeNullableExplicitFalse() { + JsonValue schema = Json.parse("{\"type\": \"string\", \"nullable\": false}"); + + Jtd validator = new Jtd(); + + // Should behave same as non-nullable + assertTrue(validator.validate(schema, Json.parse("\"test\"")).isValid()); + assertFalse(validator.validate(schema, Json.parse("null")).isValid()); + + LOG.info(() -> "Nullable explicit false: passed"); + } + + /// Test: Nullable on empty schema + @Test + public void probeNullableEmptySchema() { + JsonValue schema = Json.parse("{\"nullable\": true}"); + + Jtd validator = new Jtd(); + + // Empty schema accepts anything, nullable adds null acceptance + assertTrue(validator.validate(schema, Json.parse("null")).isValid()); + assertTrue(validator.validate(schema, Json.parse("\"anything\"")).isValid()); + assertTrue(validator.validate(schema, Json.parse("123")).isValid()); + assertTrue(validator.validate(schema, Json.parse("[]")).isValid()); + assertTrue(validator.validate(schema, Json.parse("{}")).isValid()); + + LOG.info(() -> "Nullable empty schema: passed"); + } + + /// Test: Nullable on enum + @Test + public void probeNullableEnum() { + JsonValue schema = Json.parse("{\"enum\": [\"a\", \"b\", \"c\"], \"nullable\": true}"); + + Jtd validator = new Jtd(); + + // Null is valid + assertTrue(validator.validate(schema, Json.parse("null")).isValid()); + + // Enum values are valid + assertTrue(validator.validate(schema, Json.parse("\"a\"")).isValid()); + + // Invalid values still rejected + assertFalse(validator.validate(schema, Json.parse("\"d\"")).isValid()); + assertFalse(validator.validate(schema, Json.parse("123")).isValid()); + + LOG.info(() -> "Nullable enum: passed"); + } + + /// Test: Nullable on elements + @Test + public void probeNullableElements() { + JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\"}, \"nullable\": true}"); + + Jtd validator = new Jtd(); + + // Null is valid + assertTrue(validator.validate(schema, Json.parse("null")).isValid()); + + // Array is valid + assertTrue(validator.validate(schema, Json.parse("[\"a\", \"b\"]")).isValid()); + + // Invalid element still rejected + assertFalse(validator.validate(schema, Json.parse("[\"a\", 123]")).isValid()); + + LOG.info(() -> "Nullable elements: passed"); + } + + /// Test: Nullable on properties + @Test + public void probeNullableProperties() { + JsonValue schema = Json.parse(""" + { + "properties": { + "name": {"type": "string"} + }, + "nullable": true + } + """); + + Jtd validator = new Jtd(); + + // Null is valid + assertTrue(validator.validate(schema, Json.parse("null")).isValid()); + + // Object is valid + assertTrue(validator.validate(schema, Json.parse("{\"name\": \"test\"}")).isValid()); + + // Missing required property still invalid + assertFalse(validator.validate(schema, Json.parse("{}")).isValid()); + + LOG.info(() -> "Nullable properties: passed"); + } + + /// Test: Nullable on values + @Test + public void probeNullableValues() { + JsonValue schema = Json.parse("{\"values\": {\"type\": \"int32\"}, \"nullable\": true}"); + + Jtd validator = new Jtd(); + + // Null is valid + assertTrue(validator.validate(schema, Json.parse("null")).isValid()); + + // Object is valid + assertTrue(validator.validate(schema, Json.parse("{\"a\": 1, \"b\": 2}")).isValid()); + + // Invalid value still rejected + assertFalse(validator.validate(schema, Json.parse("{\"a\": \"not-int\"}")).isValid()); + + LOG.info(() -> "Nullable values: passed"); + } + + /// Test: Nullable on discriminator + @Test + public void probeNullableDiscriminator() { + JsonValue schema = Json.parse(""" + { + "discriminator": "type", + "mapping": { + "a": {"properties": {"value": {"type": \"string\"}}} + }, + "nullable": true + } + """); + + Jtd validator = new Jtd(); + + // Null is valid + assertTrue(validator.validate(schema, Json.parse("null")).isValid()); + + // Valid discriminator object + assertTrue(validator.validate(schema, Json.parse("{\"type\": \"a\", \"value\": \"test\"}")).isValid()); + + // Invalid discriminator still rejected + assertFalse(validator.validate(schema, Json.parse("{\"type\": \"unknown\"}")).isValid()); + + LOG.info(() -> "Nullable discriminator: passed"); + } + + /// Test: Nullable on ref + @Test + public void probeNullableRef() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "item": {"type": \"string\"} + }, + "ref": "item", + "nullable": true + } + """); + + Jtd validator = new Jtd(); + + // Null is valid + assertTrue(validator.validate(schema, Json.parse("null")).isValid()); + + // String is valid + assertTrue(validator.validate(schema, Json.parse("\"test\"")).isValid()); + + // Invalid type still rejected + assertFalse(validator.validate(schema, Json.parse("123")).isValid()); + + LOG.info(() -> "Nullable ref: passed"); + } + + /// Test: Nested nullable (nullable inside nullable) + @Test + public void probeNestedNullable() { + // This is technically valid JSON but may not make semantic sense + // Testing if the implementation handles it gracefully + + JsonValue schema = Json.parse(""" + { + "properties": { + "field": { + "type": "string", + "nullable": true + } + }, + "nullable": true + } + """); + + Jtd validator = new Jtd(); + + // Root null is valid + assertTrue(validator.validate(schema, Json.parse("null")).isValid()); + + // Object with null field is valid + assertTrue(validator.validate(schema, Json.parse("{\"field\": null}")).isValid()); + + // Object with string field is valid + assertTrue(validator.validate(schema, Json.parse("{\"field\": \"test\"}")).isValid()); + + LOG.info(() -> "Nested nullable: passed"); + } + + /// Test: Nullable property value + @Test + public void probeNullablePropertyValue() { + JsonValue schema = Json.parse(""" + { + "properties": { + "name": {"type": "string", "nullable": true} + } + } + """); + + Jtd validator = new Jtd(); + + // Property with null value is valid + assertTrue(validator.validate(schema, Json.parse("{\"name\": null}")).isValid()); + + // Property with string value is valid + assertTrue(validator.validate(schema, Json.parse("{\"name\": \"test\"}")).isValid()); + + // Missing property (if required) would be invalid, but it's optional here + LOG.info(() -> "Nullable property value: passed"); + } + + /// Test: Nullable required property + @Test + public void probeNullableRequiredProperty() { + JsonValue schema = Json.parse(""" + { + "properties": { + "id": {"type": "int32", "nullable": true} + } + } + """); + + Jtd validator = new Jtd(); + + // Required property with null is valid (present, and null is allowed) + assertTrue(validator.validate(schema, Json.parse("{\"id\": null}")).isValid()); + + // Required property with int is valid + assertTrue(validator.validate(schema, Json.parse("{\"id\": 123}")).isValid()); + + // Required property missing is invalid + assertFalse(validator.validate(schema, Json.parse("{}")).isValid()); + + LOG.info(() -> "Nullable required property: passed"); + } + + /// Test: Nullable optional property + @Test + public void probeNullableOptionalProperty() { + JsonValue schema = Json.parse(""" + { + "optionalProperties": { + "note": {"type": "string", "nullable": true} + } + } + """); + + Jtd validator = new Jtd(); + + // Optional property absent is valid + assertTrue(validator.validate(schema, Json.parse("{}")).isValid()); + + // Optional property with null is valid + assertTrue(validator.validate(schema, Json.parse("{\"note\": null}")).isValid()); + + // Optional property with string is valid + assertTrue(validator.validate(schema, Json.parse("{\"note\": \"test\"}")).isValid()); + + LOG.info(() -> "Nullable optional property: passed"); + } + + /// Test: Nullable array element + @Test + public void probeNullableArrayElement() { + JsonValue schema = Json.parse("{\"elements\": {\"type\": \"string\", \"nullable\": true}}"); + + Jtd validator = new Jtd(); + + // Array with null element + assertTrue(validator.validate(schema, Json.parse("[\"a\", null, \"c\"]")).isValid()); + + // Array with all nulls + assertTrue(validator.validate(schema, Json.parse("[null, null, null]")).isValid()); + + // Mixed valid and null + assertTrue(validator.validate(schema, Json.parse("[\"a\", null, \"b\", null]")).isValid()); + + LOG.info(() -> "Nullable array element: passed"); + } + + /// Test: Nullable values in object + @Test + public void probeNullableValuesInObject() { + JsonValue schema = Json.parse("{\"values\": {\"type\": \"int32\", \"nullable\": true}}"); + + Jtd validator = new Jtd(); + + // Object with null values + assertTrue(validator.validate(schema, Json.parse("{\"a\": null, \"b\": 123}")).isValid()); + + // Object with all null values + assertTrue(validator.validate(schema, Json.parse("{\"a\": null, \"b\": null}")).isValid()); + + LOG.info(() -> "Nullable values in object: passed"); + } + + /// Test: Nullable with integer types + @Test + public void probeNullableWithIntegerTypes() { + String[] intTypes = {"int8", "uint8", "int16", "uint16", "int32", "uint32"}; + + for (String type : intTypes) { + JsonValue schema = Json.parse("{\"type\": \"" + type + "\", \"nullable\": true}"); + Jtd validator = new Jtd(); + + // Null is valid + assertTrue(validator.validate(schema, Json.parse("null")).isValid(), + type + " nullable should accept null"); + + // Valid integer is valid + if (type.startsWith("u")) { + assertTrue(validator.validate(schema, Json.parse("100")).isValid()); + } else { + assertTrue(validator.validate(schema, Json.parse("-50")).isValid()); + } + } + + LOG.info(() -> "Nullable with integer types: passed"); + } + + /// Test: Nullable with float types + @Test + public void probeNullableWithFloatTypes() { + JsonValue schema32 = Json.parse("{\"type\": \"float32\", \"nullable\": true}"); + JsonValue schema64 = Json.parse("{\"type\": \"float64\", \"nullable\": true}"); + Jtd validator = new Jtd(); + + // Null valid for both + assertTrue(validator.validate(schema32, Json.parse("null")).isValid()); + assertTrue(validator.validate(schema64, Json.parse("null")).isValid()); + + // Floats valid for both + assertTrue(validator.validate(schema32, Json.parse("3.14")).isValid()); + assertTrue(validator.validate(schema64, Json.parse("3.14")).isValid()); + + LOG.info(() -> "Nullable with float types: passed"); + } + + /// Test: Nullable with timestamp + @Test + public void probeNullableWithTimestamp() { + JsonValue schema = Json.parse("{\"type\": \"timestamp\", \"nullable\": true}"); + Jtd validator = new Jtd(); + + // Null is valid + assertTrue(validator.validate(schema, Json.parse("null")).isValid()); + + // Valid timestamp is valid + assertTrue(validator.validate(schema, Json.parse("\"2023-01-01T00:00:00Z\"")).isValid()); + + // Invalid timestamp still rejected + assertFalse(validator.validate(schema, Json.parse("\"not-a-timestamp\"")).isValid()); + + LOG.info(() -> "Nullable with timestamp: passed"); + } + + /// Test: Nullable with boolean + @Test + public void probeNullableWithBoolean() { + JsonValue schema = Json.parse("{\"type\": \"boolean\", \"nullable\": true}"); + Jtd validator = new Jtd(); + + // Null is valid + assertTrue(validator.validate(schema, Json.parse("null")).isValid()); + + // Booleans are valid + assertTrue(validator.validate(schema, Json.parse("true")).isValid()); + assertTrue(validator.validate(schema, Json.parse("false")).isValid()); + + // Non-boolean still rejected + assertFalse(validator.validate(schema, Json.parse("\"true\"")).isValid()); + + LOG.info(() -> "Nullable with boolean: passed"); + } + + /// Test: Nullable error messages + @Test + public void probeNullableErrorMessages() { + JsonValue nullableSchema = Json.parse("{\"type\": \"string\", \"nullable\": true}"); + JsonValue nonNullableSchema = Json.parse("{\"type\": \"string\"}"); + + Jtd validator = new Jtd(); + + // Non-nullable with null + Jtd.Result nonNullResult = validator.validate(nonNullableSchema, Json.parse("null")); + String nonNullError = nonNullResult.errors().get(0); + + // Nullable with invalid type + Jtd.Result nullInvalidResult = validator.validate(nullableSchema, Json.parse("123")); + String nullInvalidError = nullInvalidResult.errors().get(0); + + LOG.info(() -> "Non-nullable null error: " + nonNullError); + LOG.info(() -> "Nullable invalid type error: " + nullInvalidError); + + // Both should have errors + assertFalse(nonNullResult.isValid()); + assertFalse(nullInvalidResult.isValid()); + } + + /// Test: Nullable must be boolean compilation check + @Test + public void probeNullableMustBeBooleanCompilation() { + // Invalid: nullable is not a boolean + JsonValue schema = Json.parse("{\"type\": \"string\", \"nullable\": \"yes\"}"); + + try { + new Jtd().compile(schema); + LOG.warning(() -> "COMPILATION BUG: Should reject non-boolean nullable"); + } catch (IllegalArgumentException e) { + LOG.info(() -> "Correctly rejected non-boolean nullable: " + e.getMessage()); + } + } + + /// Test: Nullable in definitions + @Test + public void probeNullableInDefinitions() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "maybeString": { + "type": "string", + "nullable": true + } + }, + "properties": { + "field": {"ref": "maybeString"} + } + } + """); + + Jtd validator = new Jtd(); + + // Field can be null + assertTrue(validator.validate(schema, Json.parse("{\"field\": null}")).isValid()); + assertTrue(validator.validate(schema, Json.parse("{\"field\": \"test\"}")).isValid()); + + LOG.info(() -> "Nullable in definitions: passed"); + } + + /// Test: Nullable with complex nested schema + @Test + public void probeNullableWithComplexNestedSchema() { + JsonValue schema = Json.parse(""" + { + "properties": { + "data": { + "elements": { + "properties": { + "value": {"type": "string", "nullable": true} + } + }, + "nullable": true + } + } + } + """); + + Jtd validator = new Jtd(); + + // Null data + assertTrue(validator.validate(schema, Json.parse("{\"data\": null}")).isValid()); + + // Array with null values + assertTrue(validator.validate(schema, Json.parse("{\"data\": [{\"value\": null}]}")).isValid()); + + // Array with string values + assertTrue(validator.validate(schema, Json.parse("{\"data\": [{\"value\": \"test\"}]}")).isValid()); + + LOG.info(() -> "Nullable complex nested: passed"); + } + + /// Test: Nullable discriminator mapping value + /// RFC 8927: Discriminator mapping values cannot be nullable + @Test + public void probeNullableDiscriminatorMappingValue() { + JsonValue schema = Json.parse(""" + { + "discriminator": "type", + "mapping": { + "data": { + "properties": { + "value": {"type": "string"} + }, + "nullable": true + } + } + } + """); + + try { + new Jtd().compile(schema); + LOG.warning(() -> "COMPILATION BUG: Should reject nullable discriminator mapping"); + } catch (IllegalArgumentException e) { + LOG.info(() -> "Correctly rejected nullable discriminator mapping: " + e.getMessage()); + } + } + + /// Test: Multiple nullable fields + @Test + public void probeMultipleNullableFields() { + JsonValue schema = Json.parse(""" + { + "properties": { + "a": {"type": "string", "nullable": true}, + "b": {"type": "int32", "nullable": true}, + "c": {"type": "boolean", "nullable": true} + } + } + """); + + Jtd validator = new Jtd(); + + // All null + assertTrue(validator.validate(schema, Json.parse("{\"a\": null, \"b\": null, \"c\": null}")).isValid()); + + // All valid + assertTrue(validator.validate(schema, Json.parse("{\"a\": \"test\", \"b\": 123, \"c\": true}")).isValid()); + + // Mixed + assertTrue(validator.validate(schema, Json.parse("{\"a\": null, \"b\": 456, \"c\": null}")).isValid()); + + LOG.info(() -> "Multiple nullable fields: passed"); + } + + /// Test: Nullable with additionalProperties + @Test + public void probeNullableWithAdditionalProperties() { + JsonValue schema = Json.parse(""" + { + "properties": { + "id": {"type": "int32", "nullable": true} + }, + "additionalProperties": false + } + """); + + Jtd validator = new Jtd(); + + // Valid with null + assertTrue(validator.validate(schema, Json.parse("{\"id\": null}")).isValid()); + + // Valid with int + assertTrue(validator.validate(schema, Json.parse("{\"id\": 123}")).isValid()); + + // Invalid: extra property + assertFalse(validator.validate(schema, Json.parse("{\"id\": 123, \"extra\": \"bad\"}")).isValid()); + + LOG.info(() -> "Nullable with additionalProperties: passed"); + } +} diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/PropertiesEdgeCaseProbe.java b/json-java21-jtd/src/test/java/json/java21/jtd/PropertiesEdgeCaseProbe.java new file mode 100644 index 0000000..5c55535 --- /dev/null +++ b/json-java21-jtd/src/test/java/json/java21/jtd/PropertiesEdgeCaseProbe.java @@ -0,0 +1,380 @@ +package json.java21.jtd; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonValue; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +/// Probes for Properties form edge cases and potential issues +/// +/// Areas to probe: +/// 1. Empty properties with additionalProperties: false +/// 2. Additional properties detection with complex keys +/// 3. Required vs optional property precedence +/// 4. Properties with null values +/// 5. Nested properties validation order +public class PropertiesEdgeCaseProbe extends JtdTestBase { + + /// Test: Empty properties with additionalProperties: false + /// Should reject ALL additional properties + @Test + public void probeEmptyPropertiesRejectsAllExtras() { + JsonValue schema = Json.parse("{\"properties\": {}, \"additionalProperties\": false}"); + + JsonValue instance = Json.parse("{\"anyKey\": \"anyValue\"}"); + Jtd.Result result = new Jtd().validate(schema, instance); + + LOG.info(() -> "Empty properties rejects extras: " + !result.isValid()); + + // Should reject because there are no allowed properties + assertFalse(result.isValid(), "Empty properties + additionalProperties:false should reject all fields"); + + // And should report the extra property + String error = result.errors().get(0); + assertThat(error).contains("anyKey"); + } + + /// Test: Empty properties without additionalProperties (defaults to false) + /// RFC 8927: additionalProperties defaults to false + @Test + public void probeEmptyPropertiesDefaultAdditionalProperties() { + JsonValue schema = Json.parse("{\"properties\": {}}"); + + JsonValue instance = Json.parse("{\"surprise\": \"field\"}"); + Jtd.Result result = new Jtd().validate(schema, instance); + + LOG.info(() -> "Empty properties default strict: " + !result.isValid()); + + // Should reject - default is strict + assertFalse(result.isValid(), "Empty properties should default to rejecting extras"); + } + + /// Test: Additional properties with special characters in keys + /// Keys with dots, slashes, spaces should be handled correctly + @Test + public void probeAdditionalPropertiesSpecialKeys() { + JsonValue schema = Json.parse("{\"properties\": {\"normal\": {}}, \"additionalProperties\": false}"); + + // These should all be rejected as additional properties + String[] specialKeys = { + "{\"dotted.key\": 1}", + "{\"key/with/slash\": 1}", + "{\"key with space\": 1}", + "{\"\\u0000nullchar\": 1}" + }; + + Jtd validator = new Jtd(); + + for (String keyJson : specialKeys) { + try { + JsonValue instance = Json.parse(keyJson); + Jtd.Result result = validator.validate(schema, instance); + + LOG.fine(() -> "Special key validation: " + keyJson + " -> valid=" + result.isValid()); + + // Should be invalid + assertFalse(result.isValid(), "Should reject additional property: " + keyJson); + } catch (Exception e) { + LOG.warning(() -> "Failed to parse or validate: " + keyJson + " - " + e.getMessage()); + } + } + } + + /// Test: Properties with duplicate key detection + /// This probes if the implementation correctly identifies all extra properties + @Test + public void probeMultipleAdditionalPropertiesAllReported() { + JsonValue schema = Json.parse("{\"properties\": {\"allowed\": {}}, \"additionalProperties\": false}"); + + JsonValue instance = Json.parse("{\"allowed\": 1, \"extra1\": 2, \"extra2\": 3, \"extra3\": 4}"); + Jtd.Result result = new Jtd().validate(schema, instance); + + LOG.info(() -> "Multiple extras error count: " + result.errors().size()); + + assertFalse(result.isValid()); + + // Probe: Are all three extra properties reported? + List errors = result.errors(); + + Set reportedExtras = new HashSet<>(); + for (String error : errors) { + if (error.contains("extra1")) reportedExtras.add("extra1"); + if (error.contains("extra2")) reportedExtras.add("extra2"); + if (error.contains("extra3")) reportedExtras.add("extra3"); + } + + LOG.info(() -> "Reported extras: " + reportedExtras); + + // Ideally all three should be reported + assertThat(reportedExtras) + .as("Should report all additional properties") + .hasSizeGreaterThanOrEqualTo(1); + } + + /// Test: Required property with null value + /// Required means "must be present", null is a valid value + @Test + public void probeRequiredPropertyWithNullValue() { + JsonValue schema = Json.parse("{\"properties\": {\"field\": {}}}"); + + JsonValue instance = Json.parse("{\"field\": null}"); + Jtd.Result result = new Jtd().validate(schema, instance); + + LOG.info(() -> "Required with null result: " + result.isValid()); + + // Per RFC 8927: property is present (key exists), so satisfies "required" + // Empty schema {} accepts null + assertTrue(result.isValid(), "Required property with null should be valid"); + } + + /// Test: Required property missing vs null value + @Test + public void probeMissingVsNullProperty() { + JsonValue schema = Json.parse("{\"properties\": {\"field\": {}}}"); + + // Missing property + JsonValue missing = Json.parse("{}"); + Jtd.Result missingResult = new Jtd().validate(schema, missing); + + // Null property + JsonValue nullValue = Json.parse("{\"field\": null}"); + Jtd.Result nullResult = new Jtd().validate(schema, nullValue); + + LOG.info(() -> "Missing: " + !missingResult.isValid() + ", Null: " + nullResult.isValid()); + + assertFalse(missingResult.isValid(), "Missing required property should be invalid"); + assertTrue(nullResult.isValid(), "Required property with null should be valid"); + } + + /// Test: Optional property with various values + @Test + public void probeOptionalPropertyValues() { + JsonValue schema = Json.parse("{\"optionalProperties\": {\"opt\": {\"type\": \"string\"}}}"); + + Jtd validator = new Jtd(); + + // Missing - valid + assertTrue(validator.validate(schema, Json.parse("{}")).isValid()); + + // Null - should validate against the type schema (empty schema accepts null, but type:string rejects null) + Jtd.Result nullResult = validator.validate(schema, Json.parse("{\"opt\": null}")); + LOG.info(() -> "Optional null result: " + nullResult.isValid()); + // This depends on implementation - null with type:string should fail + + // Valid string + assertTrue(validator.validate(schema, Json.parse("{\"opt\": \"value\"}")).isValid()); + + // Invalid type + assertFalse(validator.validate(schema, Json.parse("{\"opt\": 123}")).isValid()); + } + + /// Test: Properties and optionalProperties with same key + /// RFC 8927: This should be a compile-time error + @Test + public void probeOverlappingRequiredAndOptionalCompilation() { + JsonValue schema = Json.parse(""" + { + "properties": { + "field": {"type": "string"} + }, + "optionalProperties": { + "field": {"type": "int32"} + } + } + """); + + Jtd validator = new Jtd(); + + try { + validator.compile(schema); + LOG.warning(() -> "COMPILATION BUG: Should reject overlapping property keys"); + } catch (IllegalArgumentException e) { + LOG.info(() -> "Correctly rejected overlapping keys: " + e.getMessage()); + } + } + + /// Test: Nested properties with different additionalProperties settings + @Test + public void probeNestedPropertiesDifferentStrictness() { + JsonValue schema = Json.parse(""" + { + "properties": { + "outer": { + "properties": { + "inner": {} + }, + "additionalProperties": true + } + }, + "additionalProperties": false + } + """); + + Jtd validator = new Jtd(); + + // Valid: outer allows extras + JsonValue valid = Json.parse("{\"outer\": {\"inner\": 1, \"extra\": 2}}"); + assertTrue(validator.validate(schema, valid).isValid()); + + // Invalid: root level doesn't allow extras + JsonValue invalid = Json.parse("{\"outer\": {\"inner\": 1}, \"rootExtra\": 3}"); + Jtd.Result result = validator.validate(schema, invalid); + + LOG.info(() -> "Root extra rejection: " + !result.isValid()); + assertFalse(result.isValid(), "Should reject extra property at root level"); + } + + /// Test: Properties validation order + /// Are errors reported in consistent order? + @Test + public void probePropertiesValidationOrder() { + JsonValue schema = Json.parse(""" + { + "properties": { + "z": {}, + "a": {}, + "m": {} + } + } + """); + + // All missing + JsonValue instance = Json.parse("{}"); + Jtd.Result result = new Jtd().validate(schema, instance); + + LOG.info(() -> "Missing properties error count: " + result.errors().size()); + + assertThat(result.errors().size()).isEqualTo(3); + + // Probe: Order of errors (alphabetical vs declaration order?) + String[] errors = result.errors().toArray(new String[0]); + LOG.info(() -> "Error order: " + String.join(", ", errors)); + + // Note: Order is unspecified by RFC, but should be deterministic + } + + /// Test: Empty string property names + @Test + public void probeEmptyStringPropertyNames() { + JsonValue schema = Json.parse("{\"properties\": {\"\": {}}}"); + + JsonValue valid = Json.parse("{\"\": \"value\"}"); + Jtd.Result result = new Jtd().validate(schema, valid); + + LOG.info(() -> "Empty string property result: " + result.isValid()); + + // Empty string is a valid property name in JSON + assertTrue(result.isValid(), "Empty string property name should be valid"); + } + + /// Test: Very deep nesting of properties + @Test + public void probeDeepNestingProperties() { + // Create a deeply nested schema + StringBuilder schemaBuilder = new StringBuilder(); + StringBuilder instanceBuilder = new StringBuilder(); + + int depth = 50; + + schemaBuilder.append("{\"properties\": {\"level0\": "); + instanceBuilder.append("{\"level0\": "); + + for (int i = 0; i < depth; i++) { + schemaBuilder.append("{\"properties\": {\"level").append(i + 1).append("\": "); + instanceBuilder.append("{\"level").append(i + 1).append("\": "); + } + + schemaBuilder.append("{\"type\": \"string\"}"); + instanceBuilder.append("\"deepValue\""); + + for (int i = 0; i <= depth; i++) { + schemaBuilder.append("}}"); + instanceBuilder.append("}"); + } + + JsonValue schema = Json.parse(schemaBuilder.toString()); + JsonValue validInstance = Json.parse(instanceBuilder.toString()); + + Jtd validator = new Jtd(); + + LOG.info(() -> "Testing depth: " + depth); + + Jtd.Result validResult = validator.validate(schema, validInstance); + LOG.info(() -> "Deep valid result: " + validResult.isValid()); + assertTrue(validResult.isValid(), "Deep nesting should work"); + + // Test invalid at deep level + JsonValue invalidInstance = Json.parse(instanceBuilder.toString().replace("\"deepValue\"", "123")); + Jtd.Result invalidResult = validator.validate(schema, invalidInstance); + + LOG.info(() -> "Deep invalid result: " + !invalidResult.isValid()); + assertFalse(invalidResult.isValid(), "Should detect error at deep level"); + } + + /// Test: Property count limits + @Test + public void probeLargeNumberOfProperties() { + // Schema with many properties + StringBuilder schemaBuilder = new StringBuilder("{\"properties\": {"); + StringBuilder instanceBuilder = new StringBuilder("{"); + + int count = 100; + + for (int i = 0; i < count; i++) { + if (i > 0) { + schemaBuilder.append(", "); + instanceBuilder.append(", "); + } + schemaBuilder.append("\"prop").append(i).append("\": {\"type\": \"string\"}"); + instanceBuilder.append("\"prop").append(i).append("\": \"value").append(i).append("\""); + } + + schemaBuilder.append("}}"); + instanceBuilder.append("}"); + + JsonValue schema = Json.parse(schemaBuilder.toString()); + JsonValue instance = Json.parse(instanceBuilder.toString()); + + Jtd validator = new Jtd(); + Jtd.Result result = validator.validate(schema, instance); + + LOG.info(() -> "Large property count (" + count + ") result: " + result.isValid()); + assertTrue(result.isValid(), "Large number of properties should work"); + } + + /// Test: Property name collisions with prototype pollution concerns + @Test + public void probePrototypePollutionPropertyNames() { + JsonValue schema = Json.parse("{\"properties\": {}, \"additionalProperties\": false}"); + + // These are technically valid property names in JSON + String[] prototypeNames = { + "{\"__proto__\": 1}", + "{\"constructor\": 1}", + "{\"toString\": 1}", + "{\"hasOwnProperty\": 1}" + }; + + Jtd validator = new Jtd(); + + for (String nameJson : prototypeNames) { + try { + JsonValue instance = Json.parse(nameJson); + Jtd.Result result = validator.validate(schema, instance); + + LOG.fine(() -> "Prototype name validation: " + nameJson + " -> valid=" + result.isValid()); + + // These should all be rejected as additional properties + assertFalse(result.isValid(), "Should reject: " + nameJson); + } catch (Exception e) { + LOG.warning(() -> "Issue with: " + nameJson + " - " + e.getMessage()); + } + } + } +} diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/RefEdgeCaseProbe.java b/json-java21-jtd/src/test/java/json/java21/jtd/RefEdgeCaseProbe.java new file mode 100644 index 0000000..e1f6b97 --- /dev/null +++ b/json-java21-jtd/src/test/java/json/java21/jtd/RefEdgeCaseProbe.java @@ -0,0 +1,533 @@ +package json.java21.jtd; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonValue; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +/// Probes for Ref schema edge cases and potential issues +/// +/// Areas to probe: +/// 1. Forward references +/// 2. Circular/recursive references +/// 3. Nested references +/// 4. Ref in different contexts (elements, values, properties) +/// 5. Ref to different form types +public class RefEdgeCaseProbe extends JtdTestBase { + + /// Test: Forward reference resolution + /// Definitions can reference each other in any order + @Test + public void probeForwardReference() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "a": {"ref": "b"}, + "b": {"type": "string"} + }, + "ref": "a" + } + """); + + JsonValue valid = Json.parse("\"test\""); + Jtd.Result result = new Jtd().validate(schema, valid); + + LOG.info(() -> "Forward reference result: " + result.isValid()); + assertTrue(result.isValid(), "Forward reference should resolve"); + } + + /// Test: Mutual recursion + /// Two definitions that reference each other + @Test + public void probeMutualRecursion() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "parent": { + "properties": { + "children": {"elements": {"ref": "child"}} + } + }, + "child": { + "properties": { + "parent": {"ref": "parent"} + } + } + }, + "ref": "parent" + } + """); + + // This is a valid instance (empty children, no parent reference) + JsonValue valid = Json.parse("{\"children\": []}"); + Jtd.Result result = new Jtd().validate(schema, valid); + + LOG.info(() -> "Mutual recursion result: " + result.isValid()); + + // Should compile and validate + assertTrue(result.isValid(), "Mutual recursion should work"); + } + + /// Test: Deeply nested refs + @Test + public void probeDeeplyNestedRefs() { + // Create a chain of references + StringBuilder schemaBuilder = new StringBuilder(); + schemaBuilder.append("{\"definitions\": {"); + + int depth = 50; + + for (int i = 0; i < depth; i++) { + if (i > 0) schemaBuilder.append(", "); + schemaBuilder.append("\"level").append(i).append("\": {"); + if (i < depth - 1) { + schemaBuilder.append("\"ref\": \"level").append(i + 1).append("\""); + } else { + schemaBuilder.append("\"type\": \"string\""); + } + schemaBuilder.append("}"); + } + + schemaBuilder.append("}, \"ref\": \"level0\"}"); + + JsonValue schema = Json.parse(schemaBuilder.toString()); + JsonValue instance = Json.parse("\"test\""); + + Jtd.Result result = new Jtd().validate(schema, instance); + + LOG.info(() -> "Deeply nested refs (" + depth + "): " + result.isValid()); + assertTrue(result.isValid(), "Deep reference chain should resolve"); + } + + /// Test: Ref in elements context + @Test + public void probeRefInElements() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "item": {"type": "string"} + }, + "elements": {"ref": "item"} + } + """); + + Jtd validator = new Jtd(); + + // Valid + assertTrue(validator.validate(schema, Json.parse("[\"a\", \"b\", \"c\"]")).isValid()); + + // Invalid element + Jtd.Result result = validator.validate(schema, Json.parse("[\"a\", 123]")); + assertFalse(result.isValid(), "Should reject invalid element via ref"); + + LOG.info(() -> "Ref in elements: error count=" + result.errors().size()); + } + + /// Test: Ref in values context + @Test + public void probeRefInValues() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "value": {"type": "int32"} + }, + "values": {"ref": "value"} + } + """); + + Jtd validator = new Jtd(); + + // Valid + assertTrue(validator.validate(schema, Json.parse("{\"a\": 1, \"b\": 2}")).isValid()); + + // Invalid value + Jtd.Result result = validator.validate(schema, Json.parse("{\"a\": \"not-int\"}")); + assertFalse(result.isValid()); + + LOG.info(() -> "Ref in values: " + !result.isValid()); + } + + /// Test: Ref in properties context + @Test + public void probeRefInProperties() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "address": { + "properties": { + "street": {"type": "string"}, + "city": {"type": "string"} + } + } + }, + "properties": { + "home": {"ref": "address"}, + "work": {"ref": "address"} + } + } + """); + + JsonValue valid = Json.parse(""" + { + "home": {"street": "123 Main", "city": "Boston"}, + "work": {"street": "456 Oak", "city": "NYC"} + } + """); + + Jtd.Result result = new Jtd().validate(schema, valid); + assertTrue(result.isValid(), "Ref in properties should work"); + + LOG.info(() -> "Ref in properties: " + result.isValid()); + } + + /// Test: Ref to empty schema + @Test + public void probeRefToEmptySchema() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "anything": {} + }, + "ref": "anything" + } + """); + + Jtd validator = new Jtd(); + + // Empty schema accepts anything + assertTrue(validator.validate(schema, Json.parse("null")).isValid()); + assertTrue(validator.validate(schema, Json.parse("123")).isValid()); + assertTrue(validator.validate(schema, Json.parse("\"string\"")).isValid()); + assertTrue(validator.validate(schema, Json.parse("[]")).isValid()); + assertTrue(validator.validate(schema, Json.parse("{}")).isValid()); + + LOG.info(() -> "Ref to empty schema accepts anything (correct)"); + } + + /// Test: Ref to discriminator + @Test + public void probeRefToDiscriminator() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "shape": { + "discriminator": "type", + "mapping": { + "circle": {"properties": {"radius": {"type": "float64"}}}, + "square": {"properties": {"side": {"type": "float64"}}} + } + } + }, + "ref": "shape" + } + """); + + Jtd validator = new Jtd(); + + // Valid circles + assertTrue(validator.validate(schema, Json.parse("{\"type\": \"circle\", \"radius\": 5.0}")).isValid()); + + // Valid squares + assertTrue(validator.validate(schema, Json.parse("{\"type\": \"square\", \"side\": 10.0}")).isValid()); + + // Invalid + Jtd.Result result = validator.validate(schema, Json.parse("{\"type\": \"unknown\"}")); + assertFalse(result.isValid()); + + LOG.info(() -> "Ref to discriminator: " + !result.isValid()); + } + + /// Test: Ref to nullable schema + @Test + public void probeRefToNullableSchema() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "maybeString": { + "type": "string", + "nullable": true + } + }, + "properties": { + "field": {"ref": "maybeString"} + } + } + """); + + Jtd validator = new Jtd(); + + // Valid: null + assertTrue(validator.validate(schema, Json.parse("{\"field\": null}")).isValid()); + + // Valid: string + assertTrue(validator.validate(schema, Json.parse("{\"field\": \"test\"}")).isValid()); + + // Invalid: number + assertFalse(validator.validate(schema, Json.parse("{\"field\": 123}")).isValid()); + + LOG.info(() -> "Ref to nullable: test passed"); + } + + /// Test: Ref to elements + @Test + public void probeRefToElements() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "stringArray": { + "elements": {"type": "string"} + } + }, + "ref": "stringArray" + } + """); + + // Valid + assertTrue(new Jtd().validate(schema, Json.parse("[\"a\", \"b\"]")).isValid()); + + // Invalid + Jtd.Result result = new Jtd().validate(schema, Json.parse("[1, 2, 3]")); + assertFalse(result.isValid()); + + LOG.info(() -> "Ref to elements: " + !result.isValid()); + } + + /// Test: Ref to values + @Test + public void probeRefToValues() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "intMap": { + "values": {"type": "int32"} + } + }, + "ref": "intMap" + } + """); + + // Valid + assertTrue(new Jtd().validate(schema, Json.parse("{\"a\": 1, \"b\": 2}")).isValid()); + + // Invalid + Jtd.Result result = new Jtd().validate(schema, Json.parse("{\"a\": \"string\"}")); + assertFalse(result.isValid()); + + LOG.info(() -> "Ref to values: " + !result.isValid()); + } + + /// Test: Ref to properties + @Test + public void probeRefToProperties() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "person": { + "properties": { + "name": {"type": "string"} + } + } + }, + "ref": "person" + } + """); + + // Valid + assertTrue(new Jtd().validate(schema, Json.parse("{\"name\": \"Alice\"}")).isValid()); + + // Invalid: missing required + Jtd.Result result = new Jtd().validate(schema, Json.parse("{}")); + assertFalse(result.isValid()); + + LOG.info(() -> "Ref to properties: " + !result.isValid()); + } + + /// Test: Ref to enum + @Test + public void probeRefToEnum() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "color": { + "enum": ["red", "green", "blue"] + } + }, + "ref": "color" + } + """); + + // Valid + assertTrue(new Jtd().validate(schema, Json.parse("\"red\"")).isValid()); + + // Invalid + Jtd.Result result = new Jtd().validate(schema, Json.parse("\"yellow\"")); + assertFalse(result.isValid()); + + LOG.info(() -> "Ref to enum: " + !result.isValid()); + } + + /// Test: Ref to type + @Test + public void probeRefToType() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "id": {"type": "int32"} + }, + "ref": "id" + } + """); + + // Valid + assertTrue(new Jtd().validate(schema, Json.parse("42")).isValid()); + + // Invalid + Jtd.Result result = new Jtd().validate(schema, Json.parse("\"not-int\"")); + assertFalse(result.isValid()); + + LOG.info(() -> "Ref to type: " + !result.isValid()); + } + + /// Test: Unused definitions + @Test + public void probeUnusedDefinitions() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "used": {"type": "string"}, + "unused": {"type": "int32"} + }, + "ref": "used" + } + """); + + // Should compile fine even with unused definition + JsonValue instance = Json.parse("\"test\""); + Jtd.Result result = new Jtd().validate(schema, instance); + + LOG.info(() -> "Unused definitions: " + result.isValid()); + assertTrue(result.isValid()); + } + + /// Test: Multiple refs to same definition + @Test + public void probeMultipleRefsToSameDefinition() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "nameType": {"type": "string"} + }, + "properties": { + "firstName": {"ref": "nameType"}, + "lastName": {"ref": "nameType"}, + "middleName": {"ref": "nameType"} + } + } + """); + + JsonValue instance = Json.parse(""" + { + "firstName": "John", + "lastName": "Doe", + "middleName": "Q" + } + """); + + Jtd.Result result = new Jtd().validate(schema, instance); + assertTrue(result.isValid()); + + LOG.info(() -> "Multiple refs to same def: " + result.isValid()); + } + + /// Test: Recursive ref with complex nesting + @Test + public void probeComplexRecursiveRef() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "tree": { + "properties": { + "value": {"type": "string"}, + "left": {"nullable": true, "ref": "tree"}, + "right": {"nullable": true, "ref": "tree"} + } + } + }, + "ref": "tree" + } + """); + + // Valid binary tree + JsonValue tree = Json.parse(""" + { + "value": "root", + "left": { + "value": "left", + "left": null, + "right": null + }, + "right": { + "value": "right", + "left": null, + "right": null + } + } + """); + + Jtd.Result result = new Jtd().validate(schema, tree); + LOG.info(() -> "Complex recursive ref: " + result.isValid()); + assertTrue(result.isValid()); + } + + /// Test: Ref in optionalProperties + @Test + public void probeRefInOptionalProperties() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "details": {"type": "string"} + }, + "optionalProperties": { + "info": {"ref": "details"} + } + } + """); + + Jtd validator = new Jtd(); + + // Missing - valid + assertTrue(validator.validate(schema, Json.parse("{}")).isValid()); + + // Present and valid + assertTrue(validator.validate(schema, Json.parse("{\"info\": \"test\"}")).isValid()); + + // Present but invalid type + assertFalse(validator.validate(schema, Json.parse("{\"info\": 123}")).isValid()); + + LOG.info(() -> "Ref in optionalProperties: passed"); + } + + /// Test: Ref resolution at multiple levels + @Test + public void probeMultiLevelRefResolution() { + JsonValue schema = Json.parse(""" + { + "definitions": { + "level1": {"ref": "level2"}, + "level2": {"ref": "level3"}, + "level3": {"type": "boolean"} + }, + "ref": "level1" + } + """); + + assertTrue(new Jtd().validate(schema, Json.parse("true")).isValid()); + assertFalse(new Jtd().validate(schema, Json.parse("\"not-bool\"")).isValid()); + + LOG.info(() -> "Multi-level ref resolution: passed"); + } +} diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/TypeValidationEdgeCaseProbe.java b/json-java21-jtd/src/test/java/json/java21/jtd/TypeValidationEdgeCaseProbe.java new file mode 100644 index 0000000..265adca --- /dev/null +++ b/json-java21-jtd/src/test/java/json/java21/jtd/TypeValidationEdgeCaseProbe.java @@ -0,0 +1,351 @@ +package json.java21.jtd; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonValue; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +/// Probes for Type validation edge cases and potential issues +/// +/// Areas to probe: +/// 1. Integer range boundary values +/// 2. Integer vs float detection (3.0 is int, 3.1 is not) +/// 3. Scientific notation handling +/// 4. Very large numbers +/// 5. Float vs double distinctions +public class TypeValidationEdgeCaseProbe extends JtdTestBase { + + /// Test: Integer type boundary values (exact min/max) + @Test + public void probeIntegerBoundaryValues() { + // int8 boundaries + testIntegerBoundary("int8", -128, 127); + + // uint8 boundaries + testIntegerBoundary("uint8", 0, 255); + + // int16 boundaries + testIntegerBoundary("int16", -32768, 32767); + + // uint16 boundaries + testIntegerBoundary("uint16", 0, 65535); + + // int32 boundaries + testIntegerBoundary("int32", -2147483648, 2147483647); + + // uint32 boundaries + testIntegerBoundary("uint32", 0, 4294967295L); + } + + private void testIntegerBoundary(String type, long min, long max) { + JsonValue schema = Json.parse("{\"type\": \"" + type + "\"}"); + Jtd validator = new Jtd(); + + // Min boundary - should be valid + JsonValue minValue = Json.parse(String.valueOf(min)); + Jtd.Result minResult = validator.validate(schema, minValue); + assertTrue(minResult.isValid(), type + " should accept min value " + min); + + // Max boundary - should be valid + JsonValue maxValue = Json.parse(String.valueOf(max)); + Jtd.Result maxResult = validator.validate(schema, maxValue); + assertTrue(maxResult.isValid(), type + " should accept max value " + max); + + // Min-1 - should be invalid + JsonValue belowMin = Json.parse(String.valueOf(min - 1)); + Jtd.Result belowResult = validator.validate(schema, belowMin); + assertFalse(belowResult.isValid(), type + " should reject " + (min - 1)); + + // Max+1 - should be invalid + JsonValue aboveMax = Json.parse(String.valueOf(max + 1)); + Jtd.Result aboveResult = validator.validate(schema, aboveMax); + assertFalse(aboveResult.isValid(), type + " should reject " + (max + 1)); + + LOG.fine(() -> type + " boundary check passed"); + } + + /// Test: Integer detection with fractional values + /// RFC 8927: "An integer value is a number without a fractional component" + @Test + public void probeIntegerFractionalDetection() { + JsonValue schema = Json.parse("{\"type\": \"int32\"}"); + Jtd validator = new Jtd(); + + // These should be valid (zero fractional part) + String[] validIntegers = {"3.0", "3.00", "3.000", "0.0", "-5.0"}; + for (String value : validIntegers) { + JsonValue instance = Json.parse(value); + Jtd.Result result = validator.validate(schema, instance); + assertTrue(result.isValid(), "int32 should accept integer representation: " + value); + } + + // These should be invalid (non-zero fractional part) + String[] invalidIntegers = {"3.1", "3.01", "3.0001", "0.1", "-5.5"}; + for (String value : invalidIntegers) { + JsonValue instance = Json.parse(value); + Jtd.Result result = validator.validate(schema, instance); + assertFalse(result.isValid(), "int32 should reject fractional value: " + value); + } + + LOG.info(() -> "Integer fractional detection probe passed"); + } + + /// Test: Scientific notation handling + /// Scientific notation that represents integers should be valid + @Test + public void probeScientificNotationIntegers() { + JsonValue schema = Json.parse("{\"type\": \"int32\"}"); + Jtd validator = new Jtd(); + + // Valid scientific notation integers + String[] validScientific = {"1e2", "1E2", "1e+2", "1e-2", "-5e1"}; + // 1e2 = 100, 1e-2 = 0.01 (not integer!) + + // These represent integers + JsonValue int1 = Json.parse("1e2"); // 100 + Jtd.Result r1 = validator.validate(schema, int1); + LOG.info(() -> "1e2 (should be 100): valid=" + r1.isValid()); + + // These do NOT represent integers + JsonValue notInt = Json.parse("1.5e0"); // 1.5 + Jtd.Result r2 = validator.validate(schema, notInt); + assertFalse(r2.isValid(), "1.5 should not be a valid int32"); + + JsonValue alsoNotInt = Json.parse("1e-1"); // 0.1 + Jtd.Result r3 = validator.validate(schema, alsoNotInt); + LOG.info(() -> "1e-1 (0.1): valid=" + r3.isValid()); + } + + /// Test: Float types accept any number + /// RFC 8927: float32 and float64 accept any JSON number + @Test + public void probeFloatTypesAcceptAnyNumber() { + Jtd validator = new Jtd(); + + JsonValue schema32 = Json.parse("{\"type\": \"float32\"}"); + JsonValue schema64 = Json.parse("{\"type\": \"float64\"}"); + + // Various number formats + String[] numbers = { + "1", "-1", "0", + "1.5", "-3.14159", "0.0001", + "1e10", "1e-10", "-5E+5", + "1.7976931348623157e308", // Near double max + "-1.7976931348623157e308" + }; + + for (String num : numbers) { + try { + JsonValue instance = Json.parse(num); + Jtd.Result r32 = validator.validate(schema32, instance); + Jtd.Result r64 = validator.validate(schema64, instance); + + LOG.fine(() -> num + " -> float32:" + r32.isValid() + " float64:" + r64.isValid()); + + assertTrue(r32.isValid(), "float32 should accept: " + num); + assertTrue(r64.isValid(), "float64 should accept: " + num); + } catch (Exception e) { + LOG.warning(() -> "Failed to parse: " + num + " - " + e.getMessage()); + } + } + } + + /// Test: Non-numbers rejected by float types + @Test + public void probeFloatTypesRejectNonNumbers() { + Jtd validator = new Jtd(); + + JsonValue schema = Json.parse("{\"type\": \"float32\"}"); + + // Non-number values + String[] nonNumbers = {"\"string\"", "true", "false", "null", "[]", "{}"}; + + for (String value : nonNumbers) { + JsonValue instance = Json.parse(value); + Jtd.Result result = validator.validate(schema, instance); + + assertFalse(result.isValid(), "float32 should reject: " + value); + } + } + + /// Test: Timestamp format variations + /// RFC 3339 has many valid formats + @Test + public void probeTimestampFormatVariations() { + JsonValue schema = Json.parse("{\"type\": \"timestamp\"}"); + Jtd validator = new Jtd(); + + // Valid RFC 3339 formats + String[] validTimestamps = { + "\"2023-01-01T00:00:00Z\"", + "\"2023-01-01T00:00:00.000Z\"", + "\"2023-01-01T00:00:00+00:00\"", + "\"2023-01-01T00:00:00-00:00\"", + "\"2023-01-01T00:00:00+05:30\"", + "\"2023-01-01T00:00:00-08:00\"", + "\"2023-12-31T23:59:60Z\"", // Leap second + "\"2020-02-29T12:00:00Z\"", // Leap year + }; + + for (String ts : validTimestamps) { + try { + JsonValue instance = Json.parse(ts); + Jtd.Result result = validator.validate(schema, instance); + + LOG.fine(() -> ts + " -> valid=" + result.isValid()); + + if (!result.isValid()) { + LOG.warning(() -> "Timestamp rejected: " + ts); + } + } catch (Exception e) { + LOG.warning(() -> "Failed: " + ts + " - " + e.getMessage()); + } + } + } + + /// Test: Invalid timestamp formats + @Test + public void probeInvalidTimestampFormats() { + JsonValue schema = Json.parse("{\"type\": \"timestamp\"}"); + Jtd validator = new Jtd(); + + // Invalid formats + String[] invalidTimestamps = { + "\"2023-01-01\"", // Date only + "\"12:00:00\"", // Time only + "\"2023/01/01T12:00:00Z\"", // Wrong date separator + "\"2023-01-01 12:00:00Z\"", // Space instead of T + "\"2023-1-1T12:00:00Z\"", // Single digit month/day + "\"2023-01-01T12:00Z\"", // Missing seconds + "\"2023-01-01T25:00:00Z\"", // Invalid hour + "\"2023-01-01T12:61:00Z\"", // Invalid minute + "\"2023-01-01T12:00:61Z\"", // Invalid second (not leap second) + "\"not-a-timestamp\"", + "123", + "null" + }; + + for (String ts : invalidTimestamps) { + try { + JsonValue instance = Json.parse(ts); + Jtd.Result result = validator.validate(schema, instance); + + assertFalse(result.isValid(), "Should reject invalid timestamp: " + ts); + } catch (Exception e) { + LOG.warning(() -> "Parse/validation failed for: " + ts); + } + } + } + + /// Test: Boolean type rejects all non-booleans + @Test + public void probeBooleanTypeStrictness() { + JsonValue schema = Json.parse("{\"type\": \"boolean\"}"); + Jtd validator = new Jtd(); + + // Valid booleans + assertTrue(validator.validate(schema, Json.parse("true")).isValid()); + assertTrue(validator.validate(schema, Json.parse("false")).isValid()); + + // Invalid values + String[] invalid = {"\"true\"", "\"false\"", "1", "0", "null", "[]", "{}"}; + for (String value : invalid) { + JsonValue instance = Json.parse(value); + Jtd.Result result = validator.validate(schema, instance); + assertFalse(result.isValid(), "Boolean should reject: " + value); + } + } + + /// Test: String type rejects non-strings + @Test + public void probeStringTypeStrictness() { + JsonValue schema = Json.parse("{\"type\": \"string\"}"); + Jtd validator = new Jtd(); + + // Valid strings + assertTrue(validator.validate(schema, Json.parse("\"hello\"")).isValid()); + assertTrue(validator.validate(schema, Json.parse("\"\"")).isValid()); // Empty string + assertTrue(validator.validate(schema, Json.parse("\"with spaces\"")).isValid()); + + // Invalid values + String[] invalid = {"123", "true", "null", "[]", "{}"}; + for (String value : invalid) { + JsonValue instance = Json.parse(value); + Jtd.Result result = validator.validate(schema, instance); + assertFalse(result.isValid(), "String should reject: " + value); + } + } + + /// Test: Very large integers + /// Numbers larger than 64-bit should be handled gracefully + @Test + public void probeVeryLargeIntegers() { + JsonValue schema = Json.parse("{\"type\": \"uint32\"}"); + Jtd validator = new Jtd(); + + // Very large number (bigger than uint32 max) + JsonValue huge = Json.parse("999999999999999999999999999999"); + Jtd.Result result = validator.validate(schema, huge); + + LOG.info(() -> "Very large number result: " + result.isValid()); + + // Should be invalid (out of range) + assertFalse(result.isValid(), "Should reject huge number"); + } + + /// Test: Zero values for all integer types + @Test + public void probeZeroValuesForAllIntegerTypes() { + String[] types = {"int8", "uint8", "int16", "uint16", "int32", "uint32"}; + + for (String type : types) { + JsonValue schema = Json.parse("{\"type\": \"" + type + "\"}"); + Jtd.Result result = new Jtd().validate(schema, Json.parse("0")); + + boolean expectValid = !type.startsWith("u") || true; // 0 is valid for all + + LOG.fine(() -> type + " with 0: " + result.isValid() + " (expected: " + expectValid + ")"); + + if (expectValid) { + assertTrue(result.isValid(), type + " should accept 0"); + } + } + } + + /// Test: Negative values for unsigned types + @Test + public void probeNegativeValuesForUnsignedTypes() { + String[] unsignedTypes = {"uint8", "uint16", "uint32"}; + + for (String type : unsignedTypes) { + JsonValue schema = Json.parse("{\"type\": \"" + type + "\"}"); + Jtd.Result result = new Jtd().validate(schema, Json.parse("-1")); + + LOG.fine(() -> type + " with -1: valid=" + result.isValid()); + + assertFalse(result.isValid(), type + " should reject negative values"); + } + } + + /// Test: Type coercion edge cases + /// Ensure no implicit type coercion happens + @Test + public void probeNoTypeCoercion() { + JsonValue intSchema = Json.parse("{\"type\": \"int32\"}"); + Jtd validator = new Jtd(); + + // String representation of number should NOT be accepted + JsonValue stringNumber = Json.parse("\"42\""); + Jtd.Result result = validator.validate(intSchema, stringNumber); + + assertFalse(result.isValid(), "String \"42\" should not be accepted as int32"); + + // Boolean should NOT be accepted + JsonValue bool = Json.parse("true"); + Jtd.Result boolResult = validator.validate(intSchema, bool); + + assertFalse(boolResult.isValid(), "Boolean should not be accepted as int32"); + } +} diff --git a/pom.xml b/pom.xml index 16d8439..79c9d99 100644 --- a/pom.xml +++ b/pom.xml @@ -42,6 +42,7 @@ json-compatibility-suite json-java21-jtd json-java21-jsonpath + json-java21-jtd-codegen