Skip to content
Merged
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
101 changes: 100 additions & 1 deletion serv/mcp_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,76 @@ func TestValidateExampleQuery_AmbiguousFK(t *testing.T) {
}
}

func TestHandleFindPath_CollapsedExample(t *testing.T) {
ms := newSQLiteMCPServerWithSchema(t, []string{
`CREATE TABLE category (catid INTEGER PRIMARY KEY, label TEXT)`,
`CREATE TABLE product (pid INTEGER PRIMARY KEY, catid INTEGER REFERENCES category(catid), title TEXT)`,
`CREATE TABLE orderitem (oiid INTEGER PRIMARY KEY, pid INTEGER REFERENCES product(pid), amt NUMERIC)`,
})

req := newToolRequest(map[string]any{
"from_table": "category",
"to_table": "orderitem",
})
result, err := ms.handleFindPath(context.Background(), req)
if err != nil {
t.Fatalf("handleFindPath: %v", err)
}
out := assertToolStructuredMap(t, result)

pathRaw, _ := out["path"].([]any)
if len(pathRaw) < 2 {
t.Fatalf("expected a 2+ hop path between category and orderitem, got %d hops: %+v", len(pathRaw), out)
}

collapsed, _ := out["collapsed_example_query"].(string)
if collapsed == "" {
t.Fatalf("expected collapsed_example_query when path has intermediates; got: %+v", out)
}
if !strings.Contains(collapsed, "category") || !strings.Contains(collapsed, "orderitem") {
t.Errorf("collapsed query must nest category and orderitem directly; got: %s", collapsed)
}
if strings.Contains(collapsed, "product") {
t.Errorf("collapsed query must NOT name the intermediate `product`; got: %s", collapsed)
}

compiles, _ := out["collapsed_example_query_compiles"].(bool)
if !compiles {
t.Errorf("collapsed example must compile via auto-traversal; got warning: %v", out["collapsed_example_query_warning"])
}

note, _ := out["collapsed_note"].(string)
if note == "" {
t.Errorf("collapsed_note must be populated when collapsed query is emitted")
} else if !strings.Contains(stringToLower(note), "auto-travers") {
t.Errorf("collapsed_note should mention auto-traversal; got: %q", note)
}
}

func TestHandleFindPath_DirectRelationship(t *testing.T) {
ms := newSQLiteMCPServerWithSchema(t, []string{
`CREATE TABLE users (uid INTEGER PRIMARY KEY, label TEXT)`,
`CREATE TABLE orders (oid INTEGER PRIMARY KEY, uid INTEGER REFERENCES users(uid), amt NUMERIC)`,
})

req := newToolRequest(map[string]any{
"from_table": "users",
"to_table": "orders",
})
result, err := ms.handleFindPath(context.Background(), req)
if err != nil {
t.Fatalf("handleFindPath: %v", err)
}
out := assertToolStructuredMap(t, result)

if collapsed, ok := out["collapsed_example_query"]; ok && collapsed != "" {
t.Errorf("collapsed_example_query should be omitted for single-hop paths; got: %v", collapsed)
}
if note, ok := out["collapsed_note"]; ok && note != "" {
t.Errorf("collapsed_note should be omitted for single-hop paths; got: %v", note)
}
}

