Skip to content

Commit f944f5b

Browse files
authored
feat(scalar): add ULID scalar (#2856)
Closes #2851. Credit to @ardatan for pointing out the implementation detail.
1 parent 8d2497e commit f944f5b

File tree

8 files changed

+244
-0
lines changed

8 files changed

+244
-0
lines changed

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import {
6363
GraphQLTime,
6464
GraphQLTimestamp,
6565
GraphQLTimeZone,
66+
GraphQLULID,
6667
GraphQLUnsignedFloat,
6768
GraphQLUnsignedInt,
6869
GraphQLURL,
@@ -275,6 +276,7 @@ export const resolvers: Record<string, GraphQLScalarType> = {
275276
Time: GraphQLTime,
276277
Timestamp: GraphQLTimestamp,
277278
TimeZone: GraphQLTimeZone,
279+
ULID: GraphQLULID,
278280
UnsignedFloat: GraphQLUnsignedFloat,
279281
UnsignedInt: GraphQLUnsignedInt,
280282
URL: GraphQLURL,
@@ -434,9 +436,13 @@ export {
434436

435437
export { GeoJSON as GeoJSONTypeDefinition } from './typeDefs.js';
436438
export { CountryName as CountryNameTypeDefinition } from './typeDefs.js';
439+
export { ULID as ULIDTypeDefinition } from './typeDefs.js';
437440
export { GraphQLCountryName as CountryNameResolver };
438441
export { GraphQLCountryName };
439442
export { CountryName as CountryNameMock } from './mocks.js';
440443
export { GraphQLGeoJSON as GeoJSONResolver };
444+
export { GraphQLULID as ULIDResolver };
441445
export { GraphQLGeoJSON };
446+
export { GraphQLULID };
442447
export { GeoJSON as GeoJSONMock } from './mocks.js';
448+
export { ULID as ULIDMock } from './mocks.js';

src/mocks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export const ULID = () => '01J9GHQM8N5X4TQ3M1ZV9J6CBK';
12
export const GeoJSON = () => 'Example GeoJSON';
23
export const CountryName = () => 'Example CountryName';
34
const BigIntMock = () => BigInt(Number.MAX_SAFE_INTEGER);

src/scalars/ULID.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { GraphQLScalarType, Kind, ValueNode } from 'graphql';
2+
import { createGraphQLError } from '../error.js';
3+
4+
const ULID_REGEX = /^[0-7][0-9ABCDEFGHJKMNPQRSTVWXYZ]{25}$/i;
5+
6+
/**
7+
* Check if a ULID is valid according to the official spec
8+
* @param id The ULID to test
9+
* @returns True if valid, false otherwise
10+
*/
11+
const isValid = (id: string): boolean => {
12+
return typeof id === 'string' && ULID_REGEX.test(id);
13+
};
14+
15+
const validate = (value: unknown, ast?: ValueNode): string => {
16+
if (typeof value !== 'string') {
17+
throw createGraphQLError(
18+
'ULID can only parse String',
19+
ast
20+
? {
21+
nodes: ast,
22+
}
23+
: undefined,
24+
);
25+
}
26+
27+
if (!isValid(value)) {
28+
throw createGraphQLError(
29+
'Invalid ULID format',
30+
ast
31+
? {
32+
nodes: ast,
33+
}
34+
: undefined,
35+
);
36+
}
37+
38+
// Return the normalized uppercase version
39+
return value.toUpperCase();
40+
};
41+
42+
export const GraphQLULID = /*#__PURE__*/ new GraphQLScalarType({
43+
name: 'ULID',
44+
description:
45+
'A ULID (Universally Unique Lexicographically Sortable Identifier) is a 26-character ' +
46+
'string that is URL-safe, case-insensitive, and lexicographically sortable.',
47+
serialize(value: unknown) {
48+
return validate(value);
49+
},
50+
parseValue(value: unknown) {
51+
return validate(value);
52+
},
53+
parseLiteral(ast) {
54+
if (ast.kind === Kind.STRING) {
55+
return validate(ast.value, ast);
56+
}
57+
throw createGraphQLError(`ULID can only parse String but got '${ast.kind}'`, {
58+
nodes: [ast],
59+
});
60+
},
61+
extensions: {
62+
codegenScalarType: 'string',
63+
jsonSchema: {
64+
title: 'ULID',
65+
type: 'string',
66+
pattern: ULID_REGEX.source,
67+
minLength: 26,
68+
maxLength: 26,
69+
},
70+
},
71+
});

src/scalars/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,4 @@ export { GraphQLSESSN } from './ssn/SE.js';
6767
export { GraphQLDeweyDecimal } from './library/DeweyDecimal.js';
6868
export { GraphQLLCCSubclass } from './library/LCCSubclass.js';
6969
export { GraphQLIPCPatent } from './patent/IPCPatent.js';
70+
export { GraphQLULID } from './ULID.js';

src/typeDefs.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export const ULID = 'scalar ULID';
12
export const GeoJSON = 'scalar GeoJSON';
23
export const CountryName = 'scalar CountryName';
34
export const BigInt = 'scalar BigInt';
@@ -135,6 +136,7 @@ export const typeDefs = [
135136
Time,
136137
Timestamp,
137138
TimeZone,
139+
ULID,
138140
UnsignedFloat,
139141
UnsignedInt,
140142
URL,

tests/ULID.test.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { Kind } from 'graphql';
2+
import { GraphQLULID } from '../src/scalars/ULID.js';
3+
4+
const invalids = [
5+
['wrong length - too short', '01HNZX8JGFACFA36RBXDHEQN6'],
6+
['wrong length - too long', '01HNZX8JGFACFA36RBXDHEQN6EE'],
7+
['invalid character - I', '01HNZX8JGFACFA36RBXDHEQN6I'],
8+
['invalid character - L', '01HNZX8JGFACFA36RBXDHEQN6L'],
9+
['invalid character - O', '01HNZX8JGFACFA36RBXDHEQN6O'],
10+
['invalid character - U', '01HNZX8JGFACFA36RBXDHEQN6U'],
11+
['invalid character - special', '01HNZX8JGFACFA36RBXDHEQN6$'],
12+
['invalid first character - 8', '81HNZX8JGFACFA36RBXDHEQN6E'],
13+
['invalid first character - 9', '91HNZX8JGFACFA36RBXDHEQN6E'],
14+
['empty string', ''],
15+
['null-like string', 'null'],
16+
['undefined-like string', 'undefined'],
17+
];
18+
19+
const valids: string[] = [
20+
'01HNZX8JGFACFA36RBXDHEQN6E',
21+
'01HNZX8JGFACFA36RBXDHEQN6F',
22+
'01HNZX8JGFACFA36RBXDHEQN6G',
23+
'01HNZX8JGFACFA36RBXDHEQN6H',
24+
'01HNZX8JGFACFA36RBXDHEQN6J',
25+
'01HNZX8JGFACFA36RBXDHEQN6K',
26+
'7ZZZZZZZZZZZZZZZZZZZZZZZZZ', // Maximum valid ULID
27+
'00000000000000000000000000', // Minimum valid ULID
28+
'01234567890123456789012345',
29+
'0ABCDEFGHJKMNPQRSTVWXYZ000',
30+
'01J9GHQM8N5X4TQ3M1ZV9J6CBK',
31+
];
32+
33+
describe('ULID', () => {
34+
describe('invalid', () => {
35+
test("type isn't String", () => {
36+
const value = 102;
37+
expect(() =>
38+
GraphQLULID.parseLiteral({
39+
kind: Kind.INT,
40+
value: '' + value,
41+
}),
42+
).toThrow(/ULID can only parse String/);
43+
expect(() => GraphQLULID.serialize(value)).toThrow(/ULID can only parse String/);
44+
expect(() => GraphQLULID.parseValue(value)).toThrow(/ULID can only parse String/);
45+
});
46+
47+
test("type isn't String - null", () => {
48+
const value = null;
49+
expect(() => GraphQLULID.serialize(value)).toThrow(/ULID can only parse String/);
50+
expect(() => GraphQLULID.parseValue(value)).toThrow(/ULID can only parse String/);
51+
});
52+
53+
test("type isn't String - undefined", () => {
54+
const value = undefined;
55+
expect(() => GraphQLULID.serialize(value)).toThrow(/ULID can only parse String/);
56+
expect(() => GraphQLULID.parseValue(value)).toThrow(/ULID can only parse String/);
57+
});
58+
59+
test("type isn't String - object", () => {
60+
const value = {};
61+
expect(() => GraphQLULID.serialize(value)).toThrow(/ULID can only parse String/);
62+
expect(() => GraphQLULID.parseValue(value)).toThrow(/ULID can only parse String/);
63+
});
64+
65+
test("type isn't String - array", () => {
66+
const value = [];
67+
expect(() => GraphQLULID.serialize(value)).toThrow(/ULID can only parse String/);
68+
expect(() => GraphQLULID.parseValue(value)).toThrow(/ULID can only parse String/);
69+
});
70+
71+
test.each(invalids)(`%s`, (_, ulid) => {
72+
expect(() =>
73+
GraphQLULID.parseLiteral({
74+
kind: Kind.STRING,
75+
value: ulid,
76+
}),
77+
).toThrow(/Invalid ULID format/);
78+
expect(() => GraphQLULID.parseValue(ulid)).toThrow(/Invalid ULID format/);
79+
expect(() => GraphQLULID.serialize(ulid)).toThrow(/Invalid ULID format/);
80+
});
81+
82+
test('parseLiteral with non-string kind', () => {
83+
expect(() =>
84+
GraphQLULID.parseLiteral({
85+
kind: Kind.INT,
86+
value: '123',
87+
}),
88+
).toThrow(/ULID can only parse String but got 'IntValue'/);
89+
});
90+
91+
test('parseLiteral with object kind', () => {
92+
expect(() =>
93+
GraphQLULID.parseLiteral({
94+
kind: Kind.OBJECT,
95+
fields: [],
96+
}),
97+
).toThrow(/ULID can only parse String but got 'ObjectValue'/);
98+
});
99+
});
100+
101+
describe('valid', () => {
102+
test.each(valids)('scalar: %s', ulid => {
103+
expect(GraphQLULID.parseValue(ulid)).toBe(ulid);
104+
expect(GraphQLULID.serialize(ulid)).toBe(ulid);
105+
expect(
106+
GraphQLULID.parseLiteral({
107+
kind: Kind.STRING,
108+
value: ulid,
109+
}),
110+
).toBe(ulid);
111+
});
112+
});
113+
114+
describe('edge cases', () => {
115+
test('case sensitivity - should accept uppercase', () => {
116+
const upperUlid = '01HNZX8JGFACFA36RBXDHEQN6E';
117+
expect(GraphQLULID.parseValue(upperUlid)).toBe(upperUlid);
118+
expect(GraphQLULID.serialize(upperUlid)).toBe(upperUlid);
119+
});
120+
121+
test('case sensitivity - should normalize lowercase to uppercase', () => {
122+
const lowerUlid = '01hnzx8jgfacfa36rbxdheqn6e';
123+
const expectedUpper = '01HNZX8JGFACFA36RBXDHEQN6E';
124+
expect(GraphQLULID.parseValue(lowerUlid)).toBe(expectedUpper);
125+
expect(GraphQLULID.serialize(lowerUlid)).toBe(expectedUpper);
126+
expect(
127+
GraphQLULID.parseLiteral({
128+
kind: Kind.STRING,
129+
value: lowerUlid,
130+
}),
131+
).toBe(expectedUpper);
132+
});
133+
134+
test('case sensitivity - should normalize mixed case to uppercase', () => {
135+
const mixedUlid = '01HnZx8JgFaCfA36RbXdHeQn6E';
136+
const expectedUpper = '01HNZX8JGFACFA36RBXDHEQN6E';
137+
expect(GraphQLULID.parseValue(mixedUlid)).toBe(expectedUpper);
138+
expect(GraphQLULID.serialize(mixedUlid)).toBe(expectedUpper);
139+
});
140+
141+
test('boundary values', () => {
142+
const minUlid = '00000000000000000000000000';
143+
const maxUlid = '7ZZZZZZZZZZZZZZZZZZZZZZZZZ';
144+
145+
expect(GraphQLULID.parseValue(minUlid)).toBe(minUlid);
146+
expect(GraphQLULID.parseValue(maxUlid)).toBe(maxUlid);
147+
});
148+
149+
test('Crockford Base32 character set validation', () => {
150+
// Valid characters: 0-9, A-H, J-K, M-N, P-T, V-Z (excludes I, L, O, U)
151+
//const validChars = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
152+
const testUlid = '01234567890123456789012345';
153+
expect(GraphQLULID.parseValue(testUlid)).toBe(testUlid);
154+
});
155+
});
156+
});

website/src/content/scalars/_meta.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export default {
6262
time: 'Time',
6363
'time-zone': 'TimeZone',
6464
timestamp: 'Timestamp',
65+
ulid: 'ULID',
6566
'unsigned-float': 'UnsignedFloat',
6667
'unsigned-int': 'UnsignedInt',
6768
url: 'URL',

website/src/content/scalars/ulid.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# ULID
2+
3+
The `ULID` scalar type represents a Universally Unique Lexicographically Sortable Identifier as
4+
defined by the [ULID specification](https://github.com/ulid/spec). ULIDs are 26-character strings
5+
that are URL-safe, case-insensitive, and lexicographically sortable, making them ideal for
6+
distributed systems requiring time-ordered unique identifiers.

0 commit comments

Comments
 (0)