Skip to content

Commit 07138d7

Browse files
feat!: move deleted filter to top-level query argument (#479)
* feat!: move deleted filter to top-level query argument Soft-deleted rows are no longer part of Where inputs. List, aggregate, singular, and reverse-relation queries on deletable entities take `deleted: Boolean = false` instead. Skip the cascade-deletion OR in permission EXISTS when the query does not include deleted rows. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(release): use conventionalcommits preset for commit analyzer The angular preset does not parse feat! breaking-change notation, so semantic-release skipped the commit on next. Align commit-analyzer with release-notes-generator and add an explicit breaking release rule. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 269beac commit 07138d7

16 files changed

Lines changed: 390 additions & 190 deletions

File tree

.releaserc

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,15 @@
1010
[
1111
"@semantic-release/commit-analyzer",
1212
{
13-
"preset": "angular",
13+
"preset": "conventionalcommits",
1414
"releaseRules": [
1515
{
1616
"type": "break",
1717
"release": "major"
18+
},
19+
{
20+
"breaking": true,
21+
"release": "major"
1822
}
1923
]
2024
}

docs/docs/10-migration-guides.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,32 @@
11
# Migration Guides
22

3+
## Upgrading to the next major version
4+
5+
### `deleted` is a top-level argument, not a `where` filter
6+
7+
The `deleted` field is no longer part of the auto-generated `Where` input. Instead, queries that operate on deletable entities expose a top-level `deleted: Boolean = false` argument. This applies to plural list queries, aggregate queries, singular `entity(where: …)` queries and reverse-relation list fields.
8+
9+
Semantics of the new argument:
10+
11+
- omitted or `deleted: false` (default): only non-deleted entries.
12+
- `deleted: true`: only deleted entries (requires `DELETE` permission).
13+
- `deleted: null`: both deleted and non-deleted entries (requires `DELETE` permission).
14+
15+
Update affected queries:
16+
17+
```diff
18+
- posts(where: { author: { id: "..." }, deleted: [true] }) { id }
19+
+ posts(where: { author: { id: "..." } }, deleted: true) { id }
20+
21+
- invoices_AGGREGATE(where: { deleted: [false] }) { COUNT }
22+
+ invoices_AGGREGATE(deleted: false) { COUNT }
23+
24+
- posts(where: { deleted: [true, false] }) { id }
25+
+ posts(where: {}, deleted: null) { id }
26+
```
27+
28+
Permission `WHERE` rules can no longer filter on `deleted` via the generated permission `Where` types. The hard-coded `deleted = false` join used by permission chains is unchanged.
29+
330
## Upgrading to v19.0.0
431

532
### Configuration changes

docs/docs/2-models.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ This exposes:
145145

146146
```graphql
147147
query {
148-
invoices_AGGREGATE(where: { deleted: [false] }) {
148+
invoices_AGGREGATE(deleted: false) {
149149
COUNT
150150
amount_SUM
151151
}

src/models/models.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,6 @@ export class Models {
174174
type: 'Boolean',
175175
nonNull: true,
176176
defaultValue: false,
177-
filterable: { default: false },
178177
generated: true,
179178
...(typeof entity.deletable === 'object' && entity.deletable.deleted),
180179
} satisfies BooleanFieldDefinition,

src/permissions/check.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export const applyPermissions = (
3838
query: Knex.QueryBuilder,
3939
action: PermissionAction,
4040
verifiedPermissionStack?: PermissionStack,
41+
includesDeletedRows?: boolean,
4142
): boolean | PermissionStack => {
4243
const permissionStack = getPermissionStack(ctx, type, action);
4344

@@ -79,7 +80,7 @@ export const applyPermissions = (
7980
subQuery,
8081
links,
8182
ctx.knex.raw(`"${tableAlias}".id`),
82-
['READ', 'RESTORE'].includes(action) ? tableAlias : undefined,
83+
['READ', 'RESTORE'].includes(action) && includesDeletedRows ? tableAlias : undefined,
8384
),
8485
),
8586
),

src/resolvers/arguments.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export type NormalizedArguments = {
2525
orderBy?: OrderBy[];
2626
where?: Where;
2727
search?: string;
28+
deleted?: boolean | null;
2829
mine?: boolean;
2930
language?: string;
3031
};

src/resolvers/filters.ts

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ export type FilterNode = {
2121
tableAlias: string;
2222
};
2323

24+
/** True when the query may return soft-deleted rows (`deleted: true` or `deleted: null`). */
25+
export const includesDeletedRows = (deleted: boolean | null | undefined) => deleted === true || deleted === null;
26+
2427
export const applyFilters = async (node: FieldResolverNode, query: Knex.QueryBuilder, joins: Joins) => {
2528
const normalizedArguments = normalizeArguments(node);
2629
// No need for default order by in aggregates
@@ -33,7 +36,7 @@ export const applyFilters = async (node: FieldResolverNode, query: Knex.QueryBui
3336
}
3437
}
3538
}
36-
const { limit, offset, orderBy, where, search } = normalizedArguments;
39+
const { limit, offset, orderBy, where, search, deleted } = normalizedArguments;
3740

3841
await node.ctx.queryHook?.({ model: node.model, query, args: normalizedArguments, ctx: node.ctx });
3942

@@ -58,30 +61,44 @@ export const applyFilters = async (node: FieldResolverNode, query: Knex.QueryBui
5861
}
5962

6063
const ops: QueryBuilderOps = [];
64+
applyDeletedFilter(node, deleted, ops);
6165
applyWhere(node, where, ops, joins);
6266
void apply(query, ops);
6367

6468
if (search) {
6569
void applySearch(node, search, query);
6670
}
6771

68-
return { paginated: normalizedArguments.limit !== undefined || normalizedArguments.offset !== undefined };
72+
return {
73+
paginated: normalizedArguments.limit !== undefined || normalizedArguments.offset !== undefined,
74+
includesDeletedRows: includesDeletedRows(deleted),
75+
};
6976
};
7077

71-
const applyWhere = (node: FilterNode, where: Where | undefined, ops: QueryBuilderOps, joins: Joins) => {
72-
if (node.model.deletable) {
73-
if (!where) {
74-
where = {};
75-
}
76-
if (where.deleted && (!Array.isArray(where.deleted) || where.deleted.some((v) => v))) {
77-
if (!getPermissionStack(node.ctx, node.model.name, 'DELETE')) {
78-
throw new ForbiddenError('You cannot access deleted entries.');
79-
}
80-
} else {
81-
where.deleted = false;
82-
}
78+
const applyDeletedFilter = (node: FilterNode, deleted: boolean | null | undefined, ops: QueryBuilderOps) => {
79+
if (!node.model.deletable) {
80+
return;
81+
}
82+
const effective = deleted === undefined ? false : deleted;
83+
if (effective !== false && !getPermissionStack(node.ctx, node.model.name, 'DELETE')) {
84+
throw new ForbiddenError('You cannot access deleted entries.');
8385
}
86+
if (effective === null) {
87+
return;
88+
}
89+
const column = getColumn(node, 'deleted');
90+
ops.push((query) => query.where({ [column]: effective }));
91+
};
92+
93+
const applyNestedDeletedDefault = (node: FilterNode, ops: QueryBuilderOps) => {
94+
if (!node.model.deletable) {
95+
return;
96+
}
97+
const column = getColumn(node, 'deleted');
98+
ops.push((query) => query.where({ [column]: false }));
99+
};
84100

101+
const applyWhere = (node: FilterNode, where: Where | undefined, ops: QueryBuilderOps, joins: Joins) => {
85102
if (!where) {
86103
return;
87104
}
@@ -139,6 +156,7 @@ const applyWhere = (node: FilterNode, where: Where | undefined, ops: QueryBuilde
139156
};
140157
const subOps: QueryBuilderOps = [];
141158
const subJoins: Joins = [];
159+
applyNestedDeletedDefault(subWhereNode, subOps);
142160
applyWhere(subWhereNode, value as Where, subOps, subJoins);
143161

144162
// TODO: make this work with subtypes
@@ -200,6 +218,7 @@ const applyWhere = (node: FilterNode, where: Where | undefined, ops: QueryBuilde
200218
tableAlias,
201219
};
202220
addJoin(joins, node.tableAlias, subNode.model.name, subNode.tableAlias, relation.field.foreignKey, 'id');
221+
applyNestedDeletedDefault(subNode, ops);
203222
applyWhere(subNode, value as Where, ops, joins);
204223
continue;
205224
}

src/resolvers/resolver.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ const buildQuery = async (
8383
): Promise<{ query: Knex.QueryBuilder; verifiedPermissionStacks: VerifiedPermissionStacks; paginated: boolean }> => {
8484
const query = node.ctx.knex.fromRaw(`"${node.rootModel.name}" as "${node.ctx.aliases.getShort(node.resultAlias)}"`);
8585
const joins: Joins = [];
86-
const { paginated } = await applyFilters(node, query, joins);
86+
const { paginated, includesDeletedRows } = await applyFilters(node, query, joins);
8787
applySelects(node, query, joins);
8888
applyJoins(node.ctx.aliases, query, joins);
8989

@@ -101,6 +101,7 @@ const buildQuery = async (
101101
query,
102102
'READ',
103103
parentVerifiedPermissionStacks?.[alias.split('__').slice(0, -1).join('__')],
104+
includesDeletedRows,
104105
);
105106

106107
if (typeof verifiedPermissionStack !== 'boolean') {

src/schema/generate.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ const pickWhereVariant = (targetModel: EntityModel, exemptedFieldName?: string):
4242
};
4343
};
4444

45+
const deletedArg = (model: EntityModel): Field[] =>
46+
model.deletable ? [{ name: 'deleted', type: 'Boolean', defaultValue: false }] : [];
47+
4548
const buildWhereFields = (
4649
model: EntityModel,
4750
{ isSubWhere = false, exemptedFieldName }: { isSubWhere?: boolean; exemptedFieldName?: string } = {},
@@ -147,6 +150,7 @@ export const generateDefinitions = ({
147150
type: variant.typeName,
148151
nonNull: variant.nonNull,
149152
},
153+
...deletedArg(targetModel),
150154
...(targetModel.fields.some(({ searchable }) => searchable) ? [{ name: 'search', type: 'String' }] : []),
151155
...(targetModel.fields.some(({ orderable }) => orderable)
152156
? [{ name: 'orderBy', type: `${targetModel.name}OrderBy`, list: true }]
@@ -266,16 +270,17 @@ export const generateDefinitions = ({
266270
},
267271
...entities
268272
.filter(({ queriable }) => queriable)
269-
.map(({ name }) => ({
270-
name: typeToField(name),
271-
type: name,
273+
.map((model) => ({
274+
name: typeToField(model.name),
275+
type: model.name,
272276
nonNull: true,
273277
args: [
274278
{
275279
name: 'where',
276-
type: `${name}WhereLookup`,
280+
type: `${model.name}WhereLookup`,
277281
nonNull: true,
278282
},
283+
...deletedArg(model),
279284
],
280285
})),
281286
...entities
@@ -291,6 +296,7 @@ export const generateDefinitions = ({
291296
type: `${model.name}Where`,
292297
nonNull: hasAnyMandatoryFilterableField(model),
293298
},
299+
...deletedArg(model),
294300
...(model.fields.some(({ searchable }) => searchable) ? [{ name: 'search', type: 'String' }] : []),
295301
...(model.fields.some(({ orderable }) => orderable)
296302
? [{ name: 'orderBy', type: `${model.name}OrderBy`, list: true }]
@@ -312,6 +318,7 @@ export const generateDefinitions = ({
312318
type: `${model.name}Where`,
313319
nonNull: hasAnyMandatoryFilterableField(model),
314320
},
321+
...deletedArg(model),
315322
...(model.fields.some(({ searchable }) => searchable) ? [{ name: 'search', type: 'String' }] : []),
316323
],
317324
})),

tests/api/delete.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ describe('delete', () => {
1616
expect(
1717
await request(gql`
1818
query GetAnotherObject {
19-
anotherObjects(where: { id: "${ANOTHER_ID}", deleted: true }) {
19+
anotherObjects(where: { id: "${ANOTHER_ID}" }, deleted: true) {
2020
id
2121
deleted
2222
}

0 commit comments

Comments
 (0)