diff --git a/packages/language/package.json b/packages/language/package.json index 3f4cca8c..2344404b 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -56,6 +56,7 @@ }, "dependencies": { "@zenstackhq/common-helpers": "workspace:*", + "@zenstackhq/schema": "workspace:*", "langium": "catalog:", "pluralize": "^8.0.0", "ts-pattern": "catalog:", diff --git a/packages/language/src/validators/function-invocation-validator.ts b/packages/language/src/validators/function-invocation-validator.ts index 8ce1035e..b1e285c7 100644 --- a/packages/language/src/validators/function-invocation-validator.ts +++ b/packages/language/src/validators/function-invocation-validator.ts @@ -1,3 +1,4 @@ +import { ID_GENERATOR_FUNCTIONS } from '@zenstackhq/schema'; import { AstUtils, type AstNode, type ValidationAcceptor } from 'langium'; import { match, P } from 'ts-pattern'; import { ExpressionContext } from '../constants'; @@ -88,7 +89,7 @@ export default class FunctionInvocationValidator implements AstValidator param.name === 'format'); const formatArg = getLiteral(expr.args[formatParamIdx]?.value); if ( diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 1b6f3d3c..f9dafb2e 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -35,6 +35,7 @@ import type { import type { AtLeast, MapBaseType, + MapStringWithFormat, MaybePromise, NonEmptyArray, NullableIf, @@ -970,14 +971,16 @@ type MapModelFieldType< type MapFieldDefType< Schema extends SchemaDef, - T extends Pick, + T extends Pick, Partial extends boolean = false, > = WrapType< T['type'] extends GetEnums ? keyof GetEnum : T['type'] extends GetTypeDefs ? TypeDefResult & Record - : MapBaseType, + : T['type'] extends 'String' + ? MapStringWithFormat + : MapBaseType, T['optional'], T['array'] >; diff --git a/packages/orm/src/utils/type-utils.ts b/packages/orm/src/utils/type-utils.ts index f1ad3d35..7713fa02 100644 --- a/packages/orm/src/utils/type-utils.ts +++ b/packages/orm/src/utils/type-utils.ts @@ -1,4 +1,5 @@ import type Decimal from 'decimal.js'; +import type { IdGeneratorFunction } from '@zenstackhq/schema'; import type { JsonObject, JsonValue } from '../common-types'; export type Optional = Omit & Partial>; @@ -88,3 +89,57 @@ export type OrUndefinedIf = Condition extends true export type UnwrapTuplePromises = { [K in keyof T]: Awaited; }; + +//#region Format string to template literal conversion + +/** + * Converts a format string like "user_%s" to a template literal type like `user_${string}`. + * Supports multiple %s placeholders: "pre_%s_mid_%s" -> `pre_${string}_mid_${string}` + * Handles escaped \%s which becomes literal %s: "\\%s_%s" -> `%s_${string}` + */ +export type FormatToTemplateLiteral = F extends `${infer Prefix}%s${infer Suffix}` + ? Prefix extends `${infer P}\\` + ? `${P}%s${FormatToTemplateLiteral}` // Escaped \%s -> literal %s + : `${Prefix}${string}${FormatToTemplateLiteral}` // Unescaped %s -> ${string} + : F; + +/** + * Extracts the format string from an ID generator call expression's args. + * For uuid/cuid/nanoid: format is in args[1] + * For ulid: format is in args[0] + */ +type ExtractFormatFromArgs< + Func extends string, + Args extends readonly unknown[], +> = Func extends 'ulid' + ? Args extends readonly [{ kind: 'literal'; value: infer V }, ...unknown[]] + ? V extends string + ? V + : never + : never + : Args extends readonly [unknown, { kind: 'literal'; value: infer V }, ...unknown[]] + ? V extends string + ? V + : never + : never; + +/** + * Extracts the format string from a field's default expression if it's an ID generator call. + * Returns the format string if found, never otherwise. + */ +export type ExtractIdFormat = Default extends { + kind: 'call'; + function: infer F extends IdGeneratorFunction; + args: infer A extends readonly unknown[]; +} + ? ExtractFormatFromArgs + : never; + +/** + * Maps a String type to either a template literal (if format exists) or plain string. + */ +export type MapStringWithFormat = ExtractIdFormat extends never + ? string + : FormatToTemplateLiteral>; + +//#endregion diff --git a/packages/orm/test/type-utils.test.ts b/packages/orm/test/type-utils.test.ts new file mode 100644 index 00000000..8e8fb052 --- /dev/null +++ b/packages/orm/test/type-utils.test.ts @@ -0,0 +1,285 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { + ExtractIdFormat, + FormatToTemplateLiteral, + MapStringWithFormat, +} from '../src/utils/type-utils'; +import type { CreateInput } from '../src/client/crud-types'; +import type { SchemaDef } from '../src/schema'; + +describe('FormatToTemplateLiteral', () => { + it('converts simple prefix format to template literal', () => { + expectTypeOf>().toEqualTypeOf<`user_${string}`>(); + }); + + it('converts simple suffix format to template literal', () => { + expectTypeOf>().toEqualTypeOf<`${string}_suffix`>(); + }); + + it('converts multiple placeholders to template literal', () => { + expectTypeOf>().toEqualTypeOf<`pre_${string}_mid_${string}`>(); + }); + + it('preserves string without placeholders', () => { + expectTypeOf>().toEqualTypeOf<'no_placeholder'>(); + }); + + it('handles escaped \\%s as literal %s', () => { + expectTypeOf>().toEqualTypeOf<'%s_end'>(); + }); + + it('handles mixed escaped and unescaped - unescaped first', () => { + expectTypeOf>().toEqualTypeOf<`${string}_%s`>(); + }); + + it('handles mixed escaped and unescaped - escaped first', () => { + expectTypeOf>().toEqualTypeOf<`%s_${string}`>(); + }); + + it('handles multiple escaped placeholders', () => { + expectTypeOf>().toEqualTypeOf<'%s_%s'>(); + }); + + it('handles complex mixed pattern', () => { + expectTypeOf>().toEqualTypeOf<`pre_%s_${string}_%s_end`>(); + }); +}); + +describe('ExtractIdFormat', () => { + it('extracts format from uuid call with format arg', () => { + type UuidCall = { + kind: 'call'; + function: 'uuid'; + args: readonly [{ kind: 'literal'; value: 4 }, { kind: 'literal'; value: 'user_%s' }]; + }; + expectTypeOf>().toEqualTypeOf<'user_%s'>(); + }); + + it('extracts format from cuid call with format arg', () => { + type CuidCall = { + kind: 'call'; + function: 'cuid'; + args: readonly [{ kind: 'literal'; value: 2 }, { kind: 'literal'; value: 'post_%s' }]; + }; + expectTypeOf>().toEqualTypeOf<'post_%s'>(); + }); + + it('extracts format from nanoid call with format arg', () => { + type NanoidCall = { + kind: 'call'; + function: 'nanoid'; + args: readonly [{ kind: 'literal'; value: 21 }, { kind: 'literal'; value: 'nano_%s' }]; + }; + expectTypeOf>().toEqualTypeOf<'nano_%s'>(); + }); + + it('extracts format from ulid call (format is first arg)', () => { + type UlidCall = { + kind: 'call'; + function: 'ulid'; + args: readonly [{ kind: 'literal'; value: 'ulid_%s' }]; + }; + expectTypeOf>().toEqualTypeOf<'ulid_%s'>(); + }); + + it('returns never for uuid call without format arg', () => { + type UuidNoFormat = { + kind: 'call'; + function: 'uuid'; + args: readonly [{ kind: 'literal'; value: 4 }]; + }; + expectTypeOf>().toEqualTypeOf(); + }); + + it('returns never for non-id-generator call', () => { + type OtherCall = { + kind: 'call'; + function: 'now'; + args: readonly []; + }; + expectTypeOf>().toEqualTypeOf(); + }); + + it('returns never for undefined', () => { + expectTypeOf>().toEqualTypeOf(); + }); +}); + +describe('MapStringWithFormat', () => { + it('returns template literal for uuid with format', () => { + type UuidCall = { + kind: 'call'; + function: 'uuid'; + args: readonly [{ kind: 'literal'; value: 4 }, { kind: 'literal'; value: 'user_%s' }]; + }; + expectTypeOf>().toEqualTypeOf<`user_${string}`>(); + }); + + it('returns plain string for uuid without format', () => { + type UuidNoFormat = { + kind: 'call'; + function: 'uuid'; + args: readonly [{ kind: 'literal'; value: 4 }]; + }; + expectTypeOf>().toEqualTypeOf(); + }); + + it('returns plain string for undefined default', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it('returns plain string for non-call default', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it('handles escaped format in uuid call', () => { + type UuidEscaped = { + kind: 'call'; + function: 'uuid'; + args: readonly [{ kind: 'literal'; value: 4 }, { kind: 'literal'; value: '\\%s_%s' }]; + }; + expectTypeOf>().toEqualTypeOf<`%s_${string}`>(); + }); +}); + +describe('CreateInput with prefixed ID', () => { + // Mock schema with a User model that has a prefixed ID + type TestSchema = SchemaDef & { + provider: { type: 'sqlite' }; + models: { + User: { + name: 'User'; + fields: { + id: { + name: 'id'; + type: 'String'; + id: true; + default: { + kind: 'call'; + function: 'uuid'; + args: readonly [ + { kind: 'literal'; value: 4 }, + { kind: 'literal'; value: 'user_%s' }, + ]; + }; + }; + name: { + name: 'name'; + type: 'String'; + }; + }; + uniqueFields: { id: { type: 'String' } }; + idFields: readonly ['id']; + }; + }; + enums: {}; + plugins: {}; + }; + + it('enforces template literal type for prefixed ID in create input', () => { + type UserCreateInput = CreateInput; + + // The id field should accept template literal type + expectTypeOf().toEqualTypeOf<`user_${string}` | undefined>(); + }); + + it('allows plain string for non-prefixed ID fields', () => { + // Schema with plain uuid (no format) + type PlainIdSchema = SchemaDef & { + provider: { type: 'sqlite' }; + models: { + Post: { + name: 'Post'; + fields: { + id: { + name: 'id'; + type: 'String'; + id: true; + default: { + kind: 'call'; + function: 'uuid'; + args: readonly [{ kind: 'literal'; value: 4 }]; + }; + }; + title: { + name: 'title'; + type: 'String'; + }; + }; + uniqueFields: { id: { type: 'String' } }; + idFields: readonly ['id']; + }; + }; + enums: {}; + plugins: {}; + }; + + type PostCreateInput = CreateInput; + + // The id field should accept plain string + expectTypeOf().toEqualTypeOf(); + }); + + it('enforces template literal for cuid with format', () => { + type CuidSchema = SchemaDef & { + provider: { type: 'sqlite' }; + models: { + Comment: { + name: 'Comment'; + fields: { + id: { + name: 'id'; + type: 'String'; + id: true; + default: { + kind: 'call'; + function: 'cuid'; + args: readonly [ + { kind: 'literal'; value: 2 }, + { kind: 'literal'; value: 'cmt_%s' }, + ]; + }; + }; + }; + uniqueFields: { id: { type: 'String' } }; + idFields: readonly ['id']; + }; + }; + enums: {}; + plugins: {}; + }; + + type CommentCreateInput = CreateInput; + expectTypeOf().toEqualTypeOf<`cmt_${string}` | undefined>(); + }); + + it('enforces template literal for ulid with format (format is first arg)', () => { + type UlidSchema = SchemaDef & { + provider: { type: 'sqlite' }; + models: { + Order: { + name: 'Order'; + fields: { + id: { + name: 'id'; + type: 'String'; + id: true; + default: { + kind: 'call'; + function: 'ulid'; + args: readonly [{ kind: 'literal'; value: 'ord_%s' }]; + }; + }; + }; + uniqueFields: { id: { type: 'String' } }; + idFields: readonly ['id']; + }; + }; + enums: {}; + plugins: {}; + }; + + type OrderCreateInput = CreateInput; + expectTypeOf().toEqualTypeOf<`ord_${string}` | undefined>(); + }); +}); diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 6a171e59..0140ef01 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -1,3 +1,3 @@ export type * from './expression'; export * from './expression-utils'; -export type * from './schema'; +export * from './schema'; diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index 40f7d8bd..cc0cdcb8 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -315,3 +315,13 @@ export type FieldIsDelegateDiscriminator< > = GetModelField['isDiscriminator'] extends true ? true : false; //#endregion + +//#region Field defaults + +/** + * ID generator functions that support format strings (e.g., uuid(4, "user_%s")) + */ +export const ID_GENERATOR_FUNCTIONS = ['uuid', 'cuid', 'nanoid', 'ulid'] as const; +export type IdGeneratorFunction = (typeof ID_GENERATOR_FUNCTIONS)[number]; + +//#endregion