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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **ORM**: Depends on Kysely, Zod, and various utility libraries
- **CLI**: Depends on language package, Commander.js, and Prisma (for migrations)
- **Language**: Uses Langium for grammar parsing and AST generation
- **Database Support**: SQLite (better-sqlite3) and PostgreSQL (pg) only
- **Database Support**: SQLite (better-sqlite3), PostgreSQL (pg), and MySQL

### Testing Strategy

Expand Down
6 changes: 6 additions & 0 deletions packages/language/res/stdlib.zmodel
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,12 @@ attribute @@@completionHint(_ values: String[])
*/
attribute @@@once()

/**
* Indicates that a field-level attribute can only be applied to a single field within a model
* (including fields inherited from base models and mixins).
*/
attribute @@@onceInModel()

/**
* Defines a single-field ID on the model.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from '../generated/ast';
import {
getAllAttributes,
getAllFields,
getAttributeArg,
getContainingDataModel,
getDataSourceProvider,
Expand Down Expand Up @@ -81,6 +82,7 @@ export default class AttributeApplicationValidator implements AstValidator<Attri

this.checkDeprecation(attr, accept);
this.checkDuplicatedAttributes(attr, accept, contextDataModel);
this.checkOnceInModel(attr, accept);
Comment thread
ymc9 marked this conversation as resolved.

const filledParams = new Set<AttributeParam>();

Expand Down Expand Up @@ -163,6 +165,31 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
}
}

private checkOnceInModel(attr: AttributeApplication, accept: ValidationAcceptor) {
const attrDecl = attr.decl.ref;
if (!attrDecl?.attributes.some((a) => a.decl.ref?.name === '@@@onceInModel')) {
return;
}

// only meaningful for field-level attributes within a data model
const field = attr.$container;
if (!isDataField(field)) {
return;
}
const dataModel = getContainingDataModel(attr);
if (!dataModel) {
return;
}

// count distinct fields (including inherited) carrying this attribute
const fieldsWithAttr = getAllFields(dataModel).filter((f) =>
f.attributes.some((a) => a.decl.ref === attrDecl),
);
if (fieldsWithAttr.length > 1) {
accept('error', `Attribute "${attrDecl.name}" can only be applied to one field per model`, { node: attr });
}
}

// TODO: design a way to let plugin register validation
@check('@@allow')
@check('@@deny')
Expand Down
61 changes: 61 additions & 0 deletions packages/language/test/attribute-application.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,67 @@ describe('Attribute application validation tests', () => {
});
});

describe('Field-level @@@onceInModel attribute', () => {
it('accepts a single field carrying the attribute', async () => {
await loadSchema(`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}

attribute @softDelete() @@@targetField([DateTimeField]) @@@onceInModel

model Foo {
id Int @id @default(autoincrement())
deletedAt DateTime? @softDelete
}
`);
});

it('rejects two fields in the same model carrying the attribute', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}

attribute @softDelete() @@@targetField([DateTimeField]) @@@onceInModel

model Foo {
id Int @id @default(autoincrement())
deletedAt DateTime? @softDelete
removedAt DateTime? @softDelete
}
`,
/Attribute "@softDelete" can only be applied to one field per model/,
);
});

it('rejects when the attribute is inherited from a mixin and also declared locally', async () => {
await loadSchemaWithError(
`
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}

attribute @softDelete() @@@targetField([DateTimeField]) @@@onceInModel

type Base {
deletedAt DateTime? @softDelete
}

model Foo with Base {
id Int @id @default(autoincrement())
removedAt DateTime? @softDelete
}
`,
/Attribute "@softDelete" can only be applied to one field per model/,
);
});
});

it('requires relation and fk to have consistent optionality', async () => {
await loadSchemaWithError(
`
Expand Down
4 changes: 4 additions & 0 deletions packages/plugins/soft-delete/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import config from '@zenstackhq/eslint-config/base.js';

/** @type {import("eslint").Linter.Config} */
export default config;
68 changes: 68 additions & 0 deletions packages/plugins/soft-delete/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"name": "@zenstackhq/plugin-soft-delete",
"displayName": "ZenStack Soft Delete Plugin",
"description": "ZenStack plugin that implements soft-delete by intercepting Kysely queries",
"version": "3.7.2",
"type": "module",
"author": {
"name": "ZenStack Team",
"email": "contact@zenstack.dev"
},
"homepage": "https://zenstack.dev",
"repository": {
"type": "git",
"url": "https://github.com/zenstackhq/zenstack"
},
"license": "MIT",
"scripts": {
"build": "tsc --noEmit && tsdown",
"watch": "tsdown --watch",
"lint": "eslint src --ext ts",
"test": "vitest run",
"pack": "pnpm pack"
},
"keywords": [],
"files": [
"dist",
"plugin.zmodel"
],
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./plugin.zmodel": {
"import": "./plugin.zmodel",
"require": "./plugin.zmodel"
},
"./package.json": {
"import": "./package.json",
"require": "./package.json"
}
},
"dependencies": {
"@zenstackhq/common-helpers": "workspace:*",
"@zenstackhq/orm": "workspace:*",
"ts-pattern": "catalog:"
},
"peerDependencies": {
"kysely": "catalog:"
},
"devDependencies": {
"@types/better-sqlite3": "catalog:",
"@types/node": "catalog:",
"@zenstackhq/eslint-config": "workspace:*",
"@zenstackhq/testtools": "workspace:*",
"@zenstackhq/tsdown-config": "workspace:*",
"@zenstackhq/typescript-config": "workspace:*",
"@zenstackhq/vitest-config": "workspace:*",
"better-sqlite3": "catalog:"
},
"funding": "https://github.com/sponsors/zenstackhq"
}
10 changes: 10 additions & 0 deletions packages/plugins/soft-delete/plugin.zmodel
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Marks a `DateTime` field as the soft-delete indicator for its owning model.
*
* When the soft-delete plugin is installed, reads against the model are automatically filtered
* to exclude rows whose marked field is non-null, and delete operations are rewritten to set
* the marked field to the current timestamp instead of physically removing the row.
*
* A model can have at most one `@deletedAt` field.
*/
attribute @deletedAt() @@@targetField([DateTimeField]) @@@once @@@onceInModel
1 change: 1 addition & 0 deletions packages/plugins/soft-delete/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './plugin';
Loading
Loading