-
Notifications
You must be signed in to change notification settings - Fork 0
feat(edge): filters for edge list — --direction, --name, --to, --from (#337)
#340
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ package edge | |
|
|
||
| import ( | ||
| "io" | ||
| "strings" | ||
|
|
||
| "github.com/spf13/cobra" | ||
|
|
||
|
|
@@ -39,16 +40,119 @@ func edgeListRow(id, dir string, name *string, loc string, isRunnable *bool, pri | |
| } | ||
| } | ||
|
|
||
| // edgeFilter narrows a node's edges. The zero value matches everything, so an | ||
| // unfiltered `edge list` is unchanged. | ||
| // | ||
| // To and From are directional by construction: an edge TO x is one this node | ||
| // points at (outgoing), an edge FROM x is one pointing at this node (incoming). | ||
| // Each matches the far endpoint by loc OR id, since both are printed in --json | ||
| // and either is what a caller has to hand. | ||
| type edgeFilter struct { | ||
| Direction string // "" | incoming | outgoing | ||
| Name string // case-insensitive substring of the edge label | ||
| To string // far endpoint loc or id, outgoing only | ||
| From string // far endpoint loc or id, incoming only | ||
| } | ||
|
|
||
| func (fl edgeFilter) match(e edgeListDTO) bool { | ||
| fl.Name, fl.To, fl.From = strings.TrimSpace(fl.Name), strings.TrimSpace(fl.To), strings.TrimSpace(fl.From) | ||
| if fl.Direction != "" && e.Direction != fl.Direction { | ||
| return false | ||
| } | ||
| if fl.Name != "" && !strings.Contains(strings.ToLower(e.Name), strings.ToLower(fl.Name)) { | ||
| return false | ||
| } | ||
| if fl.To != "" && (e.Direction != "outgoing" || !endpointIs(e, fl.To)) { | ||
| return false | ||
| } | ||
| if fl.From != "" && (e.Direction != "incoming" || !endpointIs(e, fl.From)) { | ||
| return false | ||
| } | ||
| return true | ||
| } | ||
|
|
||
| // endpointIs reports whether the far endpoint is ref, by loc or by id. A | ||
| // redacted endpoint (#781) carries neither, so it never matches — correct: the | ||
| // caller asked for a specific node and this edge can't be shown to be it. | ||
| func endpointIs(e edgeListDTO, ref string) bool { | ||
| return (e.OtherLoc != "" && e.OtherLoc == ref) || (e.OtherID != "" && e.OtherID == ref) | ||
| } | ||
|
|
||
| // filterEdges applies fl, always returning a non-nil slice so --json renders | ||
| // [] rather than null. | ||
| func filterEdges(edges []edgeListDTO, fl edgeFilter) []edgeListDTO { | ||
| out := []edgeListDTO{} | ||
| for _, e := range edges { | ||
| if fl.match(e) { | ||
| out = append(out, e) | ||
| } | ||
| } | ||
| return out | ||
| } | ||
|
Comment on lines
+83
to
+91
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd rather not, and I think the numbers support leaving it.
The short-circuit would also change the function's contract in a way that isn't free: returning the caller's slice on the zero-value filter makes So the trade reads as: a guaranteed-copy contract that's trivial to reason about, versus saving one allocation on a path that just did a network read. Happy to add it if you'd still prefer — it's three lines — but I don't think it earns the ambiguity. |
||
|
|
||
| // validate rejects filters that can only ever match nothing, or that would | ||
| // silently widen the result instead of narrowing it. | ||
| // | ||
| // provided names the flags the user actually passed, which an empty value | ||
| // alone can't tell us: `--to "$TARGET"` with TARGET unset reaches us as an | ||
| // explicitly-supplied "". Treating that as "no filter" returned EVERY edge — | ||
| // the opposite of what the caller asked for, and silent. It is a usage error. | ||
| func (fl edgeFilter) validate(provided map[string]bool) error { | ||
| for flag, val := range map[string]string{ | ||
| "direction": fl.Direction, "name": fl.Name, "to": fl.To, "from": fl.From, | ||
| } { | ||
| if provided[flag] && strings.TrimSpace(val) == "" { | ||
| return exitcode.Newf(exitcode.Usage, | ||
| "--%s was given an empty value — omit the flag to not filter on it (a shell variable that expanded to nothing?)", flag) | ||
| } | ||
| } | ||
| switch fl.Direction { | ||
| case "", "incoming", "outgoing": | ||
| default: | ||
| return exitcode.Newf(exitcode.Usage, "--direction %q must be \"incoming\" or \"outgoing\"", fl.Direction) | ||
| } | ||
| if fl.To != "" && fl.From != "" { | ||
| return exitcode.Newf(exitcode.Usage, "--to and --from are mutually exclusive — an edge has one far endpoint, and the two name opposite directions") | ||
| } | ||
| if fl.To != "" && fl.Direction == "incoming" { | ||
| return exitcode.Newf(exitcode.Usage, "--to selects outgoing edges, so it cannot be combined with --direction incoming") | ||
| } | ||
| if fl.From != "" && fl.Direction == "outgoing" { | ||
| return exitcode.Newf(exitcode.Usage, "--from selects incoming edges, so it cannot be combined with --direction outgoing") | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func newCmdLs(f *cmdutil.Factory) *cobra.Command { | ||
| var memory string | ||
| var fl edgeFilter | ||
| cmd := &cobra.Command{ | ||
| Use: "list <node-urn> | <loc> -m <memory>", | ||
| Aliases: []string{"ls"}, | ||
| Short: "List a node's edges (both directions)", | ||
| Long: `List a node's edges, outgoing and incoming. | ||
|
|
||
| Without filters every edge is listed. --direction, --name, --to and --from | ||
| narrow that client-side; the row shape is unchanged, so a filtered --json | ||
| run is a subset of an unfiltered one. | ||
|
|
||
| --to and --from are directional: --to names a node this one points AT | ||
| (outgoing), --from a node pointing at THIS one (incoming). Either takes the | ||
| far endpoint's loc or its id.`, | ||
| Example: ` hadron edge list hadronmemory.com::dev::start-here | ||
| hadron edge list start-here -m hadronmemory.com::dev`, | ||
| hadron edge list start-here -m hadronmemory.com::dev | ||
| hadron edge list review -m micromentor.org::mmdata --direction incoming | ||
| hadron edge list preflight -m hadronmemory.com::dev --name routes-to | ||
| hadron edge list preflight -m hadronmemory.com::dev --to findings:prisma-upsert-not-race-safe`, | ||
| Args: cobra.ExactArgs(1), | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| provided := map[string]bool{} | ||
| for _, n := range []string{"direction", "name", "to", "from"} { | ||
| provided[n] = cmd.Flags().Changed(n) | ||
| } | ||
| if err := fl.validate(provided); err != nil { | ||
| return err | ||
| } | ||
| client, err := f.GraphQLClient() | ||
| if err != nil { | ||
| return err | ||
|
|
@@ -84,6 +188,8 @@ func newCmdLs(f *cmdutil.Factory) *cobra.Command { | |
| edges = append(edges, edgeListRow(e.Id, "incoming", e.Name, e.Loc, e.IsRunnable, e.Priority, sid, sloc)) | ||
| } | ||
|
|
||
| edges = filterEdges(edges, fl) | ||
|
|
||
| return output.Write(f.IOStreams, f.JSON, edges, func(w io.Writer) error { | ||
| t := output.NewTable(w, "DIR", "REL", "NODE", "EDGE-ID") | ||
| for _, e := range edges { | ||
|
|
@@ -102,5 +208,9 @@ func newCmdLs(f *cmdutil.Factory) *cobra.Command { | |
| }, | ||
| } | ||
| cmd.Flags().StringVarP(&memory, "memory", "m", "", "memory (org::memory) to resolve a bare <loc> against") | ||
| cmd.Flags().StringVar(&fl.Direction, "direction", "", `only "incoming" or only "outgoing" edges`) | ||
| cmd.Flags().StringVar(&fl.Name, "name", "", "only edges whose label contains this (case-insensitive)") | ||
| cmd.Flags().StringVar(&fl.To, "to", "", "only outgoing edges whose target is this loc or id") | ||
| cmd.Flags().StringVar(&fl.From, "from", "", "only incoming edges whose source is this loc or id") | ||
| return cmd | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| package edge | ||
|
|
||
| import ( | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/hadron-memory/hadron-cli/internal/exitcode" | ||
| ) | ||
|
|
||
| // A node's edges as `edge list` builds them: two out, two in, plus one whose | ||
| // far endpoint the server redacted (#781). | ||
| func sampleEdges() []edgeListDTO { | ||
| return []edgeListDTO{ | ||
| {ID: "e1", Direction: "outgoing", Name: "routes-to", OtherID: "n1", OtherLoc: "findings:race"}, | ||
| {ID: "e2", Direction: "outgoing", Name: "to diagnose a slow query", OtherID: "n2", OtherLoc: "findings:slow"}, | ||
| {ID: "e3", Direction: "incoming", Name: "Applies when a resolver changes", OtherID: "n3", OtherLoc: "review:thin-resolver"}, | ||
| {ID: "e4", Direction: "incoming", Name: "child-of", OtherID: "n4", OtherLoc: "review:posthog"}, | ||
| {ID: "e5", Direction: "outgoing", Name: "routes-to"}, // redacted endpoint: no loc, no id | ||
| } | ||
| } | ||
|
|
||
| func ids(es []edgeListDTO) []string { | ||
| out := make([]string, 0, len(es)) | ||
| for _, e := range es { | ||
| out = append(out, e.ID) | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| func eq(a, b []string) bool { | ||
| if len(a) != len(b) { | ||
| return false | ||
| } | ||
| for i := range a { | ||
| if a[i] != b[i] { | ||
| return false | ||
| } | ||
| } | ||
| return true | ||
| } | ||
|
|
||
| func TestFilterEdges(t *testing.T) { | ||
| cases := []struct { | ||
| name string | ||
| fl edgeFilter | ||
| want []string | ||
| }{ | ||
| {"zero value matches everything", edgeFilter{}, []string{"e1", "e2", "e3", "e4", "e5"}}, | ||
| {"direction outgoing", edgeFilter{Direction: "outgoing"}, []string{"e1", "e2", "e5"}}, | ||
| {"direction incoming", edgeFilter{Direction: "incoming"}, []string{"e3", "e4"}}, | ||
| {"name substring", edgeFilter{Name: "routes-to"}, []string{"e1", "e5"}}, | ||
| {"name is case-insensitive", edgeFilter{Name: "APPLIES WHEN"}, []string{"e3"}}, | ||
| {"name is a substring, not a prefix", edgeFilter{Name: "resolver"}, []string{"e3"}}, | ||
| {"to, by loc", edgeFilter{To: "findings:slow"}, []string{"e2"}}, | ||
| {"to, by id", edgeFilter{To: "n2"}, []string{"e2"}}, | ||
| {"from, by loc", edgeFilter{From: "review:posthog"}, []string{"e4"}}, | ||
| {"from, by id", edgeFilter{From: "n3"}, []string{"e3"}}, | ||
| // --to is outgoing-only: an incoming edge from that node must not match. | ||
| {"to does not match an incoming endpoint", edgeFilter{To: "review:posthog"}, []string{}}, | ||
| {"from does not match an outgoing endpoint", edgeFilter{From: "findings:slow"}, []string{}}, | ||
| {"combined", edgeFilter{Direction: "outgoing", Name: "routes-to"}, []string{"e1", "e5"}}, | ||
| {"no match", edgeFilter{Name: "nothing-like-this"}, []string{}}, | ||
| } | ||
| for _, tc := range cases { | ||
| got := ids(filterEdges(sampleEdges(), tc.fl)) | ||
| if !eq(got, tc.want) { | ||
| t.Errorf("%s:\n got %v\n want %v", tc.name, got, tc.want) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // A redacted endpoint carries neither loc nor id, so it must never satisfy a | ||
| // request for a specific node — matching on "" would make every such edge look | ||
| // like the one asked for. | ||
| func TestRedactedEndpointNeverMatches(t *testing.T) { | ||
| redacted := edgeListDTO{ID: "e5", Direction: "outgoing", Name: "routes-to"} | ||
| if endpointIs(redacted, "") { | ||
| t.Error(`an empty ref must not match a redacted endpoint`) | ||
| } | ||
| if got := filterEdges([]edgeListDTO{redacted}, edgeFilter{To: "anything"}); len(got) != 0 { | ||
| t.Errorf("redacted endpoint matched --to: %v", ids(got)) | ||
| } | ||
| // It still shows up unfiltered, and under a name filter. | ||
| if got := filterEdges([]edgeListDTO{redacted}, edgeFilter{Name: "routes-to"}); len(got) != 1 { | ||
| t.Error("a redacted endpoint should still be listable by label") | ||
| } | ||
| } | ||
|
|
||
| // filterEdges must return a non-nil slice so --json renders [] rather than null. | ||
| func TestFilterEdgesNeverReturnsNil(t *testing.T) { | ||
| if got := filterEdges(nil, edgeFilter{}); got == nil { | ||
| t.Error("nil input produced a nil slice") | ||
| } | ||
| if got := filterEdges(sampleEdges(), edgeFilter{Name: "no-match"}); got == nil { | ||
| t.Error("an empty result produced a nil slice") | ||
| } | ||
| } | ||
|
|
||
| // Combinations that can only ever match nothing are rejected up front, rather | ||
| // than returning an empty list that reads like "no such edges". | ||
| func TestEdgeFilterValidate(t *testing.T) { | ||
| ok := []edgeFilter{ | ||
| {}, | ||
| {Direction: "incoming"}, | ||
| {Direction: "outgoing"}, | ||
| {To: "x"}, | ||
| {From: "x"}, | ||
| {Direction: "outgoing", To: "x"}, | ||
| {Direction: "incoming", From: "x"}, | ||
| {Name: "x", Direction: "incoming"}, | ||
| } | ||
| for _, fl := range ok { | ||
| if err := fl.validate(nil); err != nil { | ||
| t.Errorf("%+v should be valid, got %v", fl, err) | ||
| } | ||
| } | ||
|
|
||
| bad := []struct { | ||
| fl edgeFilter | ||
| want string | ||
| }{ | ||
| {edgeFilter{Direction: "both"}, "--direction"}, | ||
| {edgeFilter{Direction: "Incoming"}, "--direction"}, // case-sensitive by design | ||
| {edgeFilter{To: "x", From: "y"}, "mutually exclusive"}, | ||
| {edgeFilter{To: "x", Direction: "incoming"}, "--to selects outgoing"}, | ||
| {edgeFilter{From: "x", Direction: "outgoing"}, "--from selects incoming"}, | ||
| } | ||
| for _, tc := range bad { | ||
| err := tc.fl.validate(nil) | ||
| if err == nil { | ||
| t.Errorf("%+v should be rejected", tc.fl) | ||
| continue | ||
| } | ||
| if got := exitcode.FromError(err); got != exitcode.Usage { | ||
| t.Errorf("%+v should be a usage error, got %d", tc.fl, got) | ||
| } | ||
| if !strings.Contains(err.Error(), tc.want) { | ||
| t.Errorf("%+v: error should mention %q, got %q", tc.fl, tc.want, err.Error()) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // An explicitly-supplied empty value is a usage error, not "no filter". | ||
| // `--to "$TARGET"` with TARGET unset reaches us as "", and treating that as | ||
| // absent returned EVERY edge — the opposite of what the caller asked for. | ||
| func TestEmptyFilterValueIsRejected(t *testing.T) { | ||
| for _, flag := range []string{"direction", "name", "to", "from"} { | ||
| fl := edgeFilter{} | ||
| provided := map[string]bool{flag: true} | ||
| err := fl.validate(provided) | ||
| if err == nil { | ||
| t.Errorf("--%s \"\" should be rejected", flag) | ||
| continue | ||
| } | ||
| if got := exitcode.FromError(err); got != exitcode.Usage { | ||
| t.Errorf("--%s \"\": expected a usage error, got %d", flag, got) | ||
| } | ||
| if !strings.Contains(err.Error(), "--"+flag) { | ||
| t.Errorf("--%s: the error should name the flag, got %q", flag, err.Error()) | ||
| } | ||
| } | ||
|
|
||
| // Whitespace-only counts as empty. | ||
| if err := (edgeFilter{To: " "}).validate(map[string]bool{"to": true}); err == nil { | ||
| t.Error(`--to " " should be rejected`) | ||
| } | ||
| // A flag NOT passed with an empty value is simply no filter. | ||
| if err := (edgeFilter{}).validate(map[string]bool{}); err != nil { | ||
| t.Errorf("an unset filter should be valid, got %v", err) | ||
| } | ||
| // A real value passed is fine. | ||
| if err := (edgeFilter{To: "findings:x"}).validate(map[string]bool{"to": true}); err != nil { | ||
| t.Errorf("a real --to should be valid, got %v", err) | ||
| } | ||
| } | ||
|
|
||
| // Filter values are trimmed on apply, so a ref padded by shell quoting matches. | ||
| func TestFilterValuesAreTrimmed(t *testing.T) { | ||
| got := ids(filterEdges(sampleEdges(), edgeFilter{To: " findings:slow "})) | ||
| if len(got) != 1 || got[0] != "e2" { | ||
| t.Errorf("a padded --to should still match: %v", got) | ||
| } | ||
| if got := ids(filterEdges(sampleEdges(), edgeFilter{Name: " routes-to "})); len(got) != 2 { | ||
| t.Errorf("a padded --name should still match: %v", got) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a shell variable expands to empty, such as
--to "$TARGET"withTARGETunset, Cobra records an explicitly supplied empty value but these checks treat it as if the flag were absent. The command consequently returns every edge—including redacted endpoints—instead of rejecting the invalid reference or returning no matches;--from ""behaves the same way. Check whether each flag was supplied and reject an empty value before filtering.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed and fixed in 4c2bef4. I reproduced it before changing anything:
Returning everything for a filter the caller meant to narrow with is the worst shape this could take, and it's silent.
validatenow takes the set of flags cobra saw asChanged, so an explicitly-supplied empty (or whitespace-only) value on any of--direction/--name/--to/--fromis a usage error:I passed a
provided map[string]boolrather than the pflagFlagSetso the rule stays unit-testable without pulling cobra into the pure layer. Covered at both levels:TestEmptyFilterValueIsRejectedfor the rule,TestEdgeListRejectsEmptyFilterValueend-to-end for all four flags.While there I also trimmed filter values on apply, so
--to " findings:x "from shell quoting still matches —TestFilterValuesAreTrimmed.