Skip to content

Commit 04d792b

Browse files
authored
Merge pull request #640 from dahlia/decimal
Add `xsd:decimal` support to vocab runtime and codegen
2 parents 6c1f6e6 + af8de5a commit 04d792b

12 files changed

Lines changed: 437 additions & 22 deletions

File tree

CHANGES.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ To be released.
5353

5454
### @fedify/vocab-runtime
5555

56+
- Added `Decimal`, a branded string type for exact `xsd:decimal` values,
57+
along with `isDecimal()`, `canParseDecimal()`, and `parseDecimal()` for
58+
checking and validating XML Schema decimal lexical forms without
59+
introducing a decimal arithmetic dependency. `isDecimal()` performs a
60+
strict lexical-form check, while `canParseDecimal()` and `parseDecimal()`
61+
apply XML Schema whitespace normalization first. This lays the runtime
62+
groundwork for precision-safe marketplace and measurement values such as
63+
those needed by [FEP-0837]. [[#617], [#640]]
64+
5665
- Updated the preloaded <https://gotosocial.org/ns> JSON-LD context to
5766
match the current [GoToSocial] v0.21+ namespace, adding new type terms
5867
(`LikeRequest`, `LikeAuthorization`, etc.) and property terms
@@ -66,9 +75,12 @@ To be released.
6675
APIs to distinguish transport failures from specific HTTP fetch failures.
6776
[[#611]]
6877

78+
[FEP-0837]: https://w3id.org/fep/0837
6979
[GoToSocial]: https://gotosocial.org/
7080
[#453]: https://github.com/fedify-dev/fedify/issues/453
81+
[#617]: https://github.com/fedify-dev/fedify/issues/617
7182
[#622]: https://github.com/fedify-dev/fedify/pull/622
83+
[#640]: https://github.com/fedify-dev/fedify/pull/640
7284

7385
### @fedify/cli
7486

@@ -134,6 +146,14 @@ To be released.
134146

135147
### @fedify/vocab-tools
136148

149+
- Added `xsd:decimal` support to the vocabulary code generator. Properties
150+
with that range are now generated as `Decimal` in TypeScript, serialized
151+
as `xsd:decimal` JSON-LD literals, validated through
152+
`canParseDecimal()` when checking input data, and normalized through
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]]
156+
137157
- Added `typeless` field to the type YAML schema. When set to `true`,
138158
the generated `toJsonLd()` method does not emit `@type` (or `type` in
139159
compact form) in the serialized JSON-LD. This is useful for types

docs/manual/vocab.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,7 @@ corresponding TypeScript types:
508508
| `xsd:integer` | `number` |
509509
| `xsd:nonNegativeInteger` | `number` |
510510
| `xsd:float` | `number` |
511+
| `xsd:decimal` | `Decimal` |
511512
| `xsd:string` | `string` |
512513
| `xsd:anyURI` | [`URL`] |
513514
| `xsd:dateTime` | [`Temporal.Instant`] |
@@ -521,6 +522,40 @@ corresponding TypeScript types:
521522
| Proof purpose | `"assertionMethod" \| "authentication" \| "capabilityInvocation" \| "capabilityDelegation" \| "keyAgreement"` |
522523
| Units | `"cm" \| "feet" \| "inches" \| "km" \| "m" \| "miles" \| URL` |
523524

525+
`Decimal` values come from `@fedify/vocab-runtime` as a branded string type.
526+
Use `isDecimal()` when you need to check whether a string is already in the
527+
normalized `xsd:decimal` lexical form, and use `canParseDecimal()` or
528+
`parseDecimal()` when you need XML Schema whitespace normalization before
529+
validation:
530+
531+
~~~~ typescript twoslash
532+
import type { Decimal } from "@fedify/vocab-runtime";
533+
import {
534+
canParseDecimal,
535+
isDecimal,
536+
parseDecimal,
537+
} from "@fedify/vocab-runtime";
538+
539+
const raw = " 12.50 ";
540+
541+
isDecimal(raw); // false
542+
canParseDecimal(raw); // true
543+
544+
const price: Decimal = parseDecimal(raw);
545+
price; // "12.50"
546+
~~~~
547+
548+
`Decimal` keeps the original string at runtime instead of converting it to
549+
JavaScript `number`, which avoids floating-point precision loss for exact
550+
decimal values such as prices and measurements. `parseDecimal()` normalizes
551+
XML Schema whitespace before returning the branded value, so the runtime
552+
representation always uses the normalized lexical form.
553+
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+
524559
[`Temporal.Instant`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Instant
525560
[`Temporal.Duration`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Duration
526561
[`Uint8Array`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { deepStrictEqual, throws } from "node:assert";
2+
import { test } from "node:test";
3+
import { canParseDecimal, isDecimal, parseDecimal } from "./decimal.ts";
4+
import {
5+
canParseDecimal as canParseDecimalFromModule,
6+
isDecimal as isDecimalFromModule,
7+
parseDecimal as parseDecimalFromModule,
8+
} from "./mod.ts";
9+
10+
test("parseDecimal() accepts valid xsd:decimal lexical forms", () => {
11+
const values = [
12+
"-1.23",
13+
"12678967.543233",
14+
"+100000.00",
15+
"210",
16+
".5",
17+
"5.",
18+
"0",
19+
"-0.0",
20+
];
21+
22+
for (const value of values) {
23+
deepStrictEqual(parseDecimal(value), value);
24+
}
25+
});
26+
27+
test("isDecimal() reports valid xsd:decimal lexical forms", () => {
28+
deepStrictEqual(isDecimal("12.50"), true);
29+
deepStrictEqual(isDecimal(".5"), true);
30+
deepStrictEqual(isDecimal("1e3"), false);
31+
deepStrictEqual(isDecimal(" 12.50 "), false);
32+
deepStrictEqual(isDecimal("\t12.50\n"), false);
33+
});
34+
35+
test("canParseDecimal() accepts whitespace-normalized xsd:decimal strings", () => {
36+
deepStrictEqual(canParseDecimal("12.50"), true);
37+
deepStrictEqual(canParseDecimal(" 12.50 "), true);
38+
deepStrictEqual(canParseDecimal("\t+100000.00\r\n"), true);
39+
deepStrictEqual(canParseDecimal(" .5 "), true);
40+
deepStrictEqual(canParseDecimal("1e3"), false);
41+
deepStrictEqual(canParseDecimal("1 2.50"), false);
42+
deepStrictEqual(canParseDecimal("1\t2.50"), false);
43+
});
44+
45+
test("parseDecimal() normalizes XML Schema whitespace", () => {
46+
deepStrictEqual(parseDecimal("12.50"), "12.50");
47+
deepStrictEqual(parseDecimal(" 12.50 "), "12.50");
48+
deepStrictEqual(parseDecimal("\t+100000.00\r\n"), "+100000.00");
49+
deepStrictEqual(parseDecimal(" .5 "), ".5");
50+
});
51+
52+
test("parseDecimal() rejects invalid xsd:decimal lexical forms", () => {
53+
const values = [
54+
"",
55+
".",
56+
"+",
57+
"-",
58+
"1e3",
59+
"NaN",
60+
"INF",
61+
"1,2",
62+
"1..2",
63+
"1 2.3",
64+
"1\t2.3",
65+
];
66+
67+
for (const value of values) {
68+
throws(
69+
() => parseDecimal(value),
70+
{
71+
name: "TypeError",
72+
message: `${
73+
JSON.stringify(value)
74+
} is not a valid xsd:decimal lexical form.`,
75+
},
76+
);
77+
}
78+
});
79+
80+
test("parseDecimal() is exported from the package root", () => {
81+
deepStrictEqual(parseDecimalFromModule("12.50"), "12.50");
82+
});
83+
84+
test("canParseDecimal() is exported from the package root", () => {
85+
deepStrictEqual(canParseDecimalFromModule(" 12.50 "), true);
86+
});
87+
88+
test("isDecimal() is exported from the package root", () => {
89+
deepStrictEqual(isDecimalFromModule("12.50"), true);
90+
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
const DECIMAL_PATTERN = /^(\+|-)?([0-9]+(\.[0-9]*)?|\.[0-9]+)$/;
2+
const XML_SCHEMA_WHITESPACE_PATTERN = /[\t\n\r ]+/g;
3+
4+
function collapseXmlSchemaWhitespace(value: string): string {
5+
return value.replace(XML_SCHEMA_WHITESPACE_PATTERN, " ").trim();
6+
}
7+
8+
/**
9+
* A branded string representing an `xsd:decimal` value.
10+
*
11+
* Unlike JavaScript's `number`, `xsd:decimal` is intended for exact decimal
12+
* values such as prices, quantities, and measurements where binary
13+
* floating-point rounding would be inappropriate. Fedify therefore represents
14+
* these values as validated strings at runtime while preserving a distinct
15+
* TypeScript type.
16+
*
17+
* Values of this type must be created through {@link parseDecimal}, which
18+
* validates that the string matches the XML Schema `xsd:decimal` lexical form.
19+
*
20+
* The runtime representation is still a plain string. The brand exists only
21+
* at the type level so APIs can distinguish arbitrary strings from validated
22+
* decimal literals without introducing a decimal arithmetic dependency.
23+
*
24+
* Supported lexical forms include signed and unsigned integers and decimal
25+
* fractions such as `"-1.23"`, `"+100000.00"`, `"210"`, `".5"`, and `"5."`.
26+
* Scientific notation such as `"1e3"` and special values like `"NaN"` are
27+
* rejected. Strings with surrounding XML Schema whitespace can be normalized
28+
* by {@link parseDecimal}, but values of this type are always stored in their
29+
* normalized lexical form.
30+
*
31+
* This representation is designed to be forward-compatible with a future
32+
* native decimal type if JavaScript eventually gains one, while keeping the
33+
* public API semantically precise today.
34+
*
35+
* @since 2.1.0
36+
*/
37+
export type Decimal = string & { readonly __brand: "Decimal" };
38+
39+
/**
40+
* Checks whether a string is a valid `xsd:decimal` lexical form.
41+
*
42+
* This predicate checks the lexical form strictly, without applying XML Schema
43+
* whitespace normalization first. It is useful as a type guard for values
44+
* that are already expected to be normalized decimal strings.
45+
*
46+
* @param value A candidate `xsd:decimal` lexical form.
47+
* @returns `true` if the string matches the XML Schema `xsd:decimal` lexical
48+
* form, or `false` otherwise.
49+
* @since 2.1.0
50+
*/
51+
export function isDecimal(value: string): value is Decimal {
52+
return DECIMAL_PATTERN.test(value);
53+
}
54+
55+
/**
56+
* Checks whether a string can be parsed as an `xsd:decimal` lexical form.
57+
*
58+
* Unlike {@link isDecimal}, this predicate first applies the XML Schema
59+
* `whiteSpace="collapse"` normalization step and then validates the
60+
* normalized string. This means values like `" 12.50 "` are parseable even
61+
* though they are not already normalized decimal literals.
62+
*
63+
* @param value A candidate `xsd:decimal` lexical form.
64+
* @returns `true` if the normalized string matches the XML Schema
65+
* `xsd:decimal` lexical form, or `false` otherwise.
66+
* @since 2.1.0
67+
*/
68+
export function canParseDecimal(value: string): boolean {
69+
return isDecimal(collapseXmlSchemaWhitespace(value));
70+
}
71+
72+
/**
73+
* Parses a string as an `xsd:decimal` lexical form and returns it as a
74+
* branded {@link Decimal}.
75+
*
76+
* This function validates the input against the XML Schema `xsd:decimal`
77+
* lexical space after applying the XML Schema `whiteSpace="collapse"`
78+
* normalization step. It returns the normalized string without any further
79+
* canonicalization.
80+
*
81+
* @param value A candidate `xsd:decimal` lexical form.
82+
* @returns The normalized string branded as {@link Decimal}.
83+
* @throws {TypeError} Thrown when the value is not a valid `xsd:decimal`
84+
* lexical form.
85+
* @example
86+
* ```typescript
87+
* const price = parseDecimal("12.50");
88+
* ```
89+
* @example
90+
* ```typescript
91+
* const price = parseDecimal(" 12.50 ");
92+
* console.assert(price === "12.50");
93+
* ```
94+
* @example
95+
* ```typescript
96+
* try {
97+
* parseDecimal("1e3");
98+
* } catch (error) {
99+
* console.assert(error instanceof TypeError);
100+
* }
101+
* ```
102+
* @since 2.1.0
103+
*/
104+
export function parseDecimal(value: string): Decimal {
105+
const normalized = collapseXmlSchemaWhitespace(value);
106+
if (!isDecimal(normalized)) {
107+
throw new TypeError(
108+
`${JSON.stringify(value)} is not a valid xsd:decimal lexical form.`,
109+
);
110+
}
111+
return normalized as Decimal;
112+
}

packages/vocab-runtime/src/mod.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ export {
2424
importPkcs1,
2525
importSpki,
2626
} from "./key.ts";
27+
export {
28+
canParseDecimal,
29+
type Decimal,
30+
isDecimal,
31+
parseDecimal,
32+
} from "./decimal.ts";
2733
export { LanguageString } from "./langstr.ts";
2834
export {
2935
decodeMultibase,

packages/vocab-tools/src/__snapshots__/class.test.ts.deno.snap

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
11
export const snapshot = {};
22

33
snapshot[`generateClasses() 1`] = `
4-
"// deno-lint-ignore-file ban-unused-ignore prefer-const
4+
"// deno-lint-ignore-file ban-unused-ignore no-unused-vars prefer-const verbatim-module-syntax
55
import jsonld from \\"@fedify/vocab-runtime/jsonld\\";
66
import { getLogger } from \\"@logtape/logtape\\";
77
import { type Span, SpanStatusCode, type TracerProvider, trace }
88
from \\"@opentelemetry/api\\";
99
import {
10+
canParseDecimal,
1011
decodeMultibase,
12+
type Decimal,
1113
type DocumentLoader,
1214
encodeMultibase,
1315
exportMultibaseKey,
1416
exportSpki,
1517
getDocumentLoader,
1618
importMultibaseKey,
1719
importPem,
20+
isDecimal,
1821
LanguageString,
19-
type RemoteDocument,
22+
parseDecimal,
23+
type RemoteDocument
2024
} from \\"@fedify/vocab-runtime\\";
2125

2226

packages/vocab-tools/src/__snapshots__/class.test.ts.node.snap

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
exports[`generateClasses() 1`] = `
2-
"// deno-lint-ignore-file ban-unused-ignore prefer-const
2+
"// deno-lint-ignore-file ban-unused-ignore no-unused-vars prefer-const verbatim-module-syntax
33
import jsonld from \\"@fedify/vocab-runtime/jsonld\\";
44
import { getLogger } from \\"@logtape/logtape\\";
55
import { type Span, SpanStatusCode, type TracerProvider, trace }
66
from \\"@opentelemetry/api\\";
77
import {
8+
canParseDecimal,
89
decodeMultibase,
10+
type Decimal,
911
type DocumentLoader,
1012
encodeMultibase,
1113
exportMultibaseKey,
1214
exportSpki,
1315
getDocumentLoader,
1416
importMultibaseKey,
1517
importPem,
18+
isDecimal,
1619
LanguageString,
17-
type RemoteDocument,
20+
parseDecimal,
21+
type RemoteDocument
1822
} from \\"@fedify/vocab-runtime\\";
1923

2024

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
11
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
22

33
exports[`generateClasses() 1`] = `
4-
"// deno-lint-ignore-file ban-unused-ignore prefer-const
4+
"// deno-lint-ignore-file ban-unused-ignore no-unused-vars prefer-const verbatim-module-syntax
55
import jsonld from "@fedify/vocab-runtime/jsonld";
66
import { getLogger } from "@logtape/logtape";
77
import { type Span, SpanStatusCode, type TracerProvider, trace }
88
from "@opentelemetry/api";
99
import {
10+
canParseDecimal,
1011
decodeMultibase,
12+
type Decimal,
1113
type DocumentLoader,
1214
encodeMultibase,
1315
exportMultibaseKey,
1416
exportSpki,
1517
getDocumentLoader,
1618
importMultibaseKey,
1719
importPem,
20+
isDecimal,
1821
LanguageString,
19-
type RemoteDocument,
22+
parseDecimal,
23+
type RemoteDocument
2024
} from "@fedify/vocab-runtime";
2125

2226

0 commit comments

Comments
 (0)