schemagen: drop support for additionalProperties: true

Following review feedback, the generator no longer supports permissive
objects. Changes:

- Reject `additionalProperties: true` at generation time.
- Treat an absent `additionalProperties` as `false`, so every object is
  strict by default and unknown keys are rejected during parsing.
- Remove the now-dead permissive-object infrastructure: `Kind::Skip`,
  `Cat::Skip`, `kSkip`, the per-frame `unknown` key set, and `isStrict()`.
- Update the README feature/rejection tables accordingly.
- Remove the permissive "loose" object from example.schema.json and the
  associated tests from test_gen.cpp.
- Add Python unit tests verifying the new `additionalProperties` behavior.

All tests pass (`ctest --output-on-failure`).
This commit is contained in:
2026-06-23 15:11:48 -04:00
parent ab95fefb09
commit 16f13c241c
5 changed files with 64 additions and 86 deletions
+5 -5
View File
@@ -45,7 +45,7 @@ into the result, so it is non-movable.
| `$ref` to `$defs`/`definitions` | the referenced named struct | | `$ref` to `$defs`/`definitions` | the referenced named struct |
| recursive `$ref` | `std::unique_ptr<T>` (cycle broken) | | recursive `$ref` | `std::unique_ptr<T>` (cycle broken) |
| `additionalProperties: false` | unknown keys rejected | | `additionalProperties: false` | unknown keys rejected |
| `additionalProperties` absent / `true` | unknown keys' values skipped | | `additionalProperties` absent / `true` | not supported (rejected at generation) |
## Schema violations (rejected at parse time) ## Schema violations (rejected at parse time)
@@ -55,15 +55,15 @@ into the result, so it is non-movable.
- value not in a string `enum` - value not in a string `enum`
- a number not representable in the target type (e.g. `1.5` for an `integer`) - a number not representable in the target type (e.g. `1.5` for an `integer`)
- duplicate object keys - duplicate object keys
- unknown key under `additionalProperties: false` - unknown key in any object
## Not supported (rejected at generation time, no fallback) ## Not supported (rejected at generation time, no fallback)
`oneOf` / `anyOf` / `allOf` / `not` / `if`-`then`-`else`, `oneOf` / `anyOf` / `allOf` / `not` / `if`-`then`-`else`,
`patternProperties`, `additionalProperties` with a schema (typed map), `patternProperties`, `additionalProperties` with a schema (typed map),
`prefixItems` (tuples), `const`, `dependentSchemas`/`dependentRequired`, `additionalProperties: true`, `prefixItems` (tuples), `const`,
union `type` lists other than `["T", "null"]`, non-string enums, and remote `dependentSchemas`/`dependentRequired`, union `type` lists other than
(`$ref` to other documents). `["T", "null"]`, non-string enums, and remote (`$ref` to other documents).
## Notes ## Notes
-8
View File
@@ -52,14 +52,6 @@
} }
} }
}, },
"loose": {
"type": "object",
"properties": {
"known": {
"type": "string"
}
}
},
"friends": { "friends": {
"type": "array", "type": "array",
"items": { "items": {
-14
View File
@@ -186,20 +186,6 @@ int main() {
expectReject(R"([1,2,3])", "array where object expected (root)"); expectReject(R"([1,2,3])", "array where object expected (root)");
expectReject(R"({"name":"x","age":1,)", "truncated / invalid json"); expectReject(R"({"name":"x","age":1,)", "truncated / invalid json");
// ---- duplicate keys in permissive (additionalProperties allowed) object
// ----
{
RootBuilder b;
WeaselJsonStatus s =
parseStrided(b, R"({"name":"x","age":1,"loose":{"unknown":1}})");
CHECK(s == WeaselJson_OK);
printf("ok single unknown key in non-strict object\n");
}
expectReject(R"({"name":"x","age":1,"loose":{"unknown":1,"unknown":2}})",
"duplicate unknown key in non-strict object");
expectReject(R"({"name":"x","age":1,"loose":{"known":"a","known":"b"}})",
"duplicate known key in non-strict object");
if (failures == 0) { if (failures == 0) {
printf("\nALL TESTS PASSED\n"); printf("\nALL TESTS PASSED\n");
return 0; return 0;
+51 -3
View File
@@ -178,21 +178,69 @@ class SchemagenCollisionTest(unittest.TestCase):
self.assertIn("Arr1", enumerators) self.assertIn("Arr1", enumerators)
self.assertEqual(len(enumerators), len(set(enumerators))) self.assertEqual(len(enumerators), len(set(enumerators)))
def test_skip_is_reserved(self): def test_skip_user_type_is_allowed(self):
schema = { schema = {
"type": "array", "type": "array",
"items": {"$ref": "#/$defs/Skip"}, "items": {"$ref": "#/$defs/Skip"},
"$defs": {"Skip": {"type": "object", "properties": {}}}, "$defs": {"Skip": {"type": "object", "properties": {}}},
} }
out = self.generate_and_compile(schema) out = self.generate_and_compile(schema)
self.assertIn("struct Skip1", out) self.assertIn("struct Skip", out)
self.assertNotIn("struct Skip1", out)
m = re.search(r"enum class Kind : uint8_t \{([^}]+)\}", out) m = re.search(r"enum class Kind : uint8_t \{([^}]+)\}", out)
self.assertIsNotNone(m) self.assertIsNotNone(m)
enumerators = [e.strip() for e in m.group(1).split(",")] enumerators = [e.strip() for e in m.group(1).split(",")]
self.assertIn("Skip", enumerators) self.assertIn("Skip", enumerators)
self.assertIn("Skip1", enumerators) self.assertIn("Arr0", enumerators)
self.assertEqual(len(enumerators), len(set(enumerators))) self.assertEqual(len(enumerators), len(set(enumerators)))
class SchemagenAdditionalPropertiesTest(unittest.TestCase):
def run_schemagen(self, schema, args=None):
"""Run schemagen on a schema dict. Returns (returncode, stdout, stderr)."""
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fp:
json.dump(schema, fp)
schema_path = fp.name
try:
cmd = [sys.executable, SCRIPT, schema_path]
if args:
cmd.extend(args)
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
return result.returncode, result.stdout, result.stderr
finally:
os.unlink(schema_path)
def test_additional_properties_true_rejected(self):
schema = {
"type": "object",
"additionalProperties": True,
"properties": {"name": {"type": "string"}},
}
rc, stdout, stderr = self.run_schemagen(schema)
self.assertNotEqual(rc, 0)
self.assertIn("additionalProperties: true is not supported", stderr)
def test_additional_properties_absent_defaults_to_strict(self):
schema = {
"type": "object",
"properties": {"name": {"type": "string"}},
}
rc, stdout, stderr = self.run_schemagen(schema)
self.assertEqual(rc, 0, msg=stderr)
# The generated parser should reject unknown keys. Verify the key-matching
# helper returns -1 for an unknown key and cbKeyData rejects it.
self.assertIn("int matchKey(Kind k, std::string_view key) const {", stdout)
self.assertNotIn("bool isStrict(Kind k) const", stdout)
def test_additional_properties_false_accepted(self):
schema = {
"type": "object",
"additionalProperties": False,
"properties": {"name": {"type": "string"}},
}
rc, stdout, stderr = self.run_schemagen(schema)
self.assertEqual(rc, 0, msg=stderr)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+8 -56
View File
@@ -60,7 +60,6 @@ class ObjectType:
def __init__(self, name): def __init__(self, name):
self.name = name self.name = name
self.fields = [] # list[Field] self.fields = [] # list[Field]
self.strict = False # additionalProperties: false
class EnumType: class EnumType:
@@ -234,7 +233,7 @@ class Builder:
@staticmethod @staticmethod
def _is_reserved(name): def _is_reserved(name):
"""Names generated internally that must not collide with user types.""" """Names generated internally that must not collide with user types."""
return name in ("Root", "Skip", "RootScalar") return name in ("Root", "RootScalar")
def ref_name(self, ref): def ref_name(self, ref):
if not ref.startswith("#/"): if not ref.startswith("#/"):
@@ -339,12 +338,13 @@ class Builder:
# register for $ref cycles before building fields # register for $ref cycles before building fields
if defname is not None: if defname is not None:
self._building[defname] = TObj(name) self._building[defname] = TObj(name)
ap = node.get("additionalProperties", True) ap = node.get("additionalProperties", False)
if ap is True:
raise GenError("additionalProperties: true is not supported")
if isinstance(ap, dict): if isinstance(ap, dict):
raise GenError( raise GenError(
"additionalProperties with a schema (typed map) is not " "supported yet" "additionalProperties with a schema (typed map) is not " "supported yet"
) )
obj.strict = ap is False
required = set(node.get("required", [])) required = set(node.get("required", []))
props = node.get("properties", {}) props = node.get("properties", {})
seen_cpp = set() seen_cpp = set()
@@ -566,7 +566,6 @@ class Emitter:
#include <optional> #include <optional>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <unordered_set>
#include <vector> #include <vector>
#include "weaseljson.h" #include "weaseljson.h"
@@ -621,7 +620,6 @@ namespace {ns} {{"""
root_is_container = isinstance(self.root_ty, (TObj, TArr)) root_is_container = isinstance(self.root_ty, (TObj, TArr))
if not root_is_container: if not root_is_container:
kinds.append("RootScalar") kinds.append("RootScalar")
kinds.append("Skip")
self.kind_order = kinds self.kind_order = kinds
return " enum class Kind : uint8_t { " + ", ".join(kinds) + " };" return " enum class Kind : uint8_t { " + ", ".join(kinds) + " };"
@@ -810,17 +808,6 @@ namespace {ns} {{"""
lines.append(" }") lines.append(" }")
return "\n".join(lines) return "\n".join(lines)
def _is_strict(self):
strict = [n for n, o in self.b.objects.items() if o.strict]
if not strict:
return " bool isStrict(Kind) const { return false; }"
cases = " ".join(f"case Kind::{n}:" for n in strict)
return (
" bool isStrict(Kind k) const {\n"
f" switch (k) {{ {cases} return true; default: return false; }}\n"
" }"
)
def _enum_name_arrays(self): def _enum_name_arrays(self):
out = [] out = []
for e in self.b.enums.values(): for e in self.b.enums.values():
@@ -862,13 +849,7 @@ namespace {ns} {{"""
else: else:
lines.append(" if (stack_.empty()) { reject(); return; }") lines.append(" if (stack_.empty()) { reject(); return; }")
lines.append(" Frame &f = stack_.back();") lines.append(" Frame &f = stack_.back();")
lines.append(
" if (f.kind == Kind::Skip) { stack_.push_back(Frame{Kind::Skip, nullptr}); return; }"
)
lines.append(" SlotInfo si = slotInfoG(f);") lines.append(" SlotInfo si = slotInfoG(f);")
lines.append(
" if (si.cat == Cat::Skip) { stack_.push_back(Frame{Kind::Skip, nullptr}); return; }"
)
lines.append(f" if (si.cat != Cat::{event_cat}) {{ reject(); return; }}") lines.append(f" if (si.cat != Cat::{event_cat}) {{ reject(); return; }}")
lines.append(" void *p = engage(f, true);") lines.append(" void *p = engage(f, true);")
lines.append(" stack_.push_back(Frame{si.child, p});") lines.append(" stack_.push_back(Frame{si.child, p});")
@@ -919,7 +900,7 @@ public:
private: private:
{kind_enum} {kind_enum}
enum class Cat {{ Reject, Str, Int, Dbl, Bool, Enum, Obj, Arr, Skip }}; enum class Cat {{ Reject, Str, Int, Dbl, Bool, Enum, Obj, Arr }};
struct SlotInfo {{ struct SlotInfo {{
Cat cat; Cat cat;
Kind child{{}}; Kind child{{}};
@@ -930,12 +911,10 @@ private:
struct Frame {{ struct Frame {{
Kind kind; Kind kind;
void *dest; void *dest;
int field = -1; // object: selected field (-1 want key, -2 skip) int field = -1; // object: selected field (-1 means waiting for key)
std::vector<uint64_t> seen; // populated field bitset (object frames) std::vector<uint64_t> seen; // populated field bitset (object frames)
std::unordered_set<std::string> unknown; // unknown keys seen in non-strict objects
}}; }};
static constexpr int kWantKey = -1; static constexpr int kWantKey = -1;
static constexpr int kSkip = -2;
{self._enum_name_arrays()} {self._enum_name_arrays()}
@@ -950,10 +929,9 @@ private:
void reject() {{ error_ = true; }} void reject() {{ error_ = true; }}
// Wrap the generated slotInfo() with the generic key/skip states. // Wrap the generated slotInfo() with the generic key state.
SlotInfo slotInfoG(const Frame &f) {{ SlotInfo slotInfoG(const Frame &f) {{
if (isObjectKind(f.kind)) {{ if (isObjectKind(f.kind)) {{
if (f.field == kSkip) return SlotInfo{{Cat::Skip}};
if (f.field < 0) return SlotInfo{{Cat::Reject}}; if (f.field < 0) return SlotInfo{{Cat::Reject}};
}} }}
return slotInfo(f); return slotInfo(f);
@@ -978,11 +956,6 @@ private:
void cbEndObject() {{ void cbEndObject() {{
if (error_) return; if (error_) return;
Frame &f = stack_.back(); Frame &f = stack_.back();
if (f.kind == Kind::Skip) {{
stack_.pop_back();
if (stack_.empty() || stack_.back().kind != Kind::Skip) valueComplete();
return;
}}
if (isObjectKind(f.kind)) {{ if (isObjectKind(f.kind)) {{
const auto &req = requiredMask(f.kind); const auto &req = requiredMask(f.kind);
bool missing = false; bool missing = false;
@@ -1000,11 +973,6 @@ private:
void cbEndArray() {{ void cbEndArray() {{
if (error_) return; if (error_) return;
Frame f = stack_.back(); Frame f = stack_.back();
if (f.kind == Kind::Skip) {{
stack_.pop_back();
if (stack_.empty() || stack_.back().kind != Kind::Skip) valueComplete();
return;
}}
stack_.pop_back(); stack_.pop_back();
valueComplete(); valueComplete();
}} }}
@@ -1017,13 +985,7 @@ private:
if (!done) return; if (!done) return;
int idx = matchKey(f.kind, scratch_); int idx = matchKey(f.kind, scratch_);
if (idx < 0) {{ if (idx < 0) {{
if (isStrict(f.kind)) {{ reject(); return; }} reject(); return; // unknown key
if (!f.unknown.insert(scratch_).second) {{
reject(); return; // duplicate unknown key
}}
scratch_.clear();
f.field = kSkip;
return;
}} }}
scratch_.clear(); scratch_.clear();
if (f.seen[idx >> 6] & (1ull << (idx & 63))) {{ if (f.seen[idx >> 6] & (1ull << (idx & 63))) {{
@@ -1035,9 +997,7 @@ private:
void cbStringData(const char *buf, int len, int done) {{ void cbStringData(const char *buf, int len, int done) {{
if (error_) return; if (error_) return;
Frame &f = stack_.back(); Frame &f = stack_.back();
if (f.kind == Kind::Skip) return;
SlotInfo si = slotInfoG(f); SlotInfo si = slotInfoG(f);
if (si.cat == Cat::Skip) {{ if (done) valueComplete(); return; }}
if (si.cat == Cat::Str) {{ if (si.cat == Cat::Str) {{
auto *s = (std::string *)engage(f, !started_); auto *s = (std::string *)engage(f, !started_);
s->append(buf, len); s->append(buf, len);
@@ -1063,9 +1023,7 @@ private:
void cbNumberData(const char *buf, int len, int done) {{ void cbNumberData(const char *buf, int len, int done) {{
if (error_) return; if (error_) return;
Frame &f = stack_.back(); Frame &f = stack_.back();
if (f.kind == Kind::Skip) return;
SlotInfo si = slotInfoG(f); SlotInfo si = slotInfoG(f);
if (si.cat == Cat::Skip) {{ if (done) valueComplete(); return; }}
if (si.cat != Cat::Int && si.cat != Cat::Dbl) {{ reject(); return; }} if (si.cat != Cat::Int && si.cat != Cat::Dbl) {{ reject(); return; }}
scratch_.append(buf, len); scratch_.append(buf, len);
started_ = true; started_ = true;
@@ -1103,9 +1061,7 @@ private:
void cbBool(bool value) {{ void cbBool(bool value) {{
if (error_) return; if (error_) return;
Frame &f = stack_.back(); Frame &f = stack_.back();
if (f.kind == Kind::Skip) return;
SlotInfo si = slotInfoG(f); SlotInfo si = slotInfoG(f);
if (si.cat == Cat::Skip) {{ valueComplete(); return; }}
if (si.cat != Cat::Bool) {{ reject(); return; }} if (si.cat != Cat::Bool) {{ reject(); return; }}
*(bool *)engage(f, true) = value; *(bool *)engage(f, true) = value;
valueComplete(); valueComplete();
@@ -1121,9 +1077,7 @@ private:
{null_at_root} {null_at_root}
}} }}
Frame &f = stack_.back(); Frame &f = stack_.back();
if (f.kind == Kind::Skip) return;
SlotInfo si = slotInfoG(f); SlotInfo si = slotInfoG(f);
if (si.cat == Cat::Skip) {{ valueComplete(); return; }}
if (!si.nullable) {{ reject(); return; }} if (!si.nullable) {{ reject(); return; }}
if (isArrayKind(f.kind)) appendNull(f); // keep null array elements if (isArrayKind(f.kind)) appendNull(f); // keep null array elements
valueComplete(); // leave optional empty / pointer null valueComplete(); // leave optional empty / pointer null
@@ -1161,8 +1115,6 @@ private:
{self._reqmask()} {self._reqmask()}
{self._is_object_kind()} {self._is_object_kind()}
{self._is_strict()}
}}; }};
""" """