Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Database Abstraction Wrapper for Graph Schemas
![A Corgi Treat](logo_small.png)

DAWGS provides tools and query helpers for running property graphs on vanilla PostgreSQL without extra database
plugins. It exposes a backend abstraction for graph queries, with current backend support for PostgreSQL and Neo4j.
plugins. PostgreSQL 18 or newer is required. It exposes a backend abstraction for graph queries, with current backend support for PostgreSQL and Neo4j.
The query interface is built around openCypher, including a PostgreSQL SQL translator for environments that do not
support Cypher natively.

Expand Down Expand Up @@ -147,3 +147,25 @@ replace github.com/specterops/dawgs => /path/to/dawgs
- `integration/`: backend-equivalent integration suites and fixtures.
- `cmd/`: command-line tools for capture, export, and diagnostics.
- `tools/`: developer tools such as `dawgrun` and metrics reporting.

### MERGE support

CySQL supports node MERGE, relationships between bound endpoints, and complete fixed-length patterns with
`ON CREATE SET`, `ON MATCH SET`, ordinary following `SET`, and named paths. Unchanged matches do not issue an UPDATE.
Match values are evaluated once and preserve their JSON types. MERGE batches property assignments per SET clause and
splits large patches to stay within PostgreSQL's function argument limit while preserving clause evaluation semantics.
It uses relational conflict checks and omits unchanged bound endpoint writes. Named paths can be carried into subsequent MERGE clauses.
Validation runs for every actual input even with no RETURN or LIMIT 0. Queries containing only bound MERGE entities
use a validation anchor that requires INSERT permission on the node table and invokes INSERT statement triggers, while
never inserting a row. Other queries consume the guard through a private DO NOTHING row in an existing entity write.
PostgreSQL 18+ is enforced when connections are opened and transactions are acquired, including supplied pools.

