@@ -138,12 +138,13 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf
138138
139139 // #region Post mutation work
140140
141- // post-check: detect policy denial when 0 rows affected (replaces pre-check for update/delete)
142- if ( ! ( result . numAffectedRows ?? 0n ) ) {
143- if ( UpdateQueryNode . is ( node ) ) {
144- await this . postModelLevelCheck ( mutationModel , 'update' , node . where ?. where ?? trueNode ( this . dialect ) , proceed ) ;
145- } else if ( DeleteQueryNode . is ( node ) && ! node . using ) {
146- await this . postModelLevelCheck ( mutationModel , 'delete' , node . where ?. where ?? trueNode ( this . dialect ) , proceed ) ;
141+ // When 0 rows affected, distinguish "row not found" from "row denied by policy"
142+ // Use > 0 negation (not === 0) because numAffectedRows is BigInt in some drivers
143+ if ( ! ( ( result . numAffectedRows ?? 0 ) > 0 ) ) {
144+ if ( DeleteQueryNode . is ( node ) ) {
145+ await this . postMutationZeroRowsCheck ( mutationModel , 'delete' , node . where ?. where , proceed ) ;
146+ } else if ( UpdateQueryNode . is ( node ) ) {
147+ await this . postMutationZeroRowsCheck ( mutationModel , 'update' , node . where ?. where , proceed ) ;
147148 }
148149 }
149150
@@ -258,62 +259,63 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf
258259 }
259260 }
260261
261- // Post-check for model-level update/delete policy enforcement.
262- // Runs only when 0 rows were affected: distinguishes "row not found" from "row denied by policy".
263- // Combines existence check and diagnostic into a single query.
264- private async postModelLevelCheck (
265- mutationModel : string ,
262+ // Checks if any row matching the original WHERE exists without the policy filter.
263+ // Called when numAffectedRows == 0 for UPDATE or DELETE.
264+ // If a row exists but was filtered by policy → throws REJECTED_BY_POLICY with codes.
265+ // If no row matches → returns silently (ORM layer handles "not found").
266+ // Combines existence check and code diagnostics into a single query.
267+ private async postMutationZeroRowsCheck (
268+ model : string ,
266269 operation : 'update' | 'delete' ,
267- userWhere : OperationNode ,
270+ originalWhere : OperationNode | undefined ,
268271 proceed : ProceedKyselyQueryFunction ,
269272 ) {
270- const modelLevelFilter = this . buildPolicyFilter ( mutationModel , undefined , operation ) ;
271- if ( isTrueNode ( modelLevelFilter ) ) {
272- return ;
273- }
273+ if ( this . isManyToManyJoinTable ( model ) ) return ;
274+ if ( this . tryGetConstantPolicy ( model , operation ) === true ) return ;
275+ const codedPolicies = this . getModelPolicies ( model , operation ) . filter ( ( p ) => p . code ) ;
276+ // Skip if no policies carry an error code — nothing to surface.
277+ if ( codedPolicies . length === 0 ) return ;
274278
275- const codedPolicies =
276- this . options . fetchPolicyCodes !== false
277- ? this . getModelPolicies ( mutationModel , operation ) . filter ( ( p ) => p . code )
278- : [ ] ;
279+ const whereCondition = originalWhere ?? trueNode ( this . dialect ) ;
279280
280- // $exists: does the row exist at all (without policy filter)?
281- const existsInner = this . eb
282- . selectFrom ( mutationModel )
281+ const rowExistsInner = this . eb
282+ . selectFrom ( model )
283283 . select ( this . eb . lit ( 1 ) . as ( '_' ) )
284- . where ( ( ) => new ExpressionWrapper ( userWhere ) ) ;
284+ . where ( ( ) => new ExpressionWrapper ( whereCondition ) ) ;
285285
286- const selections : SelectionNode [ ] = [
287- SelectionNode . create (
288- AliasNode . create ( this . eb . exists ( existsInner ) . toOperationNode ( ) , IdentifierNode . create ( '$exists' ) ) ,
289- ) ,
290- ] ;
286+ const fetchCodes = this . options . fetchPolicyCodes !== false ;
287+ const selectedPolicies = fetchCodes ? codedPolicies : [ ] ;
291288
292- // one EXISTS column per coded policy, folded into the same query
293- for ( const [ i , policy ] of codedPolicies . entries ( ) ) {
294- const condition = this . compilePolicyCondition ( mutationModel , undefined , operation , policy ) ;
295- const existsCondition = policy . kind === 'allow' ? logicalNot ( this . dialect , condition ) : condition ;
289+ const codeSelections = selectedPolicies . map ( ( policy , i ) => {
290+ const condition = this . compilePolicyCondition ( model , undefined , operation , policy ) ;
291+ const violationCondition = policy . kind === 'allow' ? logicalNot ( this . dialect , condition ) : condition ;
296292 const inner = this . eb
297- . selectFrom ( mutationModel )
293+ . selectFrom ( model )
298294 . select ( this . eb . lit ( 1 ) . as ( '_' ) )
299- . where ( ( ) => new ExpressionWrapper ( conjunction ( this . dialect , [ userWhere , existsCondition ] ) ) ) ;
300- selections . push (
295+ . where ( ( ) => new ExpressionWrapper ( conjunction ( this . dialect , [ whereCondition , violationCondition ] ) ) ) ;
296+ return SelectionNode . create (
297+ AliasNode . create ( this . eb . exists ( inner ) . toOperationNode ( ) , IdentifierNode . create ( `$c${ i } ` ) ) ,
298+ ) ;
299+ } ) ;
300+
301+ const result = await proceed ( {
302+ kind : 'SelectQueryNode' ,
303+ selections : [
301304 SelectionNode . create (
302- AliasNode . create ( this . eb . exists ( inner ) . toOperationNode ( ) , IdentifierNode . create ( `$c ${ i } ` ) ) ,
305+ AliasNode . create ( this . eb . exists ( rowExistsInner ) . toOperationNode ( ) , IdentifierNode . create ( '$exists' ) ) ,
303306 ) ,
304- ) ;
305- }
307+ ...codeSelections ,
308+ ] ,
309+ } satisfies SelectQueryNode ) ;
306310
307- const checkResult = await proceed ( { kind : 'SelectQueryNode' , selections } satisfies SelectQueryNode ) ;
308- const row = checkResult . rows [ 0 ] ?? { } ;
311+ const row = result . rows [ 0 ] ?? { } ;
312+ if ( ! row . $exists ) return ;
309313
310- if ( row . $exists ) {
311- const policyCodes =
312- this . options . fetchPolicyCodes !== false
313- ? codedPolicies . filter ( ( _ , i ) => row [ `$c${ i } ` ] ) . map ( ( p ) => p . code ! )
314- : undefined ;
315- throw createRejectedByPolicyError ( mutationModel , RejectedByPolicyReason . NO_ACCESS , undefined , policyCodes ) ;
316- }
314+ const policyCodes = fetchCodes
315+ ? selectedPolicies . filter ( ( _ , i ) => row [ `$c${ i } ` ] ) . map ( ( p ) => p . code ! )
316+ : undefined ;
317+
318+ throw createRejectedByPolicyError ( model , RejectedByPolicyReason . NO_ACCESS , undefined , policyCodes ) ;
317319 }
318320
319321 private async postUpdateCheck (
0 commit comments