Skip to content
Merged
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 @@ -11,6 +11,7 @@
"type": "module",
"scripts": {
"build": "pnpm langium:generate && tsc --noEmit && tsup-node",
"watch": "tsup-node --watch",
"lint": "eslint src --ext ts",
"langium:generate": "langium generate",
"langium:generate:production": "langium generate --mode=production",
Expand Down
4 changes: 2 additions & 2 deletions packages/language/res/stdlib.zmodel
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,7 @@ attribute @@allow(_ operation: String @@@completionHint(["'create'", "'read'", "
* @param condition: a boolean expression that controls if the operation should be allowed.
* @param override: a boolean value that controls if the field-level policy should override the model-level policy.
*/
attribute @allow(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'delete'", "'all'"]), _ condition: Boolean, _ override: Boolean?)
// attribute @allow(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'delete'", "'all'"]), _ condition: Boolean, _ override: Boolean?)

/**
* Defines an access policy that denies a set of operations when the given condition is true.
Expand All @@ -692,7 +692,7 @@ attribute @@deny(_ operation: String @@@completionHint(["'create'", "'read'", "'
* @param operation: comma-separated list of "create", "read", "update", "delete". Use "all" to denote all operations.
* @param condition: a boolean expression that controls if the operation should be denied.
*/
attribute @deny(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'delete'", "'all'"]), _ condition: Boolean)
// attribute @deny(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'delete'", "'all'"]), _ condition: Boolean)

/**
* Checks if the current user can perform the given operation on the given field.
Expand Down
4 changes: 2 additions & 2 deletions packages/language/src/zmodel-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ import {
getAuthDecl,
getRecursiveBases,
isAuthInvocation,
isCollectionPredicate,
isBeforeInvocation,
isCollectionPredicate,
resolveImportUri,
} from './utils';

Expand Down Expand Up @@ -75,7 +75,7 @@ export class ZModelScopeComputation extends DefaultScopeComputation {

override processNode(node: AstNode, document: LangiumDocument<AstNode>, scopes: PrecomputedScopes) {
super.processNode(node, document, scopes);
if (isDataModel(node)) {
if (isDataModel(node) || isTypeDef(node)) {
// add base fields to the scope recursively
const bases = getRecursiveBases(node);
for (const base of bases) {
Expand Down
28 changes: 13 additions & 15 deletions packages/runtime/src/client/crud/operations/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -939,15 +939,18 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
combinedWhere = Object.keys(combinedWhere).length > 0 ? { AND: [parentWhere, combinedWhere] } : parentWhere;
}

// fill in automatically updated fields
const modelDef = this.requireModel(model);
let finalData = data;

// fill in automatically updated fields
const autoUpdatedFields: string[] = [];
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
if (fieldDef.updatedAt) {
if (finalData === data) {
finalData = clone(data);
}
finalData[fieldName] = this.dialect.transformPrimitive(new Date(), 'DateTime', false);
autoUpdatedFields.push(fieldName);
}
}

Expand Down Expand Up @@ -1027,7 +1030,13 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
}
}

if (Object.keys(updateFields).length === 0) {
let hasFieldUpdate = Object.keys(updateFields).length > 0;
if (hasFieldUpdate) {
// check if only updating auto-updated fields, if so, we can skip the update
hasFieldUpdate = Object.keys(updateFields).some((f) => !autoUpdatedFields.includes(f));
}

if (!hasFieldUpdate) {
// nothing to update, return the filter so that the caller can identify the entity
return combinedWhere;
} else {
Expand Down Expand Up @@ -2073,22 +2082,11 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
}
}

// Given a unique filter of a model, return the entity ids by trying to
// reused the filter if it's a complete id filter (without extra fields)
// otherwise, read the entity by the filter
// Given a unique filter of a model, load the entity and return its id fields
private getEntityIds(kysely: ToKysely<Schema>, model: GetModels<Schema>, uniqueFilter: any) {
const idFields: string[] = requireIdFields(this.schema, model);
if (
// all id fields are provided
idFields.every((f) => f in uniqueFilter && uniqueFilter[f] !== undefined) &&
// no non-id filter exists
Object.keys(uniqueFilter).every((k) => idFields.includes(k))
) {
return uniqueFilter;
}

return this.readUnique(kysely, model, {
where: uniqueFilter,
select: this.makeIdSelect(model),
});
}

Expand Down
7 changes: 6 additions & 1 deletion packages/runtime/src/client/crud/validator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -976,9 +976,14 @@ export class InputValidator<Schema extends SchemaDef> {
])
.optional();

let upsertWhere = this.makeWhereSchema(fieldType, true);
if (!fieldDef.array) {
// to-one relation, can upsert without where clause
upsertWhere = upsertWhere.optional();
}
fields['upsert'] = this.orArray(
z.strictObject({
where: this.makeWhereSchema(fieldType, true),
where: upsertWhere,
create: this.makeCreateDataSchema(fieldType, false, withoutFields),
update: this.makeUpdateDataSchema(fieldType, withoutFields),
}),
Expand Down
1 change: 0 additions & 1 deletion packages/testtools/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ export async function createTestClient<Schema extends SchemaDef>(
const provider = options?.provider ?? getTestDbProvider() ?? 'sqlite';

const dbName = options?.dbName ?? getTestDbName(provider);
console.log(`Using provider: ${provider}, db: ${dbName}`);

const dbUrl =
provider === 'sqlite'
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 17 additions & 1 deletion tests/e2e/orm/client-api/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ describe('Client update tests', () => {
email: user.email,
name: user.name,
});
expect(updated.updatedAt.getTime()).toBeGreaterThan(user.updatedAt.getTime());
// should not update updatedAt
expect(updated.updatedAt.getTime()).toEqual(user.updatedAt.getTime());

// id as filter
updated = await client.user.update({
Expand Down Expand Up @@ -114,6 +115,21 @@ describe('Client update tests', () => {
).resolves.toMatchObject({ id: 'user2' });
});

it('does not update updatedAt if no other scalar fields are updated', async () => {
const user = await createUser(client, '[email protected]');
const originalUpdatedAt = user.updatedAt;

await client.user.update({
where: { id: user.id },
data: {
posts: { create: { title: 'Post1' } },
},
});

const updatedUser = await client.user.findUnique({ where: { id: user.id } });
expect(updatedUser?.updatedAt).toEqual(originalUpdatedAt);
});

it('works with numeric incremental update', async () => {
await createUser(client, '[email protected]', {
profile: { create: { id: '1', bio: 'bio' } },
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/orm/policy/crud/read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ model Bar {

await db.$unuseAll().foo.create({ data: { id: 1 } });
await expect(db.foo.findMany()).resolves.toHaveLength(0);
await db.foo.update({ where: { id: 1 }, data: { bar: { create: { id: 1, y: 0 } } } });
await db.$unuseAll().foo.update({ where: { id: 1 }, data: { bar: { create: { id: 1, y: 0 } } } });
await expect(db.foo.findMany()).resolves.toHaveLength(1);
});

Expand All @@ -321,7 +321,7 @@ model Bar {

await db.$unuseAll().foo.create({ data: { id: 1, bars: { create: [{ id: 1, y: 0 }] } } });
await expect(db.foo.findMany()).resolves.toHaveLength(0);
await db.foo.update({ where: { id: 1 }, data: { bars: { create: { id: 2, y: 1 } } } });
await db.$unuseAll().foo.update({ where: { id: 1 }, data: { bars: { create: { id: 2, y: 1 } } } });
await expect(db.foo.findMany()).resolves.toHaveLength(1);
});

Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/orm/policy/migrated/omit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ describe('prisma omit', () => {
name String
profile Profile?
age Int
value Int @allow('read', age > 20)
value Int
@@allow('all', age > 18)
}

Expand Down
5 changes: 3 additions & 2 deletions tests/regression/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@
"test": "pnpm generate && tsc && vitest run"
},
"dependencies": {
"@zenstackhq/testtools": "workspace:*"
"@zenstackhq/testtools": "workspace:*",
"decimal.js": "^10.4.3"
},
"devDependencies": {
"@zenstackhq/cli": "workspace:*",
"@zenstackhq/sdk": "workspace:*",
"@zenstackhq/language": "workspace:*",
"@zenstackhq/runtime": "workspace:*",
"@zenstackhq/sdk": "workspace:*",
"@zenstackhq/typescript-config": "workspace:*",
"@zenstackhq/vitest-config": "workspace:*"
}
Expand Down
30 changes: 30 additions & 0 deletions tests/regression/test/v2-migrated/issue-657.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { createTestClient } from '@zenstackhq/testtools';
import Decimal from 'decimal.js';
import { expect, it } from 'vitest';

// TODO: zod support
it.skip('verifies issue 657', async () => {
const { zodSchemas } = await createTestClient(`
model Foo {
id Int @id @default(autoincrement())
intNumber Int @gt(0)
floatNumber Float @gt(0)
decimalNumber Decimal @gt(0.1) @lte(10)
}
`);

const schema = zodSchemas.models.FooUpdateSchema;
expect(schema.safeParse({ intNumber: 0 }).success).toBeFalsy();
expect(schema.safeParse({ intNumber: 1 }).success).toBeTruthy();
expect(schema.safeParse({ floatNumber: 0 }).success).toBeFalsy();
expect(schema.safeParse({ floatNumber: 1.1 }).success).toBeTruthy();
expect(schema.safeParse({ decimalNumber: 0 }).success).toBeFalsy();
expect(schema.safeParse({ decimalNumber: '0' }).success).toBeFalsy();
expect(schema.safeParse({ decimalNumber: new Decimal(0) }).success).toBeFalsy();
expect(schema.safeParse({ decimalNumber: 11 }).success).toBeFalsy();
expect(schema.safeParse({ decimalNumber: '11.123456789' }).success).toBeFalsy();
expect(schema.safeParse({ decimalNumber: new Decimal('11.123456789') }).success).toBeFalsy();
expect(schema.safeParse({ decimalNumber: 10 }).success).toBeTruthy();
expect(schema.safeParse({ decimalNumber: '10' }).success).toBeTruthy();
expect(schema.safeParse({ decimalNumber: new Decimal('10') }).success).toBeTruthy();
});
38 changes: 38 additions & 0 deletions tests/regression/test/v2-migrated/issue-665.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { createPolicyTestClient } from '@zenstackhq/testtools';
import { expect, it } from 'vitest';

// TODO: field-level policy support
it.skip('verifies issue 665', async () => {
const db = await createPolicyTestClient(
`
model User {
id Int @id @default(autoincrement())
admin Boolean @default(false)
username String @unique @allow("all", auth() == this) @allow("all", auth().admin)
password String @password @default("") @allow("all", auth() == this) @allow("all", auth().admin)
firstName String @default("")
lastName String @default("")

@@allow('all', true)
}
`,
);

await db.$unuseAll().user.create({ data: { id: 1, username: 'test', password: 'test', admin: true } });

// admin
let r = await db.$setAuth({ id: 1, admin: true }).user.findFirst();
expect(r.username).toEqual('test');

// owner
r = await db.$setAuth({ id: 1 }).user.findFirst();
expect(r.username).toEqual('test');

// anonymous
r = await db.$setAuth({ id: 0 }).user.findFirst();
expect(r.username).toBeUndefined();

// non-owner
r = await db.$setAuth({ id: 2 }).user.findFirst();
expect(r.username).toBeUndefined();
});
14 changes: 14 additions & 0 deletions tests/regression/test/v2-migrated/issue-674.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { loadSchema } from '@zenstackhq/testtools';
import { it } from 'vitest';

it('verifies issue 674', async () => {
await loadSchema(
`
model Foo {
id Int @id
}

enum MyUnUsedEnum { ABC CDE @@map('my_unused_enum') }
`,
);
});
71 changes: 71 additions & 0 deletions tests/regression/test/v2-migrated/issue-689.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { createPolicyTestClient } from '@zenstackhq/testtools';
import { expect, it } from 'vitest';

it('verifies issue 689', async () => {
const db = await createPolicyTestClient(
`
model UserRole {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId Int
role String

@@allow('all', true)
}

model User {
id Int @id @default(autoincrement())
userRole UserRole[]
deleted Boolean @default(false)

@@allow('create,read', true)
@@allow('read', auth() == this)
@@allow('read', userRole?[user == auth() && 'Admin' == role])
@@allow('read', userRole?[user == auth()])
}
`,
);

const rawDb = db.$unuseAll();

await rawDb.user.create({
data: {
id: 1,
userRole: {
create: [
{ id: 1, role: 'Admin' },
{ id: 2, role: 'Student' },
],
},
},
});

await rawDb.user.create({
data: {
id: 2,
userRole: {
connect: { id: 1 },
},
},
});

const c1 = await rawDb.user.count({
where: {
userRole: {
some: { role: 'Student' },
},
NOT: { deleted: true },
},
});

const c2 = await db.user.count({
where: {
userRole: {
some: { role: 'Student' },
},
NOT: { deleted: true },
},
});

expect(c1).toEqual(c2);
});
26 changes: 26 additions & 0 deletions tests/regression/test/v2-migrated/issue-703.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { createPolicyTestClient } from '@zenstackhq/testtools';
import { it } from 'vitest';

// TODO: field-level policy support
it.skip('verifies issue 703', async () => {
await createPolicyTestClient(
`
model User {
id Int @id @default(autoincrement())
name String?
admin Boolean @default(false)

companiesWorkedFor Company[]

username String @unique @allow("all", auth() == this) @allow('read', companiesWorkedFor?[owner == auth()]) @allow("all", auth().admin)
}

model Company {
id Int @id @default(autoincrement())
name String?
owner User @relation(fields: [ownerId], references: [id])
ownerId Int
}
`,
);
});
Loading