Skip to content

Commit a0e2b53

Browse files
authored
feat(zmodel): add new functions currentModel and currentOperation (#1925)
1 parent b41fd93 commit a0e2b53

File tree

6 files changed

+441
-15
lines changed

6 files changed

+441
-15
lines changed

packages/schema/src/language-server/validator/function-invocation-validator.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,17 @@ export default class FunctionInvocationValidator implements AstValidator<Express
8787
return;
8888
}
8989

90-
if (
90+
// TODO: express function validation rules declaratively in ZModel
91+
92+
const allCasing = ['original', 'upper', 'lower', 'capitalize', 'uncapitalize'];
93+
if (['currentModel', 'currentOperation'].includes(funcDecl.name)) {
94+
const arg = getLiteral<string>(expr.args[0]?.value);
95+
if (arg && !allCasing.includes(arg)) {
96+
accept('error', `argument must be one of: ${allCasing.map((c) => '"' + c + '"').join(', ')}`, {
97+
node: expr.args[0],
98+
});
99+
}
100+
} else if (
91101
funcAllowedContext.includes(ExpressionContext.AccessPolicy) ||
92102
funcAllowedContext.includes(ExpressionContext.ValidationRule)
93103
) {

packages/schema/src/res/stdlib.zmodel

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,29 @@ function hasSome(field: Any[], search: Any[]): Boolean {
171171
function isEmpty(field: Any[]): Boolean {
172172
} @@@expressionContext([AccessPolicy, ValidationRule])
173173

174+
/**
175+
* The name of the model for which the policy rule is defined. If the rule is
176+
* inherited to a sub model, this function returns the name of the sub model.
177+
*
178+
* @param optional parameter to control the casing of the returned value. Valid
179+
* values are "original", "upper", "lower", "capitalize", "uncapitalize". Defaults
180+
* to "original".
181+
*/
182+
function currentModel(casing: String?): String {
183+
} @@@expressionContext([AccessPolicy])
184+
185+
/**
186+
* The operation for which the policy rule is defined for. Note that a rule with
187+
* "all" operation is expanded to "create", "read", "update", and "delete" rules,
188+
* and the function returns corresponding value for each expanded version.
189+
*
190+
* @param optional parameter to control the casing of the returned value. Valid
191+
* values are "original", "upper", "lower", "capitalize", "uncapitalize". Defaults
192+
* to "original".
193+
*/
194+
function currentOperation(casing: String?): String {
195+
} @@@expressionContext([AccessPolicy])
196+
174197
/**
175198
* Marks an attribute to be only applicable to certain field types.
176199
*/

packages/sdk/src/code-gen.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ export async function saveProject(project: Project) {
4747
* Emit a TS project to JS files.
4848
*/
4949
export async function emitProject(project: Project) {
50+
// ignore type checking for all source files
51+
for (const sf of project.getSourceFiles()) {
52+
sf.insertStatements(0, '// @ts-nocheck');
53+
}
54+
5055
const errors = project.getPreEmitDiagnostics().filter((d) => d.getCategory() === DiagnosticCategory.Error);
5156
if (errors.length > 0) {
5257
console.error('Error compiling generated code:');

packages/sdk/src/typescript-expression-transformer.ts

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
isNullExpr,
2121
isThisExpr,
2222
} from '@zenstackhq/language/ast';
23+
import { getContainerOfType } from 'langium';
2324
import { P, match } from 'ts-pattern';
2425
import { ExpressionContext } from './constants';
2526
import { getEntityCheckerFunctionName } from './names';
@@ -40,6 +41,8 @@ type Options = {
4041
operationContext?: 'read' | 'create' | 'update' | 'postUpdate' | 'delete';
4142
};
4243

44+
type Casing = 'original' | 'upper' | 'lower' | 'capitalize' | 'uncapitalize';
45+
4346
// a registry of function handlers marked with @func
4447
const functionHandlers = new Map<string, PropertyDescriptor>();
4548

@@ -150,7 +153,7 @@ export class TypeScriptExpressionTransformer {
150153
}
151154

152155
const args = expr.args.map((arg) => arg.value);
153-
return handler.value.call(this, args, normalizeUndefined);
156+
return handler.value.call(this, expr, args, normalizeUndefined);
154157
}
155158

156159
// #region function invocation handlers
@@ -168,7 +171,7 @@ export class TypeScriptExpressionTransformer {
168171
}
169172

170173
@func('length')
171-
private _length(args: Expression[]) {
174+
private _length(_invocation: InvocationExpr, args: Expression[]) {
172175
const field = this.transform(args[0], false);
173176
const min = getLiteral<number>(args[1]);
174177
const max = getLiteral<number>(args[2]);
@@ -188,7 +191,7 @@ export class TypeScriptExpressionTransformer {
188191
}
189192

190193
@func('contains')
191-
private _contains(args: Expression[], normalizeUndefined: boolean) {
194+
private _contains(_invocation: InvocationExpr, args: Expression[], normalizeUndefined: boolean) {
192195
const field = this.transform(args[0], false);
193196
const caseInsensitive = getLiteral<boolean>(args[2]) === true;
194197
let result: string;
@@ -201,34 +204,34 @@ export class TypeScriptExpressionTransformer {
201204
}
202205

203206
@func('startsWith')
204-
private _startsWith(args: Expression[], normalizeUndefined: boolean) {
207+
private _startsWith(_invocation: InvocationExpr, args: Expression[], normalizeUndefined: boolean) {
205208
const field = this.transform(args[0], false);
206209
const result = `${field}?.startsWith(${this.transform(args[1], normalizeUndefined)})`;
207210
return this.ensureBoolean(result);
208211
}
209212

210213
@func('endsWith')
211-
private _endsWith(args: Expression[], normalizeUndefined: boolean) {
214+
private _endsWith(_invocation: InvocationExpr, args: Expression[], normalizeUndefined: boolean) {
212215
const field = this.transform(args[0], false);
213216
const result = `${field}?.endsWith(${this.transform(args[1], normalizeUndefined)})`;
214217
return this.ensureBoolean(result);
215218
}
216219

217220
@func('regex')
218-
private _regex(args: Expression[]) {
221+
private _regex(_invocation: InvocationExpr, args: Expression[]) {
219222
const field = this.transform(args[0], false);
220223
const pattern = getLiteral<string>(args[1]);
221224
return this.ensureBooleanTernary(args[0], field, `new RegExp(${JSON.stringify(pattern)}).test(${field})`);
222225
}
223226

224227
@func('email')
225-
private _email(args: Expression[]) {
228+
private _email(_invocation: InvocationExpr, args: Expression[]) {
226229
const field = this.transform(args[0], false);
227230
return this.ensureBooleanTernary(args[0], field, `z.string().email().safeParse(${field}).success`);
228231
}
229232

230233
@func('datetime')
231-
private _datetime(args: Expression[]) {
234+
private _datetime(_invocation: InvocationExpr, args: Expression[]) {
232235
const field = this.transform(args[0], false);
233236
return this.ensureBooleanTernary(
234237
args[0],
@@ -238,20 +241,20 @@ export class TypeScriptExpressionTransformer {
238241
}
239242

240243
@func('url')
241-
private _url(args: Expression[]) {
244+
private _url(_invocation: InvocationExpr, args: Expression[]) {
242245
const field = this.transform(args[0], false);
243246
return this.ensureBooleanTernary(args[0], field, `z.string().url().safeParse(${field}).success`);
244247
}
245248

246249
@func('has')
247-
private _has(args: Expression[], normalizeUndefined: boolean) {
250+
private _has(_invocation: InvocationExpr, args: Expression[], normalizeUndefined: boolean) {
248251
const field = this.transform(args[0], false);
249252
const result = `${field}?.includes(${this.transform(args[1], normalizeUndefined)})`;
250253
return this.ensureBoolean(result);
251254
}
252255

253256
@func('hasEvery')
254-
private _hasEvery(args: Expression[], normalizeUndefined: boolean) {
257+
private _hasEvery(_invocation: InvocationExpr, args: Expression[], normalizeUndefined: boolean) {
255258
const field = this.transform(args[0], false);
256259
return this.ensureBooleanTernary(
257260
args[0],
@@ -261,7 +264,7 @@ export class TypeScriptExpressionTransformer {
261264
}
262265

263266
@func('hasSome')
264-
private _hasSome(args: Expression[], normalizeUndefined: boolean) {
267+
private _hasSome(_invocation: InvocationExpr, args: Expression[], normalizeUndefined: boolean) {
265268
const field = this.transform(args[0], false);
266269
return this.ensureBooleanTernary(
267270
args[0],
@@ -271,13 +274,13 @@ export class TypeScriptExpressionTransformer {
271274
}
272275

273276
@func('isEmpty')
274-
private _isEmpty(args: Expression[]) {
277+
private _isEmpty(_invocation: InvocationExpr, args: Expression[]) {
275278
const field = this.transform(args[0], false);
276279
return `(!${field} || ${field}?.length === 0)`;
277280
}
278281

279282
@func('check')
280-
private _check(args: Expression[]) {
283+
private _check(_invocation: InvocationExpr, args: Expression[]) {
281284
if (!isDataModelFieldReference(args[0])) {
282285
throw new TypeScriptExpressionTransformerError(`First argument of check() must be a field`);
283286
}
@@ -309,6 +312,52 @@ export class TypeScriptExpressionTransformer {
309312
return `${entityCheckerFunc}(input.${fieldRef.target.$refText}, context)`;
310313
}
311314

315+
private toStringWithCaseChange(value: string, casing: Casing) {
316+
if (!value) {
317+
return "''";
318+
}
319+
return match(casing)
320+
.with('original', () => `'${value}'`)
321+
.with('upper', () => `'${value.toUpperCase()}'`)
322+
.with('lower', () => `'${value.toLowerCase()}'`)
323+
.with('capitalize', () => `'${value.charAt(0).toUpperCase() + value.slice(1)}'`)
324+
.with('uncapitalize', () => `'${value.charAt(0).toLowerCase() + value.slice(1)}'`)
325+
.exhaustive();
326+
}
327+
328+
@func('currentModel')
329+
private _currentModel(invocation: InvocationExpr, args: Expression[]) {
330+
let casing: Casing = 'original';
331+
if (args[0]) {
332+
casing = getLiteral<string>(args[0]) as Casing;
333+
}
334+
335+
const containingModel = getContainerOfType(invocation, isDataModel);
336+
if (!containingModel) {
337+
throw new TypeScriptExpressionTransformerError('currentModel() must be called inside a model');
338+
}
339+
return this.toStringWithCaseChange(containingModel.name, casing);
340+
}
341+
342+
@func('currentOperation')
343+
private _currentOperation(_invocation: InvocationExpr, args: Expression[]) {
344+
let casing: Casing = 'original';
345+
if (args[0]) {
346+
casing = getLiteral<string>(args[0]) as Casing;
347+
}
348+
349+
if (!this.options.operationContext) {
350+
throw new TypeScriptExpressionTransformerError(
351+
'currentOperation() must be called inside an access policy rule'
352+
);
353+
}
354+
let contextOperation = this.options.operationContext;
355+
if (contextOperation === 'postUpdate') {
356+
contextOperation = 'update';
357+
}
358+
return this.toStringWithCaseChange(contextOperation, casing);
359+
}
360+
312361
private ensureBoolean(expr: string) {
313362
if (this.options.context === ExpressionContext.ValidationRule) {
314363
// all fields are optional in a validation context, so we treat undefined

0 commit comments

Comments
 (0)