Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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
37 changes: 15 additions & 22 deletions packages/runtime/src/client/crud/operations/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -939,17 +939,8 @@ 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;
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);
}
}

if (Object.keys(finalData).length === 0) {
// nothing to update, return the original filter so that caller can identify the entity
Expand Down Expand Up @@ -1027,6 +1018,19 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
}
}

// fill in automatically updated fields
const scalarFields = Object.values(modelDef.fields)
.filter((f) => !f.relation)
.map((f) => f.name);
if (Object.keys(updateFields).some((f) => scalarFields.includes(f))) {
// if any scalar fields are being updated, also update the `updatedAt` fields
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
if (fieldDef.updatedAt) {
updateFields[fieldName] = this.dialect.transformPrimitive(new Date(), 'DateTime', false);
}
}
}

if (Object.keys(updateFields).length === 0) {
// nothing to update, return the filter so that the caller can identify the entity
return combinedWhere;
Expand Down Expand Up @@ -2073,22 +2077,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
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.

15 changes: 15 additions & 0 deletions tests/e2e/orm/client-api/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
email: user.email,
name: user.name,
});
expect(updated.updatedAt.getTime()).toBeGreaterThan(user.updatedAt.getTime());

Check failure on line 41 in tests/e2e/orm/client-api/update.test.ts

View workflow job for this annotation

GitHub Actions / build-test (20.x, sqlite)

orm/client-api/update.test.ts > Client update tests > toplevel > works with toplevel update

AssertionError: expected 1760057444871 to be greater than 1760057444871 ❯ orm/client-api/update.test.ts:41:49

Check failure on line 41 in tests/e2e/orm/client-api/update.test.ts

View workflow job for this annotation

GitHub Actions / build-test (20.x, postgresql)

orm/client-api/update.test.ts > Client update tests > toplevel > works with toplevel update

AssertionError: expected 1760057443845 to be greater than 1760057443845 ❯ orm/client-api/update.test.ts:41:49

// id as filter
updated = await client.user.update({
Expand Down Expand Up @@ -114,6 +114,21 @@
).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
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
Loading