Skip to content
This repository was archived by the owner on Mar 1, 2026. It is now read-only.

Commit 4f06c20

Browse files
sanny-ioymc9
andauthored
Add support for generated identifier format strings (id prefixing). (#509)
* Add support for generated identifier format strings (ID prefixing). * Add tests. * Add missing semicolon. * Cleanup logic. * Fix typo. * Use `replaceAll` instead. * Add language support and tests. * Simplify logic. * allow using '\\%s' to escape replacement pattern, improve tests * Shorten some test names. --------- Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com>
1 parent 9c84664 commit 4f06c20

5 files changed

Lines changed: 431 additions & 19 deletions

File tree

packages/language/res/stdlib.zmodel

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,25 +83,25 @@ function now(): DateTime {
8383
/**
8484
* Generates a globally unique identifier based on the UUID specs.
8585
*/
86-
function uuid(version: Int?): String {
86+
function uuid(version: Int?, format: String?): String {
8787
} @@@expressionContext([DefaultValue])
8888

8989
/**
9090
* Generates a globally unique identifier based on the CUID spec.
9191
*/
92-
function cuid(version: Int?): String {
92+
function cuid(version: Int?, format: String?): String {
9393
} @@@expressionContext([DefaultValue])
9494

9595
/**
9696
* Generates an identifier based on the nanoid spec.
9797
*/
98-
function nanoid(length: Int?): String {
98+
function nanoid(length: Int?, format: String?): String {
9999
} @@@expressionContext([DefaultValue])
100100

101101
/**
102102
* Generates an identifier based on the ulid spec.
103103
*/
104-
function ulid(): String {
104+
function ulid(format: String?): String {
105105
} @@@expressionContext([DefaultValue])
106106

107107
/**

packages/language/src/validators/function-invocation-validator.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,16 @@ export default class FunctionInvocationValidator implements AstValidator<Express
8787
}
8888
}
8989

90+
if (['uuid', 'ulid', 'cuid', 'nanoid'].includes(funcDecl.name)) {
91+
const formatParamIdx = funcDecl.params.findIndex(param => param.name === 'format');
92+
const formatArg = getLiteral<string>(expr.args[formatParamIdx]?.value);
93+
if (formatArg && !formatArg.includes('%s')) {
94+
accept('error', 'argument must include "%s"', {
95+
node: expr.args[formatParamIdx]!,
96+
});
97+
}
98+
}
99+
90100
// run checkers for specific functions
91101
const checker = invocationCheckers.get(expr.function.$refText);
92102
if (checker) {
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { describe, it } from 'vitest';
2+
import { loadSchema, loadSchemaWithError } from './utils';
3+
4+
describe('Function Invocation Tests', () => {
5+
it('id functions should not require format strings', async () => {
6+
await loadSchema(
7+
`
8+
datasource db {
9+
provider = 'sqlite'
10+
url = 'file:./dev.db'
11+
}
12+
13+
model User {
14+
id String @id @default(uuid())
15+
}
16+
`,
17+
);
18+
19+
await loadSchema(
20+
`
21+
datasource db {
22+
provider = 'sqlite'
23+
url = 'file:./dev.db'
24+
}
25+
26+
model User {
27+
id String @id @default(uuid(7))
28+
}
29+
`,
30+
);
31+
32+
await loadSchema(
33+
`
34+
datasource db {
35+
provider = 'sqlite'
36+
url = 'file:./dev.db'
37+
}
38+
39+
model User {
40+
id String @id @default(nanoid())
41+
}
42+
`,
43+
);
44+
45+
await loadSchema(
46+
`
47+
datasource db {
48+
provider = 'sqlite'
49+
url = 'file:./dev.db'
50+
}
51+
52+
model User {
53+
id String @id @default(nanoid(8))
54+
}
55+
`,
56+
);
57+
58+
await loadSchema(
59+
`
60+
datasource db {
61+
provider = 'sqlite'
62+
url = 'file:./dev.db'
63+
}
64+
65+
model User {
66+
id String @id @default(ulid())
67+
}
68+
`,
69+
);
70+
71+
await loadSchema(
72+
`
73+
datasource db {
74+
provider = 'sqlite'
75+
url = 'file:./dev.db'
76+
}
77+
78+
model User {
79+
id String @id @default(cuid())
80+
}
81+
`,
82+
);
83+
84+
await loadSchema(
85+
`
86+
datasource db {
87+
provider = 'sqlite'
88+
url = 'file:./dev.db'
89+
}
90+
91+
model User {
92+
id String @id @default(cuid(2))
93+
}
94+
`,
95+
);
96+
});
97+
98+
it('id functions should allow valid format strings', async () => {
99+
await loadSchema(
100+
`
101+
datasource db {
102+
provider = 'sqlite'
103+
url = 'file:./dev.db'
104+
}
105+
106+
model User {
107+
id String @id @default(uuid(7, '%s_user'))
108+
}
109+
`,
110+
);
111+
112+
await loadSchema(
113+
`
114+
datasource db {
115+
provider = 'sqlite'
116+
url = 'file:./dev.db'
117+
}
118+
119+
model User {
120+
id String @id @default(cuid(2, '%s'))
121+
}
122+
`,
123+
);
124+
125+
await loadSchema(
126+
`
127+
datasource db {
128+
provider = 'sqlite'
129+
url = 'file:./dev.db'
130+
}
131+
132+
model User {
133+
id String @id @default(ulid('user_%s'))
134+
}
135+
`,
136+
);
137+
138+
await loadSchema(
139+
`
140+
datasource db {
141+
provider = 'sqlite'
142+
url = 'file:./dev.db'
143+
}
144+
145+
model User {
146+
id String @id @default(nanoid(8, 'user_%s'))
147+
}
148+
`,
149+
);
150+
});
151+
152+
it('id functions should reject invalid format strings', async () => {
153+
await loadSchemaWithError(
154+
`
155+
datasource db {
156+
provider = 'sqlite'
157+
url = 'file:./dev.db'
158+
}
159+
160+
model User {
161+
id String @id @default(uuid(7, 'user_%'))
162+
}
163+
`,
164+
'argument must include',
165+
);
166+
167+
await loadSchemaWithError(
168+
`
169+
datasource db {
170+
provider = 'sqlite'
171+
url = 'file:./dev.db'
172+
}
173+
174+
model User {
175+
id String @id @default(nanoid(8, 'user'))
176+
}
177+
`,
178+
'argument must include',
179+
);
180+
181+
await loadSchemaWithError(
182+
`
183+
datasource db {
184+
provider = 'sqlite'
185+
url = 'file:./dev.db'
186+
}
187+
188+
model User {
189+
id String @id @default(ulid('user_%'))
190+
}
191+
`,
192+
'argument must include',
193+
);
194+
195+
await loadSchemaWithError(
196+
`
197+
datasource db {
198+
provider = 'sqlite'
199+
url = 'file:./dev.db'
200+
}
201+
202+
model User {
203+
id String @id @default(cuid(2, 'user_%'))
204+
}
205+
`,
206+
'argument must include',
207+
);
208+
});
209+
});

packages/orm/src/client/crud/operations/base.ts

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -860,22 +860,30 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
860860
private evalGenerator(defaultValue: Expression) {
861861
if (ExpressionUtils.isCall(defaultValue)) {
862862
return match(defaultValue.function)
863-
.with('cuid', () => createId())
864-
.with('uuid', () =>
865-
defaultValue.args?.[0] &&
866-
ExpressionUtils.isLiteral(defaultValue.args?.[0]) &&
867-
defaultValue.args[0].value === 7
863+
.with('cuid', () => this.formatGeneratedValue(createId(), defaultValue.args?.[1]))
864+
.with('uuid', () => {
865+
const version = defaultValue.args?.[0] && ExpressionUtils.isLiteral(defaultValue.args[0])
866+
? defaultValue.args[0].value
867+
: undefined;
868+
869+
const generated = version === 7
868870
? uuid.v7()
869-
: uuid.v4(),
870-
)
871-
.with('nanoid', () =>
872-
defaultValue.args?.[0] &&
873-
ExpressionUtils.isLiteral(defaultValue.args[0]) &&
874-
typeof defaultValue.args[0].value === 'number'
875-
? nanoid(defaultValue.args[0].value)
876-
: nanoid(),
877-
)
878-
.with('ulid', () => ulid())
871+
: uuid.v4();
872+
873+
return this.formatGeneratedValue(generated, defaultValue.args?.[1]);
874+
})
875+
.with('nanoid', () => {
876+
const length = defaultValue.args?.[0] && ExpressionUtils.isLiteral(defaultValue.args[0])
877+
? defaultValue.args[0].value
878+
: undefined;
879+
880+
const generated = typeof length === 'number'
881+
? nanoid(length)
882+
: nanoid();
883+
884+
return this.formatGeneratedValue(generated, defaultValue.args?.[1]);
885+
})
886+
.with('ulid', () => this.formatGeneratedValue(ulid(), defaultValue.args?.[0]))
879887
.otherwise(() => undefined);
880888
} else if (
881889
ExpressionUtils.isMember(defaultValue) &&
@@ -893,6 +901,15 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
893901
}
894902
}
895903

904+
private formatGeneratedValue(generated: string, formatExpr?: Expression) {
905+
if (!formatExpr || !ExpressionUtils.isLiteral(formatExpr) || typeof formatExpr.value !== 'string') {
906+
return generated;
907+
}
908+
909+
// Replace non-escaped %s with the generated value, then unescape \%s to %s
910+
return formatExpr.value.replace(/(?<!\\)%s/g, generated).replace(/\\%s/g, '%s');
911+
}
912+
896913
protected async update(
897914
kysely: AnyKysely,
898915
model: string,

0 commit comments

Comments
 (0)