func newSQLiteReadyMCPServer(t *testing.T, queries map[string]string, queryVars map[string]string, fragments ...map[string]string) *mcpServer {
t.Helper()

Expand Down Expand Up @@ -669,6 +739,7 @@ func TestBuildFixQueryErrorRepair_Arms(t *testing.T) {
cases := []struct {
name string
errorMsg string
query string // optional; defaults to "query { foo { bar } }"
wantKind string
wantInRepair []string
wantTools []string
Expand Down Expand Up @@ -719,6 +790,21 @@ func TestBuildFixQueryErrorRepair_Arms(t *testing.T) {
wantInRepair: []string{"<actual_pk_column>", "<actual_name_column>"},
wantTools: []string{"describe_table", "get_query_syntax"},
},
{
name: "wrong_dialect_argument",
errorMsg: `unknown argument 'aggregation' on field 'orders'`,
wantKind: fixKindWrongDialect,
wantInRepair: []string{"sum(expr:", "sum_<numeric_col>", "count_<pk_column>"},
wantTools: []string{"get_query_syntax", "describe_table"},
},
{
name: "wrong_dialect_aggregate_suffix",
errorMsg: `table not found: orders_aggregate`,
query: `query { orders_aggregate { aggregate { count } } }`,
wantKind: fixKindWrongDialect,
wantInRepair: []string{"orders", "sum(expr:", "_aggregate"},
wantTools: []string{"get_query_syntax"},
},
{
name: "generic_fallback",
errorMsg: `something completely unexpected happened`,
Expand All @@ -728,7 +814,11 @@ func TestBuildFixQueryErrorRepair_Arms(t *testing.T) {

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
res := buildFixQueryErrorRepair("query { foo { bar } }", tc.errorMsg, false)
query := tc.query
if query == "" {
query = "query { foo { bar } }"
}
res := buildFixQueryErrorRepair(query, tc.errorMsg, false)
if res.Kind != tc.wantKind {
t.Fatalf("kind: got %q want %q", res.Kind, tc.wantKind)
}
Expand Down Expand Up @@ -901,6 +991,15 @@ func TestCanonicalQueryPatterns(t *testing.T) {
if mbd.WrongExample == "" || mbd.WrongReason == "" {
t.Errorf("metric_by_dimension must include WrongExample and WrongReason (load-bearing per P3)")
}
if mbd.AutoTraversalNote == "" {
t.Errorf("metric_by_dimension must include AutoTraversalNote so agents learn the collapsed shape")
} else {
for _, marker := range []string{"auto-travers", "find_path"} {
if !strings.Contains(stringToLower(mbd.AutoTraversalNote), stringToLower(marker)) {
t.Errorf("metric_by_dimension.AutoTraversalNote should mention %q; got: %q", marker, mbd.AutoTraversalNote)
}
}
}

// Patterns must use placeholder column names like <pk_column>,
// <name_column>, <date_column>. Literal 'id' / 'name' tokens
Expand Down
55 changes: 51 additions & 4 deletions serv/mcp_fix_query_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const (
fixKindTableNotFound = "table_not_found"
fixKindColumnNotFound = "column_not_found"
fixKindFieldNotOnTable = "field_not_on_table"
fixKindWrongDialect = "wrong_dialect"
fixKindOperatorInvalid = "operator_or_syntax_invalid"
fixKindSyntaxParse = "syntax_or_parse_error"
fixKindPermission = "permission_denied"
Expand All @@ -33,10 +34,12 @@ const (
)

var (
reAmbiguousRel = regexp.MustCompile(`ambiguous relationship\s+(\S+)\s*->\s*(\S+):\s*multiple foreign keys\s*\(([^)]+)\)`)
reNestedShape = regexp.MustCompile(`nested selection '([^']+)' joins through parent column '([^']+)\.([^']+)', which is not in distinct: \[([^\]]+)\]`)
rePartitionReq = regexp.MustCompile(`table\s+"([^"]+)"\s+requires a filter on (?:partition|temporal) column\s+"([^"]+)"`)
reFieldNotOnTable = regexp.MustCompile(`field '([^']+)' is not a column or a function`)
reAmbiguousRel = regexp.MustCompile(`ambiguous relationship\s+(\S+)\s*->\s*(\S+):\s*multiple foreign keys\s*\(([^)]+)\)`)
reNestedShape = regexp.MustCompile(`nested selection '([^']+)' joins through parent column '([^']+)\.([^']+)', which is not in distinct: \[([^\]]+)\]`)
rePartitionReq = regexp.MustCompile(`table\s+"([^"]+)"\s+requires a filter on (?:partition|temporal) column\s+"([^"]+)"`)
reFieldNotOnTable = regexp.MustCompile(`field '([^']+)' is not a column or a function`)
reWrongDialectArg = regexp.MustCompile(`unknown argument\s+['"` + "`" + `]?(aggregation|aggregate)['"` + "`" + `]?`)
reWrongDialectField = regexp.MustCompile(`(?i)([a-z0-9_]+)_aggregate\b`)
)

// buildFixQueryErrorRepair classifies a failing query+error and returns structured repair guidance.
Expand All @@ -53,6 +56,8 @@ func buildFixQueryErrorRepair(query, errorMsg string, analyticsMode bool) FixQue
fillPartitionFilterArm(&res, errorMsg)
case reFieldNotOnTable.MatchString(errorMsg):
fillFieldNotOnTableArm(&res, errorMsg)
case isWrongDialectError(errorMsg, query):
fillWrongDialectArm(&res, errorMsg, query)
case strings.Contains(errLower, "relationship not found"):
fillUnknownRelArm(&res, errorMsg)
case strings.Contains(errLower, "table") && (strings.Contains(errLower, "not found") || strings.Contains(errLower, "unknown")):
Expand Down Expand Up @@ -187,6 +192,48 @@ query {
}`, field)
}

// isWrongDialectError flags Hasura-style aggregate leakage: either the `aggregation:` argument or a `<table>_aggregate` field suffix in the source query.
func isWrongDialectError(errorMsg, query string) bool {
if reWrongDialectArg.MatchString(errorMsg) {
return true
}
errLower := strings.ToLower(errorMsg)
if !(strings.Contains(errLower, "table") && (strings.Contains(errLower, "not found") || strings.Contains(errLower, "unknown") || strings.Contains(errLower, "does not exist"))) {
return false
}
return reWrongDialectField.MatchString(query)
}

func fillWrongDialectArm(res *FixQueryErrorResult, errorMsg, query string) {
res.Kind = fixKindWrongDialect
res.FollowUpTools = []string{"get_query_syntax", "describe_table", "get_table_sample"}

tableHint := "<table>"
if m := reWrongDialectField.FindStringSubmatch(query); m != nil {
tableHint = m[1]
}

if reWrongDialectArg.MatchString(errorMsg) {
res.Diagnosis = "Query used the Hasura/PostgREST `aggregation`/`aggregate` argument. GraphJin has no such argument — aggregates are leaf-level fields: `sum_<col>`, `avg_<col>`, `count_<col>`, or `<alias>: sum(expr: { mul: [<col_a>, <col_b>] })` for arithmetic. Call get_query_syntax for the full grammar."
} else {
res.Diagnosis = fmt.Sprintf(
"Query referenced `%s_aggregate` — the Hasura aggregate-table shape. GraphJin has no `_aggregate` suffix; aggregates live as leaf fields on the original table: `sum_<col>`, `count_<col>`, or `<alias>: sum(expr: { ... })` for arithmetic. Call get_query_syntax for the full grammar.",
tableHint)
}

res.RepairedQuery = fmt.Sprintf(
`# Aggregates are leaf fields on %s — no _aggregate selection, no aggregation: argument.
query {
%s {
count_<pk_column>
sum_<numeric_col>
avg_<numeric_col>
revenue: sum(expr: { mul: [<col_a>, <col_b>] })
}
}`,
tableHint, tableHint)
}

func fillUnknownRelArm(res *FixQueryErrorResult, errorMsg string) {
res.Kind = fixKindUnknownRelationship
res.Diagnosis = "GraphJin has no relationship between the named tables. Confirm the join path before retrying."
Expand Down
1 change: 1 addition & 0 deletions serv/mcp_js_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ func (ms *mcpServer) buildJSRuntimeAPI() JSRuntimeAPI {
},
Notes: []string{
"IMPORTANT: All gj.tools.* functions return DECODED native JavaScript objects — ready to use directly.",
"Direct MCP callers (NOT inside gj.tools.*): tool results are MCP-wrapped — prefer `result.structuredContent` when present (populated for tools with output schemas); otherwise `JSON.parse(result.content[0].text)`. gj.tools.* unwraps this for you.",
"Example: var result = gj.tools.executeGraphql({query: 'query GetOrders { orders { id total } }'}); var orders = result.data.orders;",
"Example: var tables = gj.tools.listTables().tables;",
"Example: var schema = gj.tools.describeTable({table: 'orders'});",
Expand Down
22 changes: 12 additions & 10 deletions serv/mcp_query_patterns.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ package serv
// Three canonical query shapes; Wrong/Right contrast is load-bearing for small-model pattern matching.

type QueryPattern struct {
Name string `json:"name"`
Title string `json:"title"`
Question string `json:"question"`
Rule string `json:"rule"`
Why string `json:"why"`
WrongExample string `json:"wrong_example,omitempty"`
WrongReason string `json:"wrong_reason,omitempty"`
RightExample string `json:"right_example"`
Name string `json:"name"`
Title string `json:"title"`
Question string `json:"question"`
Rule string `json:"rule"`
Why string `json:"why"`
WrongExample string `json:"wrong_example,omitempty"`
WrongReason string `json:"wrong_reason,omitempty"`
RightExample string `json:"right_example"`
AutoTraversalNote string `json:"auto_traversal_note,omitempty"`
}

func canonicalQueryPatterns() []QueryPattern {
Expand All @@ -26,7 +27,7 @@ func canonicalQueryPatterns() []QueryPattern {
sum_<metric>
}
}`,
WrongReason: "distinct on a fact-table FK dedupes rows but does not produce per-dimension aggregates. The compiler will reject this when the nested join references a column outside the distinct list.",
WrongReason: "distinct on a fact-table FK dedupes rows but does not produce per-dimension aggregates. The compiler will reject this when the nested join references a column outside the distinct list.",
RightExample: `query {
<dimension_table> {
<pk_column>
Expand All @@ -36,6 +37,7 @@ func canonicalQueryPatterns() []QueryPattern {
}
}
}`,
AutoTraversalNote: "When the dimension and fact tables are not directly related, you can still nest them directly — GraphJin auto-traverses any single FK path between them. For example, `{ productcategory { salesorderdetail { sum_amt: sum(<numeric_col>) } } }` works even if the underlying chain is productcategory → productsubcategory → product → specialofferproduct → salesorderdetail. Use this collapsed form for clean per-dimension aggregates; call find_path first to confirm a single FK path exists.",
},
{
Name: "time_series",
Expand Down Expand Up @@ -65,7 +67,7 @@ func canonicalQueryPatterns() []QueryPattern {
}
# Nesting <other_dimension_table> would fail because its FK is NOT in
# distinct. Only nest joins whose FK is the same column listed in distinct.`,
WrongReason: "Nesting through a non-distinct FK column is rejected (a column dropped by GROUP BY cannot be a join key). Only nest joins whose FK column is itself in distinct.",
WrongReason: "Nesting through a non-distinct FK column is rejected (a column dropped by GROUP BY cannot be a join key). Only nest joins whose FK column is itself in distinct.",
RightExample: `query {
<fact_table>(distinct: [<entity_fk>], order_by: { <metric_alias>: desc }, limit: 10) {
<entity_fk>
Expand Down
59 changes: 47 additions & 12 deletions serv/mcp_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,14 @@ func (ms *mcpServer) registerSchemaTools() {
mcp.Description("Optional database name. Omit to search all databases."),
),
mcp.WithOutputSchema[struct {
Path []core.PathStep `json:"path"`
ExampleQuery string `json:"example_query"`
ExampleQueryCompiles bool `json:"example_query_compiles"`
ExampleQueryWarning *FixQueryErrorResult `json:"example_query_warning,omitempty"`
Path []core.PathStep `json:"path"`
ExampleQuery string `json:"example_query"`
ExampleQueryCompiles bool `json:"example_query_compiles"`
ExampleQueryWarning *FixQueryErrorResult `json:"example_query_warning,omitempty"`
CollapsedExampleQuery string `json:"collapsed_example_query,omitempty"`
CollapsedExampleQueryCompiles bool `json:"collapsed_example_query_compiles,omitempty"`
CollapsedExampleQueryWarning *FixQueryErrorResult `json:"collapsed_example_query_warning,omitempty"`
CollapsedNote string `json:"collapsed_note,omitempty"`
}](),
), ms.handleFindPath)

Expand Down Expand Up @@ -489,16 +493,47 @@ func (ms *mcpServer) handleFindPath(ctx context.Context, req mcp.CallToolRequest
exampleQuery := generatePathExampleQuery(fromTable, path, ms.resolvePKColumn)
compiles, warning := ms.validateExampleQuery(exampleQuery)

// When the path has intermediates, emit a collapsed `{ <from> { <to> } }` shape that GraphJin auto-traverses.
var (
collapsedQuery string
collapsedCompiles bool
collapsedWarning *FixQueryErrorResult
collapsedNote string
)
if len(path) >= 2 {
toTable := path[len(path)-1].To
collapsedQuery = generatePathExampleQuery(fromTable,
[]core.PathStep{{To: toTable}}, ms.resolvePKColumn)
collapsedCompiles, collapsedWarning = ms.validateExampleQuery(collapsedQuery)
if collapsedCompiles {
collapsedNote = "GraphJin auto-traverses the multi-hop FK path; you can nest `" +
fromTable + "` and `" + toTable + "` directly. Use this collapsed form for " +
"per-dimension aggregations (see get_query_syntax.patterns.metric_by_dimension)."
} else {
collapsedNote = "Auto-traversal between `" + fromTable + "` and `" + path[len(path)-1].To +
"` did not compile on this schema (see collapsed_example_query_warning); " +
"use the full nested example_query instead."
}
}

result := struct {
Path []core.PathStep `json:"path"`
ExampleQuery string `json:"example_query"`
ExampleQueryCompiles bool `json:"example_query_compiles"`
ExampleQueryWarning *FixQueryErrorResult `json:"example_query_warning,omitempty"`
Path []core.PathStep `json:"path"`
ExampleQuery string `json:"example_query"`
ExampleQueryCompiles bool `json:"example_query_compiles"`
ExampleQueryWarning *FixQueryErrorResult `json:"example_query_warning,omitempty"`
CollapsedExampleQuery string `json:"collapsed_example_query,omitempty"`
CollapsedExampleQueryCompiles bool `json:"collapsed_example_query_compiles,omitempty"`
CollapsedExampleQueryWarning *FixQueryErrorResult `json:"collapsed_example_query_warning,omitempty"`
CollapsedNote string `json:"collapsed_note,omitempty"`
}{
Path: path,
ExampleQuery: exampleQuery,
ExampleQueryCompiles: compiles,
ExampleQueryWarning: warning,
Path: path,
ExampleQuery: exampleQuery,
ExampleQueryCompiles: compiles,
ExampleQueryWarning: warning,
CollapsedExampleQuery: collapsedQuery,
CollapsedExampleQueryCompiles: collapsedCompiles,
CollapsedExampleQueryWarning: collapsedWarning,
CollapsedNote: collapsedNote,
}
return ms.toolResultJSON("find_path", args, result)
}
Expand Down
Loading