Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/language/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
},
"dependencies": {
"@zenstackhq/common-helpers": "workspace:*",
"@zenstackhq/schema": "workspace:*",
"langium": "catalog:",
"pluralize": "^8.0.0",
"ts-pattern": "catalog:",
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -88,7 +89,7 @@ export default class FunctionInvocationValidator implements AstValidator<Express
}
}

if (['uuid', 'ulid', 'cuid', 'nanoid'].includes(funcDecl.name)) {
if (ID_GENERATOR_FUNCTIONS.includes(funcDecl.name as typeof ID_GENERATOR_FUNCTIONS[number])) {
const formatParamIdx = funcDecl.params.findIndex((param) => param.name === 'format');
const formatArg = getLiteral<string>(expr.args[formatParamIdx]?.value);
if (
Expand Down
7 changes: 5 additions & 2 deletions packages/orm/src/client/crud-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import type {
import type {
AtLeast,
MapBaseType,
MapStringWithFormat,
MaybePromise,
NonEmptyArray,
NullableIf,
Expand Down Expand Up @@ -970,14 +971,16 @@ type MapModelFieldType<

type MapFieldDefType<
Schema extends SchemaDef,
T extends Pick<FieldDef, 'type' | 'optional' | 'array'>,
T extends Pick<FieldDef, 'type' | 'optional' | 'array' | 'default'>,
Partial extends boolean = false,
> = WrapType<
T['type'] extends GetEnums<Schema>
? keyof GetEnum<Schema, T['type']>
: T['type'] extends GetTypeDefs<Schema>
? TypeDefResult<Schema, T['type'], Partial> & Record<string, unknown>
: MapBaseType<T['type']>,
: T['type'] extends 'String'
? MapStringWithFormat<T['default']>
: MapBaseType<T['type']>,
T['optional'],
T['array']
>;
Expand Down
55 changes: 55 additions & 0 deletions packages/orm/src/utils/type-utils.ts
Original file line number Diff line number Diff line change
@@ -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<T extends object, K extends keyof T = keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
Expand Down Expand Up @@ -88,3 +89,57 @@ export type OrUndefinedIf<T, Condition extends boolean> = Condition extends true
export type UnwrapTuplePromises<T extends readonly unknown[]> = {
[K in keyof T]: Awaited<T[K]>;
};

//#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 string> = F extends `${infer Prefix}%s${infer Suffix}`
? Prefix extends `${infer P}\\`
? `${P}%s${FormatToTemplateLiteral<Suffix>}` // Escaped \%s -> literal %s
: `${Prefix}${string}${FormatToTemplateLiteral<Suffix>}` // 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> = Default extends {
kind: 'call';
function: infer F extends IdGeneratorFunction;
args: infer A extends readonly unknown[];
}
? ExtractFormatFromArgs<F, A>
: never;

/**
* Maps a String type to either a template literal (if format exists) or plain string.
*/
export type MapStringWithFormat<Default> = ExtractIdFormat<Default> extends never
? string
: FormatToTemplateLiteral<ExtractIdFormat<Default>>;

//#endregion
285 changes: 285 additions & 0 deletions packages/orm/test/type-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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<FormatToTemplateLiteral<'user_%s'>>().toEqualTypeOf<`user_${string}`>();
});

it('converts simple suffix format to template literal', () => {
expectTypeOf<FormatToTemplateLiteral<'%s_suffix'>>().toEqualTypeOf<`${string}_suffix`>();
});

it('converts multiple placeholders to template literal', () => {
expectTypeOf<FormatToTemplateLiteral<'pre_%s_mid_%s'>>().toEqualTypeOf<`pre_${string}_mid_${string}`>();
});

it('preserves string without placeholders', () => {
expectTypeOf<FormatToTemplateLiteral<'no_placeholder'>>().toEqualTypeOf<'no_placeholder'>();
});

it('handles escaped \\%s as literal %s', () => {
expectTypeOf<FormatToTemplateLiteral<'\\%s_end'>>().toEqualTypeOf<'%s_end'>();
});

it('handles mixed escaped and unescaped - unescaped first', () => {
expectTypeOf<FormatToTemplateLiteral<'%s_\\%s'>>().toEqualTypeOf<`${string}_%s`>();
});

it('handles mixed escaped and unescaped - escaped first', () => {
expectTypeOf<FormatToTemplateLiteral<'\\%s_%s'>>().toEqualTypeOf<`%s_${string}`>();
});

it('handles multiple escaped placeholders', () => {
expectTypeOf<FormatToTemplateLiteral<'\\%s_\\%s'>>().toEqualTypeOf<'%s_%s'>();
});

it('handles complex mixed pattern', () => {
expectTypeOf<FormatToTemplateLiteral<'pre_\\%s_%s_\\%s_end'>>().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<ExtractIdFormat<UuidCall>>().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<ExtractIdFormat<CuidCall>>().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<ExtractIdFormat<NanoidCall>>().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<ExtractIdFormat<UlidCall>>().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<ExtractIdFormat<UuidNoFormat>>().toEqualTypeOf<never>();
});

it('returns never for non-id-generator call', () => {
type OtherCall = {
kind: 'call';
function: 'now';
args: readonly [];
};
expectTypeOf<ExtractIdFormat<OtherCall>>().toEqualTypeOf<never>();
});

it('returns never for undefined', () => {
expectTypeOf<ExtractIdFormat<undefined>>().toEqualTypeOf<never>();
});
});

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<MapStringWithFormat<UuidCall>>().toEqualTypeOf<`user_${string}`>();
});

it('returns plain string for uuid without format', () => {
type UuidNoFormat = {
kind: 'call';
function: 'uuid';
args: readonly [{ kind: 'literal'; value: 4 }];
};
expectTypeOf<MapStringWithFormat<UuidNoFormat>>().toEqualTypeOf<string>();
});

it('returns plain string for undefined default', () => {
expectTypeOf<MapStringWithFormat<undefined>>().toEqualTypeOf<string>();
});

it('returns plain string for non-call default', () => {
expectTypeOf<MapStringWithFormat<'static-value'>>().toEqualTypeOf<string>();
});

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<MapStringWithFormat<UuidEscaped>>().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<TestSchema, 'User'>;

// The id field should accept template literal type
expectTypeOf<UserCreateInput['id']>().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<PlainIdSchema, 'Post'>;

// The id field should accept plain string
expectTypeOf<PostCreateInput['id']>().toEqualTypeOf<string | undefined>();
});

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<CuidSchema, 'Comment'>;
expectTypeOf<CommentCreateInput['id']>().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<UlidSchema, 'Order'>;
expectTypeOf<OrderCreateInput['id']>().toEqualTypeOf<`ord_${string}` | undefined>();
});
});
Loading