-
Notifications
You must be signed in to change notification settings - Fork 8
feat (query/v2): Query Builder #75
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
Open
zinic
wants to merge
25
commits into
SpecterOps:main
Choose a base branch
from
zinic:query-v2
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
25 commits
Select commit
Hold shift + click to select a range
ef54601
feat (cypher): port v2 query builder
zinic 94cff72
feat(query/v2): materialize builder parameters
zinic f251be1
fix(query/v2): infer match patterns by query scope
zinic f3f2d0d
feat(query/v2): add typed projection and order helpers
zinic c8324f2
feat(query/v2): add query helper parity
zinic 19d83c0
test(query/v2): cover backend preparation paths
zinic 303e7c5
test(pgsql): exercise translator with v2 builder
zinic 905870d
fix(query/v2): surface helper validation errors
zinic 6348eeb
fix(query/v2): preserve updating clause order
zinic b124c06
feat(query/v2): support scoped pattern aliases
zinic f58829c
test(query/v2): assert backend render output
zinic 15db0fc
fix(pgsql): reject unsupported create translation
zinic 0a7f8a8
fix(query/v2): reject unsupported relationship directions
zinic 86ef9f3
fix(query/v2): validate create qualified expressions
zinic 5af16b9
fix(query/v2): make kind projections scope aware
zinic 53fa1f1
fix(query/v2): ignore projection aliases for match inference
zinic 84129b2
fix(query/v2): validate explicit relationship directions
zinic 94457a5
fix(query/v2): validate scope aliases
zinic d0443eb
fix(query/v2): validate raw projection inputs
zinic c578723
chore(query/v2): remove unused extractor state
zinic d5a3ef4
fix(query/v2): sort property update keys
zinic 4106eab
fix(query/v2): validate raw mutation inputs
zinic f74622d
fix(query/v2): validate alias symbols
zinic 4ac05a8
fix (query/v2): named parameter fixups; preserve projection metadata;…
zinic 5e192b5
fix (query/v2): clean up some poorly supported neo4j constructs
zinic File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,283 @@ | ||
| package v2_test | ||
|
|
||
| import ( | ||
| "context" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/specterops/dawgs/cypher/models/pgsql/translate" | ||
| "github.com/specterops/dawgs/drivers/pg/pgutil" | ||
| "github.com/specterops/dawgs/graph" | ||
| "github.com/specterops/dawgs/query/neo4j" | ||
| v2 "github.com/specterops/dawgs/query/v2" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func testKindMapper(kinds ...graph.Kind) *pgutil.InMemoryKindMapper { | ||
| mapper := pgutil.NewInMemoryKindMapper() | ||
|
|
||
| for _, kind := range kinds { | ||
| mapper.Put(kind) | ||
| } | ||
|
|
||
| return mapper | ||
| } | ||
|
|
||
| func TestBackendParityNeo4jPrepare(t *testing.T) { | ||
| cases := map[string]struct { | ||
| builder v2.QueryBuilder | ||
| expectedCypher string | ||
| expectedParams map[string]any | ||
| }{ | ||
| "node read": { | ||
| builder: v2.New().Where( | ||
| v2.Node().Kinds().Has(graph.StringKind("User")), | ||
| v2.Node().Property("name").Contains("admin"), | ||
| ).Return( | ||
| v2.Node(), | ||
| ).OrderBy( | ||
| v2.Node().Property("name"), | ||
| ), | ||
| expectedCypher: "match (n) where n:User and n.name contains $p0 return n order by n.name asc", | ||
| expectedParams: map[string]any{"p0": "admin"}, | ||
| }, | ||
| "relationship read": { | ||
| builder: v2.New().Where( | ||
| v2.Relationship().Kind().Is(graph.StringKind("MemberOf")), | ||
| v2.Start().ID().Equals(1), | ||
| ).Return( | ||
| v2.Start().ID(), | ||
| v2.Relationship().ID(), | ||
| v2.End().ID(), | ||
| ), | ||
| expectedCypher: "match (s)-[r:MemberOf]->(e) where id(s) = $p0 return id(s), id(r), id(e)", | ||
| expectedParams: map[string]any{"p0": 1}, | ||
| }, | ||
| "shortest path": { | ||
| builder: v2.New().WithShortestPaths().Where( | ||
| v2.Relationship().Kind().Is(graph.StringKind("MemberOf")), | ||
| v2.Start().ID().Equals(1), | ||
| v2.End().ID().Equals(2), | ||
| ).Return( | ||
| v2.Path(), | ||
| ), | ||
| expectedCypher: "match p = shortestPath((s)-[r:MemberOf*]->(e)) where id(s) = $p0 and id(e) = $p1 return p", | ||
| expectedParams: map[string]any{"p0": 1, "p1": 2}, | ||
| }, | ||
| "all shortest paths": { | ||
| builder: v2.New().WithAllShortestPaths().Where( | ||
| v2.Relationship().Kind().Is(graph.StringKind("MemberOf")), | ||
| v2.Start().ID().Equals(1), | ||
| v2.End().ID().Equals(2), | ||
| ).Return( | ||
| v2.Path(), | ||
| ), | ||
| expectedCypher: "match p = allShortestPaths((s)-[r:MemberOf*]->(e)) where id(s) = $p0 and id(e) = $p1 return p", | ||
| expectedParams: map[string]any{"p0": 1, "p1": 2}, | ||
| }, | ||
| "create node": { | ||
| builder: v2.New().Create( | ||
| v2.NodePattern(graph.Kinds{graph.StringKind("User")}, v2.NamedParameter("props", map[string]any{"name": "u"})), | ||
| ).Return( | ||
| v2.Node().ID(), | ||
| ), | ||
| expectedCypher: "create (n:User $p0) return id(n)", | ||
| expectedParams: map[string]any{"p0": map[string]any{"name": "u"}}, | ||
| }, | ||
| "update node": { | ||
| builder: v2.New().Where( | ||
| v2.Node().ID().Equals(1), | ||
| ).Update( | ||
| v2.SetProperty(v2.Node().Property("name"), "updated"), | ||
| ), | ||
| expectedCypher: "match (n) where id(n) = $p0 set n.name = $p1", | ||
| expectedParams: map[string]any{"p0": 1, "p1": "updated"}, | ||
| }, | ||
| "delete relationship": { | ||
| builder: v2.New().Where( | ||
| v2.Relationship().ID().Equals(1), | ||
| ).Delete( | ||
| v2.Relationship(), | ||
| ), | ||
| expectedCypher: "match ()-[r]->() where id(r) = $p0 delete r", | ||
| expectedParams: map[string]any{"p0": 1}, | ||
| }, | ||
| "delete node": { | ||
| builder: v2.New().Where( | ||
| v2.Node().ID().Equals(1), | ||
| ).Delete( | ||
| v2.Node(), | ||
| ), | ||
| expectedCypher: "match (n) where id(n) = $p0 detach delete n", | ||
| expectedParams: map[string]any{"p0": 1}, | ||
| }, | ||
| } | ||
|
|
||
| for name, testCase := range cases { | ||
| t.Run(name, func(t *testing.T) { | ||
| preparedQuery, err := testCase.builder.Build() | ||
| require.NoError(t, err) | ||
|
|
||
| queryBuilder := neo4j.NewQueryBuilder(preparedQuery.Query) | ||
| require.NoError(t, queryBuilder.Prepare()) | ||
|
|
||
| rendered, err := queryBuilder.Render() | ||
| require.NoError(t, err) | ||
| require.Equal(t, testCase.expectedCypher, rendered) | ||
| require.Equal(t, testCase.expectedParams, queryBuilder.Parameters) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestBackendParityPGTranslate(t *testing.T) { | ||
| userKind := graph.StringKind("User") | ||
| edgeKind := graph.StringKind("MemberOf") | ||
| mapper := testKindMapper(userKind, edgeKind) | ||
|
|
||
| cases := map[string]struct { | ||
| builder v2.QueryBuilder | ||
| expectedSQL string | ||
| expectedParams map[string]any | ||
| }{ | ||
| "node read": { | ||
| builder: v2.New().Where( | ||
| v2.Node().Kinds().Has(userKind), | ||
| v2.Node().Property("name").Contains("admin"), | ||
| ).Return( | ||
| v2.Node().ID(), | ||
| v2.Node().Kinds(), | ||
| ), | ||
| expectedSQL: "with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (n0.kind_ids operator (pg_catalog.&&) array [1]::int2[] and (n0.properties ->> 'name') like '%' || @pi0::text || '%')) select (s0.n0).id, (s0.n0).kind_ids from s0;", | ||
| expectedParams: map[string]any{"p0": "admin", "pi0": "admin"}, | ||
| }, | ||
| "relationship read": { | ||
| builder: v2.New().Where( | ||
| v2.Relationship().Kind().Is(edgeKind), | ||
| v2.Start().ID().Equals(1), | ||
| ).Return( | ||
| v2.Start().ID(), | ||
| v2.Relationship().ID(), | ||
| v2.End().ID(), | ||
| ), | ||
| expectedSQL: "with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on (n0.id = @pi0::int8) and n0.id = e0.start_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [2]::int2[])) select (s0.n0).id, (s0.e0).id, (s0.n1).id from s0;", | ||
| expectedParams: map[string]any{"p0": 1, "pi0": 1}, | ||
| }, | ||
| "update node": { | ||
| builder: v2.New().Where( | ||
| v2.Node().ID().Equals(1), | ||
| ).Update( | ||
| v2.SetProperty(v2.Node().Property("name"), "updated"), | ||
| ), | ||
| expectedSQL: "with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (n0.id = @pi0::int8)), s1 as (update node n1 set properties = n1.properties || jsonb_build_object('name', @pi1::text)::jsonb from s0 where (s0.n0).id = n1.id returning (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n0) select 1;", | ||
| expectedParams: map[string]any{"p0": 1, "p1": "updated", "pi0": 1, "pi1": "updated"}, | ||
| }, | ||
| "delete relationship": { | ||
| builder: v2.New().Where( | ||
| v2.Relationship().ID().Equals(1), | ||
| ).Delete( | ||
| v2.Relationship(), | ||
| ), | ||
| expectedSQL: "with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where (e0.id = @pi0::int8)), s1 as (delete from edge e1 using s0 where (s0.e0).id = e1.id) select 1;", | ||
| expectedParams: map[string]any{"p0": 1, "pi0": 1}, | ||
| }, | ||
| "delete node": { | ||
| builder: v2.New().Where( | ||
| v2.Node().ID().Equals(1), | ||
| ).Delete( | ||
| v2.Node(), | ||
| ), | ||
| expectedSQL: "with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (n0.id = @pi0::int8)), s1 as (delete from node n1 using s0 where (s0.n0).id = n1.id) select 1;", | ||
| expectedParams: map[string]any{"p0": 1, "pi0": 1}, | ||
| }, | ||
| } | ||
|
|
||
| for name, testCase := range cases { | ||
| t.Run(name, func(t *testing.T) { | ||
| preparedQuery, err := testCase.builder.Build() | ||
| require.NoError(t, err) | ||
|
|
||
| translation, err := translate.Translate(context.Background(), preparedQuery.Query, mapper, preparedQuery.Parameters) | ||
| require.NoError(t, err) | ||
|
|
||
| sql, err := translate.Translated(translation) | ||
| require.NoError(t, err) | ||
| require.Equal(t, testCase.expectedSQL, sql) | ||
| require.Equal(t, testCase.expectedParams, translation.Parameters) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestBackendParityPGTranslateShortestPaths(t *testing.T) { | ||
| edgeKind := graph.StringKind("MemberOf") | ||
| mapper := testKindMapper(edgeKind) | ||
|
|
||
| cases := map[string]struct { | ||
| builder v2.QueryBuilder | ||
| expectedHarness string | ||
| }{ | ||
| "shortest path": { | ||
| builder: v2.New().WithShortestPaths().Where( | ||
| v2.Relationship().Kind().Is(edgeKind), | ||
| v2.Start().ID().Equals(1), | ||
| v2.End().ID().Equals(2), | ||
| ).Return( | ||
| v2.Path(), | ||
| ), | ||
| expectedHarness: "unidirectional_sp_harness", | ||
| }, | ||
| "all shortest paths": { | ||
| builder: v2.New().WithAllShortestPaths().Where( | ||
| v2.Relationship().Kind().Is(edgeKind), | ||
| v2.Start().ID().Equals(1), | ||
| v2.End().ID().Equals(2), | ||
| ).Return( | ||
| v2.Path(), | ||
| ), | ||
| expectedHarness: "bidirectional_asp_harness", | ||
| }, | ||
| } | ||
|
|
||
| for name, testCase := range cases { | ||
| t.Run(name, func(t *testing.T) { | ||
| preparedQuery, err := testCase.builder.Build() | ||
| require.NoError(t, err) | ||
|
|
||
| translation, err := translate.Translate(context.Background(), preparedQuery.Query, mapper, preparedQuery.Parameters) | ||
| require.NoError(t, err) | ||
|
|
||
| sql, err := translate.Translated(translation) | ||
| require.NoError(t, err) | ||
| require.Contains(t, sql, testCase.expectedHarness) | ||
| require.Contains(t, sql, "edges_to_path") | ||
| require.Equal(t, 1, translation.Parameters["p0"]) | ||
| require.Equal(t, 2, translation.Parameters["p1"]) | ||
|
|
||
| serializedHarnessQueryHasKindConstraint := false | ||
| for _, parameterValue := range translation.Parameters { | ||
| if serializedQuery, typeOK := parameterValue.(string); typeOK && strings.Contains(serializedQuery, "array [1]::int2[]") { | ||
| serializedHarnessQueryHasKindConstraint = true | ||
| break | ||
| } | ||
| } | ||
| require.True(t, serializedHarnessQueryHasKindConstraint, "expected serialized shortest-path harness query to contain edge kind constraint: %#v", translation.Parameters) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestBackendParityPGCreateUnsupported(t *testing.T) { | ||
| edgeKind := graph.StringKind("MemberOf") | ||
| mapper := testKindMapper(edgeKind) | ||
|
|
||
| preparedQuery, err := v2.New().Where( | ||
| v2.Start().ID().Equals(1), | ||
| v2.End().ID().Equals(2), | ||
| ).Create( | ||
| v2.RelationshipPattern(edgeKind, nil, graph.DirectionOutbound), | ||
| ).Return( | ||
| v2.Relationship().ID(), | ||
| ).Build() | ||
| require.NoError(t, err) | ||
|
|
||
| _, err = translate.Translate(context.Background(), preparedQuery.Query, mapper, preparedQuery.Parameters) | ||
| require.ErrorContains(t, err, "pgsql translator does not support create clauses") | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
Stop after
Build()fails.t.Errorfkeeps the loop running, but Line 37 dereferencesbuiltQueryeven whenBuild()returned an error. Uset.Fatalf/require.NoError, orcontinueafter the failure paths.🤖 Prompt for AI Agents