Skip to content

Commit 65a10a4

Browse files
fix: ensure all objects, files, and history are hard deleted on org cascade delete
Signed-off-by: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com>
1 parent b80c9bc commit 65a10a4

20 files changed

Lines changed: 3351 additions & 29 deletions

.task/checksum/generate-ent-smart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
8f951e1a0020299ee9450a72fdfa0456
1+
2d307e5e6ca1c7bab36a399e0475c471
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
f0f7821ffee1b805d010e873c4106a75
1+
5669f9c138c1b8e0909f504ac0877f3b
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
673005be2e9496ae3902b1e0f281124f
1+
ea6b375ad70ff3d16b671f9c17e8f609
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
001a87c385f673386bb8cd60ec25c1b4a6bc53b30912f6ecb2eef46289b08d62
1+
f3c3132eb648cc67fca2b288f3650ab193b68a70cb72df25abceda4241420697
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
ac6af5b3c4a1d30cefe12c7f70dc5f2ec7cce30d4e4d3cf78c3c6ba6b9461e3d
1+
92f81d84248650b5231e1cd2cc1e05f36d89cee66fa2e329179464a08af2789c

internal/ent/generate/entc.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,14 @@ func getEntHistoryGqlExtension() *entgql.Extension {
273273
return gqlExt
274274
}
275275