This first iteration uses one SQL statement. Later clauses do not observe earlier table mutations through scans.
A materialized candidate guard rejects repeated target writes across rows or bindings with an ordered-execution error.
Single-node creations accept distinct inputs only when neither can match the other created value; multiple absent
complete-pattern inputs are rejected. Run rejected inputs as separate commands. Concurrent MERGE statements can raise
uniqueness conflicts. Property-qualified relationships retain the existing endpoint/type uniqueness constraint and
fail if their requested properties conflict with an existing relationship. See
[MERGE semantics and limits](docs/postgresql_translation.md#merge) for the execution contract and validation commands.
The [implementation evidence](docs/merge_implementation.md) describes the delivered pipeline and benchmark results;
[merge_gaps.md](merge_gaps.md) and [merge_gaps_plan.md](merge_gaps_plan.md) preserve the historical analysis and plan.
12 changes: 11 additions & 1 deletion cypher/Cypher Syntax Support.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,6 @@ efforts may be pursued to add support for these language features.
* List Comprehensions
* Pattern Comprehensions
* Existential Subqueries (e.g. exists)
* Merge Statements
* Unwind Expressions
* Pattern Predicates using Recursive Expansion

Expand Down Expand Up @@ -484,3 +483,14 @@ return 1
The reference `n` is being projected by the multipart `with` statement but this projection removes the resultset from
the original query, allowing for ambiguity to slip into future operations against `n.name` where some values of
`n.name` may be `null`.

### MERGE statements

On PostgreSQL 18+, CySQL supports node merges, fixed-length complete patterns, bound endpoints, undirected relationships,
named paths, ON CREATE SET, ON MATCH SET, and following SET clauses. Match properties cannot be null. Relationship
types must be singular, ranges are invalid, and a previously bound relationship cannot be redeclared in MERGE.

This initial implementation uses a shared SQL statement snapshot. Later clauses cannot scan earlier writes; repeated
inputs that depend on earlier creations or updates are rejected with an ordered-execution error. Multiple absent
complete-pattern inputs are also rejected. Relationship uniqueness and native
PostgreSQL concurrent-conflict behavior also apply. See [MERGE semantics](../docs/postgresql_translation.md#merge).
6 changes: 5 additions & 1 deletion cypher/models/pgsql/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ take openCypher input and output valid PostgreSQL SQL. This model is not intende
available SQL dialect features but rather the subset of the dialect required to perform openCypher to PostgreSQL
translation.

**Expected PostgreSQL SQL dialect version**: `16.X`
**Expected PostgreSQL SQL dialect version**: `18+`

## Formatting

Expand All @@ -28,3 +28,7 @@ The `visualization` package contains a PUML digraph formatter for the PgSQL synt
## Test Cases

The `test` package contains the test cases used to validate translation.

`Merge` is a statement and a set expression, so it can be used as a CTE body. It supports returning projections,
`MergeDoNothing`, and optional `SourceQuery` while retaining table sources for existing callers. `FunctionMergeAction`
represents PostgreSQL's `merge_action()`. Empty SQL windows support pipeline row numbering.
49 changes: 48 additions & 1 deletion cypher/models/pgsql/format/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,10 +363,18 @@ func formatNode(builder *OutputBuilder, rootExpr pgsql.SyntaxNode) error {
exprStack = append(exprStack, *typedNextExpr)

case pgsql.FunctionCall:
if typedNextExpr.Over != nil {
if len(typedNextExpr.Over.PartitionBy) > 0 || len(typedNextExpr.Over.OrderBy) > 0 || typedNextExpr.Over.WindowFrame != nil {
return fmt.Errorf("only empty SQL windows are supported")
}
}
if typedNextExpr.CastType.IsKnown() {
exprStack = append(exprStack, typedNextExpr.CastType, pgsql.FormattingLiteral("::"))
}

if typedNextExpr.Over != nil {
exprStack = append(exprStack, pgsql.FormattingLiteral(" over ()"))
}
if !typedNextExpr.Bare {
exprStack = append(exprStack, pgsql.FormattingLiteral(")"))
}
Expand Down Expand Up @@ -776,6 +784,12 @@ func formatSelect(builder *OutputBuilder, selectStmt pgsql.Select) error {
}
}

if selectStmt.Having != nil {
builder.Write(" having ")
if err := formatNode(builder, selectStmt.Having); err != nil {
return err
}
}
return nil
}

Expand Down Expand Up @@ -998,6 +1012,8 @@ func formatSetExpression(builder *OutputBuilder, expression pgsql.SetExpression)

case pgsql.Update:
return formatUpdateStatement(builder, typedSetExpression)
case pgsql.Merge:
return formatMergeStatement(builder, typedSetExpression)

default:
return fmt.Errorf("unsupported set expression type %T", expression)
Expand All @@ -1019,7 +1035,14 @@ func formatMergeStatement(builder *OutputBuilder, merge pgsql.Merge) error {

builder.Write(" using ")

if err := formatNode(builder, merge.Source); err != nil {
if merge.SourceQuery != nil {
if err := formatNode(builder, *merge.SourceQuery); err != nil {
return err
}
if merge.Source.Binding.Set {
builder.Write(" as ", merge.Source.Binding.Value)
}
} else if err := formatNode(builder, merge.Source); err != nil {
return err
}

Expand All @@ -1039,6 +1062,18 @@ func formatMergeStatement(builder *OutputBuilder, merge pgsql.Merge) error {
builder.Write("when ")

switch typedMergeAction := mergeAction.(type) {
case pgsql.MergeDoNothing:
if !typedMergeAction.Matched {
builder.Write("not ")
}
builder.Write("matched")
if typedMergeAction.Predicate != nil {
builder.Write(" and ")
if err := formatNode(builder, typedMergeAction.Predicate); err != nil {
return err
}
}
builder.Write(" then do nothing")
case pgsql.MatchedUpdate:
builder.Write("matched")

Expand Down Expand Up @@ -1112,6 +1147,18 @@ func formatMergeStatement(builder *OutputBuilder, merge pgsql.Merge) error {
}
}

if len(merge.Returning) > 0 {
builder.Write(" returning ")
for idx, item := range merge.Returning {
if idx > 0 {
builder.Write(", ")
}
if err := formatNode(builder, item); err != nil {
return err
}
}
return nil
}
return nil
}

Expand Down
14 changes: 14 additions & 0 deletions cypher/models/pgsql/format/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -850,3 +850,17 @@ func TestFormat_NonMaterializedStringLiteralRemainsExtracted(t *testing.T) {
require.Equal(t, "@__strlit0::text", formatted.Statement)
requireExtractedStringLiteral(t, formatted, value)
}

func TestFormatSelectHaving(t *testing.T) {
for _, grouped := range []bool{false, true} {
query := pgsql.Select{Projection: pgsql.Projection{pgsql.NewLiteral(1, pgsql.Int4)}, Having: pgsql.NewBinaryExpression(pgsql.FunctionCall{Function: "count", Parameters: []pgsql.Expression{pgsql.WildcardIdentifier}}, pgsql.OperatorGreaterThan, pgsql.NewLiteral(1, pgsql.Int4))}
expected := "select 1 having count(*) > 1"
if grouped {
query.GroupBy = []pgsql.Expression{pgsql.Identifier("key")}
expected = "select 1 group by key having count(*) > 1"
}
formatted, err := format.Expression(query, format.NewOutputBuilder())
require.NoError(t, err)
require.Equal(t, expected, formatted.Statement)
}
}
29 changes: 29 additions & 0 deletions cypher/models/pgsql/format/merge_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package format_test

import (
"testing"

"github.com/specterops/dawgs/cypher/models/pgsql"
"github.com/specterops/dawgs/cypher/models/pgsql/format"
"github.com/stretchr/testify/require"
)

func TestMergeReturningInCTE(t *testing.T) {
source := &pgsql.Subquery{Query: pgsql.Query{Body: pgsql.Select{Projection: pgsql.Projection{&pgsql.AliasedExpression{Expression: pgsql.NewLiteral(1, pgsql.Int4), Alias: pgsql.AsOptionalIdentifier("id")}}}}}
merge := pgsql.Merge{Into: true, Table: pgsql.TableReference{Name: pgsql.Identifier("target").AsCompoundIdentifier(), Binding: pgsql.AsOptionalIdentifier("t")}, SourceQuery: source, Source: pgsql.TableReference{Binding: pgsql.AsOptionalIdentifier("s")}, JoinTarget: pgsql.NewBinaryExpression(pgsql.CompoundIdentifier{"t", "id"}, pgsql.OperatorEquals, pgsql.CompoundIdentifier{"s", "id"}), Actions: []pgsql.MergeAction{pgsql.MergeDoNothing{Matched: true, Predicate: pgsql.NewLiteral(true, pgsql.Boolean)}, pgsql.UnmatchedAction{Columns: []pgsql.Identifier{"id"}, Values: pgsql.Values{Values: []pgsql.Expression{pgsql.CompoundIdentifier{"s", "id"}}}}}, Returning: pgsql.Projection{pgsql.CompoundIdentifier{"s", "id"}, pgsql.FunctionCall{Function: pgsql.FunctionMergeAction}}}
formatted, err := format.Statement(pgsql.Query{CommonTableExpressions: &pgsql.With{Expressions: []pgsql.CommonTableExpression{{Alias: pgsql.TableAlias{Name: "m"}, Query: pgsql.Query{Body: merge}}}}, Body: pgsql.Select{Projection: pgsql.Projection{pgsql.WildcardIdentifier}, From: []pgsql.FromClause{{Source: pgsql.Identifier("m")}}}}, format.NewOutputBuilder())
require.NoError(t, err)
require.Equal(t, "with m as (merge into target t using (select 1 as id) as s on t.id = s.id when matched and true then do nothing when not matched then insert (id) values (s.id) returning s.id, merge_action()) select * from m;", formatted.Statement)
merge.Actions = []pgsql.MergeAction{pgsql.MergeDoNothing{Matched: false}}
formatted, err = format.Statement(merge, format.NewOutputBuilder())
require.NoError(t, err)
require.Contains(t, formatted.Statement, "when not matched then do nothing")
}

func TestEmptyWindowFormatting(t *testing.T) {
formatted, err := format.Expression(pgsql.FunctionCall{Function: "row_number", Over: &pgsql.Window{}, CastType: pgsql.Int8}, format.NewOutputBuilder())
require.NoError(t, err)
require.Equal(t, "row_number() over ()::int8", formatted.Statement)
_, err = format.Expression(pgsql.FunctionCall{Function: "row_number", Over: &pgsql.Window{PartitionBy: []pgsql.Expression{pgsql.Identifier("id")}}}, format.NewOutputBuilder())
require.ErrorContains(t, err, "only empty SQL windows")
}
1 change: 1 addition & 0 deletions cypher/models/pgsql/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const (
FunctionReplace Identifier = "replace"
FunctionUnnest Identifier = "unnest"
FunctionNextValue Identifier = "nextval"
FunctionMergeAction Identifier = "merge_action"
FunctionPGGetSerialSequence Identifier = "pg_get_serial_sequence"
FunctionJSONBSet Identifier = "jsonb_set"
FunctionCount Identifier = "count"
Expand Down
17 changes: 17 additions & 0 deletions cypher/models/pgsql/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -985,6 +985,9 @@ type Merge struct {
Source TableReference
JoinTarget Expression
Actions []MergeAction
Returning Projection
// SourceQuery permits a query source while retaining Source for existing callers.
SourceQuery *Subquery
}

func (s Merge) NodeType() string {
Expand All @@ -995,6 +998,20 @@ func (s Merge) AsStatement() Statement {
return s
}

func (s Merge) AsExpression() Expression { return s }
func (s Merge) AsSetExpression() SetExpression { return s }

// MergeDoNothing preserves a matched or unmatched row without writing it.
// PostgreSQL does not emit a RETURNING row for this action.
type MergeDoNothing struct {
Matched bool
Predicate Expression
}

func (s MergeDoNothing) NodeType() string { return "merge_do_nothing" }
func (s MergeDoNothing) AsExpression() Expression { return s }
func (s MergeDoNothing) AsMergeAction() MergeAction { return s }

type ConflictTarget struct {
Columns []Expression
Constraint CompoundIdentifier
Expand Down
Loading
Loading