Skip to content
Open
Show file tree
Hide file tree
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 May 7, 2026
94cff72
feat(query/v2): materialize builder parameters
zinic May 7, 2026
f251be1
fix(query/v2): infer match patterns by query scope
zinic May 7, 2026
f3f2d0d
feat(query/v2): add typed projection and order helpers
zinic May 7, 2026
c8324f2
feat(query/v2): add query helper parity
zinic May 7, 2026
19d83c0
test(query/v2): cover backend preparation paths
zinic May 7, 2026
303e7c5
test(pgsql): exercise translator with v2 builder
zinic May 7, 2026
905870d
fix(query/v2): surface helper validation errors
zinic May 7, 2026
6348eeb
fix(query/v2): preserve updating clause order
zinic May 7, 2026
b124c06
feat(query/v2): support scoped pattern aliases
zinic May 7, 2026
f58829c
test(query/v2): assert backend render output
zinic May 7, 2026
15db0fc
fix(pgsql): reject unsupported create translation
zinic May 7, 2026
0a7f8a8
fix(query/v2): reject unsupported relationship directions
zinic May 7, 2026
86ef9f3
fix(query/v2): validate create qualified expressions
zinic May 7, 2026
5af16b9
fix(query/v2): make kind projections scope aware
zinic May 7, 2026
53fa1f1
fix(query/v2): ignore projection aliases for match inference
zinic May 7, 2026
84129b2
fix(query/v2): validate explicit relationship directions
zinic May 7, 2026
94457a5
fix(query/v2): validate scope aliases
zinic May 7, 2026
d0443eb
fix(query/v2): validate raw projection inputs
zinic May 7, 2026
c578723
chore(query/v2): remove unused extractor state
zinic May 7, 2026
d5a3ef4
fix(query/v2): sort property update keys
zinic May 7, 2026
4106eab
fix(query/v2): validate raw mutation inputs
zinic May 7, 2026
f74622d
fix(query/v2): validate alias symbols
zinic May 7, 2026
4ac05a8
fix (query/v2): named parameter fixups; preserve projection metadata;…
zinic May 8, 2026
5e192b5
fix (query/v2): clean up some poorly supported neo4j constructs
zinic May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion cypher/models/cypher/format/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ func (s Emitter) formatProjection(output io.Writer, projection *cypher.Projectio
}

func (s Emitter) formatReturn(output io.Writer, returnClause *cypher.Return) error {
if _, err := io.WriteString(output, " return "); err != nil {
if _, err := io.WriteString(output, "return "); err != nil {
return err
}

Expand Down Expand Up @@ -1095,6 +1095,12 @@ func (s Emitter) formatSinglePartQuery(writer io.Writer, singlePartQuery *cypher
}

if singlePartQuery.Return != nil {
if len(singlePartQuery.ReadingClauses) > 0 || len(singlePartQuery.UpdatingClauses) > 0 {
if _, err := io.WriteString(writer, " "); err != nil {
return err
}
}

return s.formatReturn(writer, singlePartQuery.Return)
}

Expand Down
18 changes: 8 additions & 10 deletions cypher/models/pgsql/test/query_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@ import (
"slices"
"testing"

"github.com/specterops/dawgs/cypher/models/cypher"
"github.com/specterops/dawgs/cypher/models/pgsql"
"github.com/specterops/dawgs/cypher/models/pgsql/translate"
"github.com/specterops/dawgs/cypher/models/walk"
"github.com/specterops/dawgs/graph"
"github.com/specterops/dawgs/query"
v2 "github.com/specterops/dawgs/query/v2"
)

var (
Expand All @@ -24,19 +23,18 @@ var (
func TestQuery_KindGeneratesInclusiveKindMatcher(t *testing.T) {
mapper := newKindMapper()

queries := []*cypher.Where{
query.Where(query.KindIn(query.Node(), NodeKind1)),
query.Where(query.Kind(query.Node(), NodeKind2)),
queries := []v2.QueryBuilder{
v2.New().Where(v2.KindIn(v2.Node(), NodeKind1)).Return(v2.Node()),
v2.New().Where(v2.Kind(v2.Node(), NodeKind2)).Return(v2.Node()),
}

for _, nodeQuery := range queries {
builder := query.NewBuilderWithCriteria(nodeQuery)
builtQuery, err := builder.Build(false)
for _, queryBuilder := range queries {
builtQuery, err := queryBuilder.Build()
if err != nil {
t.Errorf("could not build query: %v", err)
}

translatedQuery, err := translate.Translate(context.Background(), builtQuery, mapper, nil)
translatedQuery, err := translate.Translate(context.Background(), builtQuery.Query, mapper, builtQuery.Parameters)
if err != nil {
t.Errorf("could not translate query: %#v: %v", builtQuery, err)
Comment on lines +31 to 39
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Stop after Build() fails.

t.Errorf keeps the loop running, but Line 37 dereferences builtQuery even when Build() returned an error. Use t.Fatalf/require.NoError, or continue after the failure paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cypher/models/pgsql/test/query_test.go` around lines 31 - 39, The loop
currently continues after queryBuilder.Build() fails and then dereferences
builtQuery on the next lines; change the failure handling for Build() so the
test stops or skips the iteration—either replace t.Errorf("could not build
query: %v", err) with t.Fatalf(...) (or require.NoError(t, err)) to abort the
test, or add a continue after the t.Errorf to skip using builtQuery; apply the
same pattern wherever builtQuery is dereferenced (e.g., before calling
translate.Translate) to ensure no dereference happens after a failed Build().

}
Expand All @@ -47,7 +45,7 @@ func TestQuery_KindGeneratesInclusiveKindMatcher(t *testing.T) {
switch leftTyped := typedNode.LOperand.(type) {
case pgsql.CompoundIdentifier:
if slices.Equal(leftTyped, pgsql.AsCompoundIdentifier("n0", "kind_ids")) && typedNode.Operator != pgsql.OperatorPGArrayOverlap {
t.Errorf("query did not generate an array overlap operator (&&): %#v", nodeQuery)
t.Errorf("query did not generate an array overlap operator (&&): %#v", builtQuery)
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions cypher/models/pgsql/translate/translator.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,9 @@ func (s *Translator) Exit(expression cypher.SyntaxNode) {
s.SetError(err)
}

case *cypher.Create:
s.SetErrorf("pgsql translator does not support create clauses")

case *cypher.Delete:
if err := s.translateDelete(s.scope, typedExpression); err != nil {
s.SetError(err)
Expand Down
12 changes: 12 additions & 0 deletions query/neo4j/neo4j.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ func (s *QueryBuilder) rewriteParameters() error {
return nil
}

func hasPreparedMatchPattern(readingClause *cypher.ReadingClause) bool {
if readingClause == nil || readingClause.Match == nil {
return false
}

return len(readingClause.Match.Pattern) > 0
}

func (s *QueryBuilder) Apply(criteria graph.Criteria) {
switch typedCriteria := criteria.(type) {
case *cypher.Where:
Expand Down Expand Up @@ -201,6 +209,10 @@ func (s *QueryBuilder) prepareMatch() error {
return ErrAmbiguousQueryVariables
}

if firstReadingClause := query.GetFirstReadingClause(s.query); hasPreparedMatchPattern(firstReadingClause) {
return nil
}

if singleNodeBound && !creatingSingleNode {
patternPart.AddPatternElements(&cypher.NodePattern{
Variable: cypher.NewVariableWithSymbol(query.NodeSymbol),
Expand Down
283 changes: 283 additions & 0 deletions query/v2/backend_test.go
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")
}
Loading
Loading