Skip to content

Commit af8de5a

Browse files
dahliacodex
andcommitted
Reject ambiguous decimal/string unions
Reject property ranges that mix xsd:string and xsd:decimal during code generation, since both map to runtime strings and the generated encoder cannot disambiguate them reliably. Update the vocab-tools tests to cover the rejection path directly, and document the restriction in the manual and changelog. #640 (comment) Co-Authored-By: OpenAI Codex <codex@openai.com>
1 parent 05d793d commit af8de5a

5 files changed

Lines changed: 81 additions & 34 deletions

File tree

CHANGES.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,9 @@ To be released.
150150
with that range are now generated as `Decimal` in TypeScript, serialized
151151
as `xsd:decimal` JSON-LD literals, validated through
152152
`canParseDecimal()` when checking input data, and normalized through
153-
`parseDecimal()` when decoded. [[#617], [#640]]
153+
`parseDecimal()` when decoded. Code generation now also rejects property
154+
ranges that mix `xsd:string` and `xsd:decimal`, since both map to runtime
155+
strings and would make serialization ambiguous. [[#617], [#640]]
154156

155157
- Added `typeless` field to the type YAML schema. When set to `true`,
156158
the generated `toJsonLd()` method does not emit `@type` (or `type` in

docs/manual/vocab.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,11 @@ decimal values such as prices and measurements. `parseDecimal()` normalizes
551551
XML Schema whitespace before returning the branded value, so the runtime
552552
representation always uses the normalized lexical form.
553553

554+
A property range must not combine `xsd:string` and `xsd:decimal`. Both map to
555+
runtime strings, so the generated encoder cannot distinguish them reliably
556+
during JSON-LD serialization and Fedify rejects such schema definitions at code
557+
generation time.
558+
554559
[`Temporal.Instant`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Instant
555560
[`Temporal.Duration`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Duration
556561
[`Uint8Array`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array

packages/vocab-tools/src/class.test.ts

Lines changed: 39 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { deepStrictEqual, match } from "node:assert";
1+
import { deepStrictEqual, match, rejects } from "node:assert";
22
import { basename, dirname, extname, join } from "node:path";
33
import { test } from "node:test";
44
import metadata from "../deno.json" with { type: "json" };
55
import { generateClasses, sortTopologically } from "./class.ts";
6+
import { getDataCheck } from "./type.ts";
67
import { loadSchemaFiles, type TypeSchema } from "./schema.ts";
78

89
test("sortTopologically()", () => {
@@ -80,9 +81,43 @@ test("generateClasses() imports Decimal helpers for xsd:decimal", async () => {
8081
match(entireCode, /parseDecimal\(v\["@value"\]\)/);
8182
});
8283

83-
test("generateClasses() uses canParseDecimal() in xsd:decimal data checks", async () => {
84-
const entireCode = await getDecimalUnionFixtureCode();
85-
match(entireCode, /canParseDecimal\(v\["@value"\]\)/);
84+
test("getDataCheck() uses canParseDecimal() for xsd:decimal", () => {
85+
const check = getDataCheck(
86+
"http://www.w3.org/2001/XMLSchema#decimal",
87+
{},
88+
"v",
89+
);
90+
match(check, /canParseDecimal\(v\["@value"\]\)/);
91+
});
92+
93+
test("generateClasses() rejects xsd:string and xsd:decimal unions", async () => {
94+
await rejects(
95+
Array.fromAsync(generateClasses({
96+
"https://example.com/measure": {
97+
name: "Measure",
98+
uri: "https://example.com/measure",
99+
compactName: "Measure",
100+
entity: false,
101+
description: "A measure.",
102+
properties: [
103+
{
104+
singularName: "amount",
105+
functional: true,
106+
compactName: "amount",
107+
uri: "https://example.com/amount",
108+
description: "An exact decimal amount.",
109+
range: [
110+
"http://www.w3.org/2001/XMLSchema#decimal",
111+
"http://www.w3.org/2001/XMLSchema#string",
112+
],
113+
},
114+
],
115+
defaultContext:
116+
"https://example.com/context" as TypeSchema["defaultContext"],
117+
},
118+
})),
119+
/cannot have both xsd:string and xsd:decimal in its range/,
120+
);
86121
});
87122

88123
if ("Deno" in globalThis) {
@@ -142,34 +177,6 @@ async function getDecimalFixtureCode() {
142177
return (await Array.fromAsync(generateClasses(types))).join("");
143178
}
144179

145-
async function getDecimalUnionFixtureCode() {
146-
const types: Record<string, TypeSchema> = {
147-
"https://example.com/measure": {
148-
name: "Measure",
149-
uri: "https://example.com/measure",
150-
compactName: "Measure",
151-
entity: false,
152-
description: "A measure.",
153-
properties: [
154-
{
155-
singularName: "amount",
156-
functional: true,
157-
compactName: "amount",
158-
uri: "https://example.com/amount",
159-
description: "An exact decimal amount.",
160-
range: [
161-
"http://www.w3.org/2001/XMLSchema#decimal",
162-
"http://www.w3.org/2001/XMLSchema#string",
163-
],
164-
},
165-
],
166-
defaultContext:
167-
"https://example.com/context" as TypeSchema["defaultContext"],
168-
},
169-
};
170-
return (await Array.fromAsync(generateClasses(types))).join("");
171-
}
172-
173180
async function changeNodeSnapshotPath() {
174181
const { snapshot } = await import("node:test");
175182
snapshot.setResolveSnapshotPath(

packages/vocab-tools/src/class.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { generateCloner, generateConstructor } from "./constructor.ts";
33
import { generateFields } from "./field.ts";
44
import { generateInspector, generateInspectorPostClass } from "./inspector.ts";
55
import { generateProperties } from "./property.ts";
6-
import type { TypeSchema } from "./schema.ts";
6+
import { type TypeSchema, validateTypeSchemas } from "./schema.ts";
77
import { emitOverride } from "./type.ts";
88

99
/**
@@ -117,6 +117,7 @@ async function* generateClass(
117117
export async function* generateClasses(
118118
types: Record<string, TypeSchema>,
119119
): AsyncIterable<string> {
120+
validateTypeSchemas(types);
120121
const runtimeImports = [
121122
"canParseDecimal",
122123
"decodeMultibase",

packages/vocab-tools/src/schema.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,38 @@ export function hasSingularAccessor(property: PropertySchema): boolean {
240240
property.singularAccessor === true;
241241
}
242242

243+
const XSD_STRING_URI = "http://www.w3.org/2001/XMLSchema#string";
244+
const XSD_DECIMAL_URI = "http://www.w3.org/2001/XMLSchema#decimal";
245+
246+
/**
247+
* Validates schema combinations that cannot be represented safely by the
248+
* generated code.
249+
*
250+
* In particular, `xsd:string` and `xsd:decimal` cannot coexist in the same
251+
* property range because both are represented as runtime strings, which makes
252+
* JSON-LD serialization ambiguous and order-dependent.
253+
*
254+
* @param types The loaded type schemas to validate.
255+
* @throws {TypeError} Thrown when an unsupported range combination is found.
256+
*/
257+
export function validateTypeSchemas(
258+
types: Record<string, TypeSchema>,
259+
): void {
260+
for (const type of Object.values(types)) {
261+
for (const property of type.properties) {
262+
const hasString = property.range.includes(XSD_STRING_URI);
263+
const hasDecimal = property.range.includes(XSD_DECIMAL_URI);
264+
if (hasString && hasDecimal) {
265+
throw new TypeError(
266+
`The property ${type.name}.${property.singularName} cannot have ` +
267+
`both xsd:string and xsd:decimal in its range because the ` +
268+
`generated encoder cannot disambiguate them at runtime.`,
269+
);
270+
}
271+
}
272+
}
273+
}
274+
243275
/**
244276
* An error that occurred while loading a schema file.
245277
*/

0 commit comments

Comments
 (0)