276+
// skipper defines what will bypass history logs, this includes
277+
// purge history on cascade delete as well as
278+
// if the caller has the cap to bypass audit logs, give to the integration caller
276279
const skipper = `
280+
if PurgeHistoryEnabled(ctx) {
281+
return true
282+
}
283+
277284
caller, _ := auth.CallerFromContext(ctx)
278285
279286
return caller.HasInLineage(auth.CapBypassAuditLog)

internal/ent/generate/templates/ent/edge_cleanup.tmpl

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,32 @@ import (
3131
{{- range $edge := $node.Edges }}
3232
{{/* if the edge has our custom annotation applied */}}
3333
{{- if $annotation := $edge.Annotations.OPENLANE_CASCADE }}
34+
{{/* schemas that opt out of history tracking have no history table to purge */}}
35+
{{- $trackedHistory := or ( not $edge.Type.Annotations.History ) (eq $edge.Type.Annotations.History.Exclude false) }}
3436
{{/* use the client to delete records where the edge schema has a field (provided by the annotation) containing the ID provided by the func */}}
3537
{{/* special case for child fields */}}
3638
{{- if eq $annotation.Field "Child" }}
3739
if exists, err := FromContext(ctx).{{ $edge.Type.Name }}.Query().Where({{ $edge.Type.Name | lower }}.HasParentWith({{ $edge.Type.Name | lower }}.ID(id))).Exist(ctx); err == nil && exists {
40+
{{- if $trackedHistory }}
41+
{{/* the history rows are matched by a sub-select on the records being removed, so this has to run first */}}
42+
if err := Purge{{ $edge.Type.Name }}History(ctx, {{ $edge.Type.Name | lower }}.HasParentWith({{ $edge.Type.Name | lower }}.ID(id))); err != nil {
43+
return err
44+
}
45+
{{- end }}
46+
3847
if organizationCount, err := FromContext(ctx).{{ $edge.Type.Name }}.Delete().Where({{ $edge.Type.Name | lower }}.HasParentWith({{ $edge.Type.Name | lower }}.ID(id))).Exec(ctx); err != nil {
3948
logx.FromContext(ctx).Error().Err(err).Int("count", {{ $edge.Type.Name | lower }}Count).Msg("error deleting child {{ $edge.Type.Name | lower }}")
4049
return err
4150
}
4251
}
4352
{{- else if hasSuffix $annotation.Field "ID" }}
4453
if exists, err := FromContext(ctx).{{ $edge.Type.Name }}.Query().Where(({{ $edge.Type.Name | lower }}.{{ $annotation.Field }}(id))).Exist(ctx); err == nil && exists {
54+
{{- if $trackedHistory }}
55+
if err := Purge{{ $edge.Type.Name }}History(ctx, {{ $edge.Type.Name | lower }}.{{ $annotation.Field }}(id)); err != nil {
56+
return err
57+
}
58+
{{- end }}
59+
4560
if {{ $edge.Type.Name | lower }}Count, err := FromContext(ctx).{{ $edge.Type.Name }}.Delete().Where({{ $edge.Type.Name | lower }}.{{ $annotation.Field }}(id)).Exec(ctx); err != nil {
4661
logx.FromContext(ctx).Error().Err(err).Int("count", {{ $edge.Type.Name | lower }}Count).Msg("error deleting {{ $edge.Type.Name | lower }}")
4762
return err
@@ -63,6 +78,12 @@ import (
6378
}
6479
}
6580
if exists, err := FromContext(ctx).{{ $edge.Type.Name }}.Query().Where(({{ $edge.Type.Name | lower }}.Has{{ $annotation.Field }}With({{ $node.Name | lower }}.ID(id)))).Exist(ctx); err == nil && exists {
81+
{{- if $trackedHistory }}
82+
if err := Purge{{ $edge.Type.Name }}History(ctx, {{ $edge.Type.Name | lower }}.Has{{ $annotation.Field }}With({{ $node.Name | lower }}.ID(id))); err != nil {
83+
return err
84+
}
85+
{{- end }}
86+
6687
if {{ $edge.Type.Name | lower }}Count, err := FromContext(ctx).{{ $edge.Type.Name }}.Delete().Where({{ $edge.Type.Name | lower }}.Has{{ $annotation.Field }}With({{ $node.Name | lower }}.ID(id))).Exec(ctx); err != nil {
6788
logx.FromContext(ctx).Error().Err(err).Int("count", {{ $edge.Type.Name | lower }}Count).Msg("error deleting {{ $edge.Type.Name | lower }}")
6889
return err
@@ -74,8 +95,21 @@ import (
7495
{{- if $annotation := $node.Annotations.OPENLANE_CASCADE_THROUGH }}
7596
{{- range $schema := $annotation.Schemas }}
7697
{{- $field := $schema.Through }}
98+
{{/* the through schema is named by the annotation, so its node has to be looked up to know whether it tracks history */}}
99+
{{- $throughHistory := false }}
100+
{{- range $through := $.Nodes }}
101+
{{- if eq $through.Name $field }}
102+
{{- $throughHistory = or ( not $through.Annotations.History ) (eq $through.Annotations.History.Exclude false) }}
103+
{{- end }}
104+
{{- end }}
77105
{{/* use the client to delete records where the edge has a field and a through schema (provided by the annotation) containing the ID provided by the func */}}
78106
if exists, err := FromContext(ctx).{{ $field }}.Query().Where(({{ $field | lower }}.Has{{ $schema.Field }}With({{ $schema.Field | lower }}.ID(id)))).Exist(ctx); err == nil && exists {
107+
{{- if $throughHistory }}
108+
if err := Purge{{ $field }}History(ctx, {{ $field | lower }}.Has{{ $schema.Field }}With({{ $schema.Field | lower }}.ID(id))); err != nil {
109+
return err
110+
}
111+
{{- end }}
112+
79113
if {{ $field | lower }}Count, err := FromContext(ctx).{{ $field }}.Delete().Where({{ $field | lower }}.Has{{ $schema.Field }}With({{ $schema.Field | lower }}.ID(id))).Exec(ctx); err != nil {
80114
logx.FromContext(ctx).Error().Err(err).Int("count", {{ $field | lower }}Count).Msg("error deleting {{ $field | lower }}")
81115
return err
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
{{/* The line below tells Intellij/GoLand to enable the autocompletion based on the *gen.Graph type. */}}
2+
{{/* gotype: entgo.io/ent/entc/gen.Graph */}}
3+
4+
{{ define "history_cleanup" }}
5+
6+
{{/* Add the base header for the generated file */}}
7+
{{ $pkg := base $.Config.Package }}
8+
{{ template "header" $ }}
9+
10+
import (
11+
"entgo.io/ent/dialect/sql"
12+
"github.com/theopenlane/core/internal/ent/generated/predicate"
13+
"github.com/theopenlane/core/internal/ent/hooks/contextx"
14+
"github.com/theopenlane/core/pkg/logx"
15+
"github.com/theopenlane/entx/history"
16+
{{- range $node := $.Nodes }}
17+
{{- if or ( not $node.Annotations.History ) (eq $node.Annotations.History.Exclude false) }}
18+
"github.com/theopenlane/core/internal/ent/historygenerated/{{ $node.Name | lower }}history"
19+
{{- end }}
20+
{{- end }}
21+
)
22+
23+
// PurgeHistoryEnabled reports whether the current operation is purging history rather than recording
24+
// it. The generated history hooks use this as their skipper, a delete that is purging history must
25+
// not write a new row recording that delete, it would undo the purge that ran just before it
26+
func PurgeHistoryEnabled(ctx context.Context) bool {
27+
return contextx.PurgeHistoryEnabled(ctx)
28+
}
29+
30+
{{- range $node := $.Nodes }}
31+
{{/* only schemas that actually track history have a history table to purge */}}
32+
{{- if or ( not $node.Annotations.History ) (eq $node.Annotations.History.Exclude false) }}
33+
{{- $pkgName := $node.Name | lower }}
34+
35+
{{/* the ids are matched with a sub-select instead of being loaded into memory, an
36+
organization can own hundreds of thousands of records and materializing every id
37+
to build an IN list would blow up both memory and the statement size */}}
38+
// Purge{{ $node.Name }}History removes the history rows belonging to every {{ $pkgName }} matching
39+
// the given predicates. It is a no-op unless the context opts in via contextx.WithPurgeHistory, so
40+
// deletes that should keep their audit trail are unaffected.
41+
// This has to run before the {{ $pkgName }} records themselves are deleted, the rows are matched
42+
// with a sub-select against the {{ $pkgName }} table rather than by loading the ids
43+
func Purge{{ $node.Name }}History(ctx context.Context, ps ...predicate.{{ $node.Name }}) error {
44+
if !contextx.PurgeHistoryEnabled(ctx) {
45+
return nil
46+
}
47+
48+
client := FromContext(ctx)
49+
if client == nil || client.HistoryClient == nil {
50+
return nil
51+
}
52+
53+
refs := sql.Select({{ $pkgName }}.FieldID).From(sql.Table({{ $pkgName }}.Table))
54+
for _, p := range ps {
55+
p(refs)
56+
}
57+
58+
if _, err := client.HistoryClient.{{ $node.Name }}History.Delete().Where(func(s *sql.Selector) {
59+
s.Where(sql.In({{ $pkgName }}history.FieldRef, refs))
60+
}).Exec(history.WithContext(ctx)); err != nil {
61+
logx.FromContext(ctx).Error().Err(err).Msg("error purging {{ $pkgName }} history")
62+
63+
return err
64+
}
65+
66+
return nil
67+
}
68+
{{- end }}
69+
{{- end }}
70+
{{ end }}

0 commit comments

Comments
 (0)