Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

834 lines
25 KiB
Go

// Package sql parses SQL string literals into the table references
// they touch. Used by language extractors that detect calls into a
// SQL exec API (db.Query, db.Exec, sqlx.NamedExec, etc.) with a
// string-literal first arg — the literal goes through ExtractTables
// to get the names; the caller emits KindTable nodes plus EdgeQueries
// edges.
//
// Scope (v1): regex-based table extraction from FROM / JOIN /
// INSERT INTO / UPDATE / DELETE FROM clauses. The regex picks up
// the canonical patterns without spinning up a full SQL parser.
// Trade-offs:
//
// - Dynamic SQL built by string concatenation or query builders
// is invisible. Agents who care about that will fall back to
// grep — same v1 stance the broader spec takes for noisy
// extractions.
//
// - Quoted identifiers (`"foo"`, `[foo]`) and case-sensitive
// schema-qualified names (`schema.table`) are handled — the
// regex strips quoting and keeps the trailing identifier, with
// schema preserved in the meta when present.
//
// - SQL keywords used as identifiers (`FROM "from"`) misclassify
// as the keyword. A future enhancement could feed the regex
// output through a SQL keyword list to filter them; v1 accepts
// the noise.
//
// - Default-off via the `sql` coverage gate per the spec — string-
// literal SQL is noisy enough that opt-in is the right shape.
package sql
import (
"regexp"
"sort"
"strings"
)
// tableRefPatterns enumerates the SQL clauses that introduce a
// table reference. Each pattern uses a single capture group on the
// table identifier. Case-insensitive match — SQL conventionally
// uppercases keywords but we tolerate either form.
var tableRefPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\bFROM\s+([a-zA-Z_"\x60\[][a-zA-Z0-9_."\x60\]]*)`),
regexp.MustCompile(`(?i)\bJOIN\s+([a-zA-Z_"\x60\[][a-zA-Z0-9_."\x60\]]*)`),
regexp.MustCompile(`(?i)\bINSERT\s+INTO\s+([a-zA-Z_"\x60\[][a-zA-Z0-9_."\x60\]]*)`),
regexp.MustCompile(`(?i)\bUPDATE\s+([a-zA-Z_"\x60\[][a-zA-Z0-9_."\x60\]]*)`),
regexp.MustCompile(`(?i)\bDELETE\s+FROM\s+([a-zA-Z_"\x60\[][a-zA-Z0-9_."\x60\]]*)`),
regexp.MustCompile(`(?i)\bTRUNCATE\s+(?:TABLE\s+)?([a-zA-Z_"\x60\[][a-zA-Z0-9_."\x60\]]*)`),
}
// TableRef is a single resolved table reference.
type TableRef struct {
Table string // unquoted table name (last segment if schema.table)
Schema string // optional schema prefix; "" when none
Op string // canonical operation: select, insert, update, delete, truncate
}
// canonicalOp maps a clause keyword to a stable operation tag for
// downstream queries that scope by op (e.g. "find every site that
// truncates X").
func canonicalOp(clauseHead string) string {
switch strings.ToUpper(strings.Fields(clauseHead)[0]) {
case "FROM", "JOIN":
return "select"
case "INSERT":
return "insert"
case "UPDATE":
return "update"
case "DELETE":
return "delete"
case "TRUNCATE":
return "truncate"
}
return ""
}
// ExtractTables walks query and returns the de-duplicated set of
// table references found. Order follows source-text occurrence so
// the result is diff-able across runs of the same query string.
func ExtractTables(query string) []TableRef {
if query == "" {
return nil
}
seen := make(map[string]struct{})
var refs []TableRef
// `DELETE FROM` matches both the DELETE FROM pattern (correct)
// and the bare FROM pattern (wrong — we'd report the same
// table as both a select and a delete). Process compound
// keywords first, mask out their match ranges so the FROM
// regex doesn't see them, then process the remaining ones.
working := maskDeleteFromForFromPattern(query)
for i, re := range tableRefPatterns {
// The FROM pattern (index 0) sees the masked text; the
// DELETE FROM pattern (index 4) sees the original to find
// its own matches first.
text := query
if i == 0 {
text = working
}
matches := re.FindAllStringSubmatch(text, -1)
for _, m := range matches {
if len(m) < 2 {
continue
}
schema, table := splitSchemaTable(stripQuoting(m[1]))
if table == "" {
continue
}
op := canonicalOp(m[0])
key := op + "::" + schema + "::" + table
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
refs = append(refs, TableRef{
Table: table,
Schema: schema,
Op: op,
})
}
}
sort.Slice(refs, func(i, j int) bool {
if refs[i].Op != refs[j].Op {
return refs[i].Op < refs[j].Op
}
if refs[i].Schema != refs[j].Schema {
return refs[i].Schema < refs[j].Schema
}
return refs[i].Table < refs[j].Table
})
return refs
}
// maskDeleteFromForFromPattern substitutes the FROM keyword in
// "DELETE FROM" with a non-keyword sentinel so the bare FROM
// regex doesn't double-match the same table reference. The
// sentinel `__GFOX_FROM__` won't appear in real SQL and is
// valid in the regex's character class so it gets ignored
// silently. The DELETE FROM pattern still operates on the
// original query string and finds its own match.
var deleteFromMaskRe = regexp.MustCompile(`(?i)\b(DELETE)\s+FROM\b`)
func maskDeleteFromForFromPattern(query string) string {
return deleteFromMaskRe.ReplaceAllString(query, "$1 __GFOX_FROM__")
}
// stripQuoting removes the four shapes of SQL identifier quoting:
// double quotes (ANSI), backticks (MySQL), brackets (T-SQL). The
// inner content is returned unchanged otherwise.
func stripQuoting(name string) string {
name = strings.TrimSpace(name)
if len(name) >= 2 {
first, last := name[0], name[len(name)-1]
switch {
case first == '"' && last == '"',
first == '`' && last == '`',
first == '[' && last == ']':
return name[1 : len(name)-1]
}
}
return name
}
// splitSchemaTable separates `schema.table` into its parts.
// Multi-dot identifiers (`db.schema.table`) collapse to schema=
// "schema", table="table" — the leftmost segment is database-
// scoped and rarely useful for graph queries.
func splitSchemaTable(name string) (schema, table string) {
if i := strings.LastIndex(name, "."); i >= 0 {
schema = name[:i]
table = name[i+1:]
// If the schema piece itself has a database segment, keep
// only the immediate parent.
if j := strings.LastIndex(schema, "."); j >= 0 {
schema = schema[j+1:]
}
return strings.TrimSpace(stripQuoting(schema)), strings.TrimSpace(stripQuoting(table))
}
return "", strings.TrimSpace(name)
}
// createTableRe matches CREATE TABLE [IF NOT EXISTS] declarations
// across the four canonical identifier-quoting styles. Used by
// ExtractCreateTables for migration-file extraction — distinct
// from ExtractTables, which scans query strings rather than DDL.
var createTableRe = regexp.MustCompile(`(?i)\bCREATE\s+(?:GLOBAL\s+TEMPORARY\s+|LOCAL\s+TEMPORARY\s+|TEMPORARY\s+|TEMP\s+|UNLOGGED\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-zA-Z_"\x60\[][a-zA-Z0-9_."\x60\]]*)`)
// ExtractCreateTables returns the tables declared by CREATE TABLE
// statements in a SQL source file. Schema-qualified names retain
// their schema in TableRef.Schema; identifier quoting is stripped.
// Op is always "create".
//
// Used by migration-file extraction where the SQL source is a DDL
// script rather than an embedded query string. Drop / alter
// statements are deliberately not extracted — a migration that
// drops a table doesn't *provide* the table to the rest of the
// repo, and modeling alter-as-delta would require maintaining
// per-migration ordering that's out of scope for the v1.
func ExtractCreateTables(source string) []TableRef {
if source == "" {
return nil
}
matches := createTableRe.FindAllStringSubmatch(source, -1)
seen := make(map[string]struct{})
var refs []TableRef
for _, m := range matches {
if len(m) < 2 {
continue
}
schema, table := splitSchemaTable(stripQuoting(m[1]))
if table == "" {
continue
}
key := schema + "::" + table
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
refs = append(refs, TableRef{
Table: table,
Schema: schema,
Op: "create",
})
}
sort.Slice(refs, func(i, j int) bool {
if refs[i].Schema != refs[j].Schema {
return refs[i].Schema < refs[j].Schema
}
return refs[i].Table < refs[j].Table
})
return refs
}
// CreateColumn is a column declared inside a CREATE TABLE body. Type is a
// best-effort leading type token ("" when not determinable).
type CreateColumn struct {
Name string
Type string
}
// CreateTableDef is a CREATE TABLE statement together with its declared
// columns — the schema-ingestion counterpart to TableRef, used where
// columns become first-class graph nodes (migration files, live-DB DDL).
type CreateTableDef struct {
Schema string
Table string
Columns []CreateColumn
}
// tableConstraintHeads are the leading keywords of a table-level
// constraint clause inside a CREATE TABLE body; an entry beginning with
// one of these is not a column.
var tableConstraintHeads = map[string]bool{
"PRIMARY": true, "FOREIGN": true, "CONSTRAINT": true, "UNIQUE": true,
"CHECK": true, "EXCLUDE": true, "LIKE": true, "PARTITION": true,
"INDEX": true, "KEY": true,
}
// ExtractCreateTablesWithColumns parses CREATE TABLE statements and their
// column lists out of SQL DDL. The parenthesised body is scanned with
// paren-depth + quote tracking (so VARCHAR(255) / NUMERIC(10,2) stay
// intact), split on top-level commas, and the leading identifier of each
// non-constraint entry is taken as a column. Best-effort and parser-free,
// matching the rest of this package. Tables are de-duplicated by
// schema::table and returned in a deterministic order.
func ExtractCreateTablesWithColumns(source string) []CreateTableDef {
if source == "" {
return nil
}
locs := createTableRe.FindAllStringSubmatchIndex(source, -1)
seen := make(map[string]struct{})
var out []CreateTableDef
for _, loc := range locs {
if len(loc) < 4 || loc[2] < 0 {
continue
}
schema, table := splitSchemaTable(stripQuoting(source[loc[2]:loc[3]]))
if table == "" {
continue
}
key := schema + "::" + table
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
def := CreateTableDef{Schema: schema, Table: table}
if body := balancedParenBody(source, loc[3]); body != "" {
def.Columns = parseColumnDefs(body)
}
out = append(out, def)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Schema != out[j].Schema {
return out[i].Schema < out[j].Schema
}
return out[i].Table < out[j].Table
})
return out
}
// balancedParenBody returns the contents between the first '(' at or after
// `from` and its matching ')', or "" when there is no balanced group
// (e.g. CREATE TABLE ... AS SELECT, or CREATE TABLE x LIKE y).
func balancedParenBody(s string, from int) string {
open := strings.IndexByte(s[from:], '(')
if open < 0 {
return ""
}
start := from + open
depth := 0
inSingle, inDouble := false, false
for i := start; i < len(s); i++ {
c := s[i]
switch {
case inSingle:
if c == '\'' {
inSingle = false
}
case inDouble:
if c == '"' {
inDouble = false
}
case c == '\'':
inSingle = true
case c == '"':
inDouble = true
case c == '(':
depth++
case c == ')':
depth--
if depth == 0 {
return s[start+1 : i]
}
}
}
return ""
}
// parseColumnDefs splits a CREATE TABLE body on top-level commas and reads
// the leading identifier of each non-constraint entry as a column.
func parseColumnDefs(body string) []CreateColumn {
var cols []CreateColumn
seen := make(map[string]struct{})
for _, seg := range splitTopLevel(body, ',') {
seg = strings.TrimSpace(seg)
if seg == "" {
continue
}
fields := strings.Fields(seg)
if len(fields) == 0 {
continue
}
if tableConstraintHeads[strings.ToUpper(stripQuoting(fields[0]))] {
continue
}
name := stripQuoting(fields[0])
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
typ := ""
if len(fields) > 1 {
typ = fields[1]
}
cols = append(cols, CreateColumn{Name: name, Type: typ})
}
return cols
}
// splitTopLevel splits s on sep at paren depth 0, ignoring sep inside
// single/double quotes or parentheses.
func splitTopLevel(s string, sep byte) []string {
var parts []string
depth := 0
inSingle, inDouble := false, false
last := 0
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case inSingle:
if c == '\'' {
inSingle = false
}
case inDouble:
if c == '"' {
inDouble = false
}
case c == '\'':
inSingle = true
case c == '"':
inDouble = true
case c == '(':
depth++
case c == ')':
if depth > 0 {
depth--
}
case c == sep && depth == 0:
parts = append(parts, s[last:i])
last = i + 1
}
}
return append(parts, s[last:])
}
// ColumnRef is a single resolved column reference. Op is "read" for
// columns appearing in SELECT projections, WHERE clauses, ORDER BY,
// or GROUP BY; "write" for columns in INSERT INTO (col-list) and
// UPDATE … SET col = … assignments. Table identifies the table the
// column belongs to; multi-table queries (joins) are restricted to
// the first table reference because column-table association would
// otherwise require a real SQL parser.
type ColumnRef struct {
Schema string
Table string
Column string
Op string // "read" | "write"
}
// insertColsRe matches `INSERT INTO tbl (col1, col2, …)` with
// optional schema-qualified table.
var insertColsRe = regexp.MustCompile(`(?is)\bINSERT\s+INTO\s+([a-zA-Z_"\x60\[][a-zA-Z0-9_."\x60\]]*)\s*\(([^)]*)\)`)
// updateSetRe matches `UPDATE tbl SET col = …, col2 = …` capturing
// the table name and the SET clause's content (greedy until WHERE
// or end of statement).
var updateSetRe = regexp.MustCompile(`(?is)\bUPDATE\s+([a-zA-Z_"\x60\[][a-zA-Z0-9_."\x60\]]*)\s+SET\s+(.+?)(?:\bWHERE\b|\bRETURNING\b|;|$)`)
// selectFromRe matches `SELECT cols FROM tbl` for single-table
// queries (no JOIN). Multi-table SELECTs return no column edges
// because v1 can't disambiguate which table each column lives on.
var selectFromRe = regexp.MustCompile(`(?is)\bSELECT\s+(.+?)\s+FROM\s+([a-zA-Z_"\x60\[][a-zA-Z0-9_."\x60\]]*)\b`)
// joinDetectRe is used to suppress SELECT column extraction when
// the query has any kind of JOIN — preserves correctness over
// completeness in v1.
var joinDetectRe = regexp.MustCompile(`(?i)\bJOIN\b`)
// ExtractColumns walks a query string and returns the column
// references it touches. The "Op" field distinguishes reads from
// writes so the caller can emit EdgeReadsCol vs EdgeWritesCol.
//
// Limitations (intentional for v1):
// - SELECT * does not produce edges (wildcard).
// - Multi-table SELECTs (with JOIN) produce no column edges.
// - Functions, expressions, and CASE statements degrade to no edge
// for that particular projection slot.
// - WHERE-clause column reads are extracted only when the value-side
// reference is a bare identifier.
func ExtractColumns(query string) []ColumnRef {
if query == "" {
return nil
}
out := []ColumnRef{}
seen := map[string]struct{}{}
add := func(c ColumnRef) {
key := c.Op + "::" + c.Schema + "::" + c.Table + "::" + c.Column
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
out = append(out, c)
}
// INSERT INTO tbl (col1, col2, …) → writes.
for _, m := range insertColsRe.FindAllStringSubmatch(query, -1) {
schema, table := splitSchemaTable(stripQuoting(m[1]))
if table == "" {
continue
}
for _, c := range splitColumnList(m[2]) {
add(ColumnRef{Schema: schema, Table: table, Column: c, Op: "write"})
}
}
// UPDATE tbl SET col = …, col2 = … → writes.
for _, m := range updateSetRe.FindAllStringSubmatch(query, -1) {
schema, table := splitSchemaTable(stripQuoting(m[1]))
if table == "" {
continue
}
for _, c := range splitSetAssignments(m[2]) {
add(ColumnRef{Schema: schema, Table: table, Column: c, Op: "write"})
}
}
// SELECT col1, col2 FROM tbl (single-table) → reads.
if !joinDetectRe.MatchString(query) {
for _, m := range selectFromRe.FindAllStringSubmatch(query, -1) {
projection := strings.TrimSpace(m[1])
schema, table := splitSchemaTable(stripQuoting(m[2]))
if table == "" {
continue
}
if projection == "*" || projection == "" {
continue
}
for _, c := range splitColumnList(projection) {
add(ColumnRef{Schema: schema, Table: table, Column: c, Op: "read"})
}
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Op != out[j].Op {
return out[i].Op < out[j].Op
}
if out[i].Schema != out[j].Schema {
return out[i].Schema < out[j].Schema
}
if out[i].Table != out[j].Table {
return out[i].Table < out[j].Table
}
return out[i].Column < out[j].Column
})
return out
}
// splitColumnList parses a comma-separated column list (used for
// INSERT and SELECT projections), returning bare column identifiers.
// Aliases (`col AS alias`) collapse to the source column. Function
// calls and expressions return "" and are dropped.
func splitColumnList(list string) []string {
out := []string{}
depth := 0
cur := strings.Builder{}
flush := func() {
s := strings.TrimSpace(cur.String())
cur.Reset()
if s == "" {
return
}
// Strip trailing AS alias.
if idx := strings.LastIndex(strings.ToUpper(s), " AS "); idx >= 0 {
s = strings.TrimSpace(s[:idx])
}
// Strip table prefix `tbl.col` → `col`.
if idx := strings.LastIndex(s, "."); idx >= 0 {
s = s[idx+1:]
}
s = strings.TrimSpace(stripQuoting(s))
// Reject bare expressions (function calls, arithmetic, *).
if s == "" || s == "*" || !isPlainSQLIdent(s) {
return
}
out = append(out, s)
}
for i := 0; i < len(list); i++ {
c := list[i]
switch c {
case '(':
depth++
case ')':
if depth > 0 {
depth--
}
case ',':
if depth == 0 {
flush()
continue
}
}
cur.WriteByte(c)
}
flush()
return out
}
// splitSetAssignments parses a SET clause body (`col = ?, col2 =
// fn(x)`) and returns the column names being written. The right-
// hand expressions are skipped; column refs deeper than `tbl.col` are
// reduced to `col`.
func splitSetAssignments(set string) []string {
out := []string{}
depth := 0
cur := strings.Builder{}
flush := func() {
seg := strings.TrimSpace(cur.String())
cur.Reset()
if seg == "" {
return
}
eq := strings.Index(seg, "=")
if eq < 0 {
return
}
name := strings.TrimSpace(seg[:eq])
if idx := strings.LastIndex(name, "."); idx >= 0 {
name = name[idx+1:]
}
name = strings.TrimSpace(stripQuoting(name))
if name != "" && isPlainSQLIdent(name) {
out = append(out, name)
}
}
for i := 0; i < len(set); i++ {
c := set[i]
switch c {
case '(':
depth++
case ')':
if depth > 0 {
depth--
}
case ',':
if depth == 0 {
flush()
continue
}
}
cur.WriteByte(c)
}
flush()
return out
}
// isPlainSQLIdent returns true when s is a single bare identifier
// (letter/underscore start, alphanum/underscore body). Filters out
// function-call shells (`fn(`), wildcards, arithmetic, and the like.
func isPlainSQLIdent(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
isAlpha := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'
isDigit := c >= '0' && c <= '9'
if i == 0 {
if !isAlpha {
return false
}
} else if !isAlpha && !isDigit {
return false
}
}
return true
}
// selectNoFromRe matches a `SELECT <projection>` that has no FROM
// clause (e.g. `SELECT 1 AS id`). Used only as the fallback when a
// query contains no FROM at all — selectFromRe takes precedence.
var selectNoFromRe = regexp.MustCompile(`(?is)\bSELECT\s+(.+?)\s*(?:;|$)`)
// ProjectionColumns returns the output column names a query produces —
// the columns of the relation the query *materialises*, as opposed to
// ExtractColumns which attributes columns to the source tables a query
// reads or writes. Used by the dbt / SQLMesh model extractor to record
// a model's own columns.
//
// The output column of a projection slot is its alias when one is
// present (`total AS order_total` → `order_total`) and the bare column
// identifier otherwise (`customers.id` → `id`). Slots that are pure
// expressions / function calls with no alias, and `*` / `tbl.*`
// wildcards, produce no name.
//
// Heuristic (mirrors the v1 regex stance of the rest of this package):
// the last `SELECT <projection> FROM` occurrence is taken as the
// query's output projection — CTEs and subqueries appear earlier in
// source order, so the final top-level SELECT is what the query
// returns. When the query has no FROM clause at all the trailing
// `SELECT <projection>` is used. Subquery-valued projection slots and
// JOIN-bearing final SELECTs degrade gracefully to whatever bare
// identifiers can still be recovered.
func ProjectionColumns(query string) []string {
if query == "" {
return nil
}
var projection string
if all := selectFromRe.FindAllStringSubmatch(query, -1); len(all) > 0 {
projection = strings.TrimSpace(all[len(all)-1][1])
} else if m := selectNoFromRe.FindStringSubmatch(query); m != nil {
projection = strings.TrimSpace(m[1])
}
if projection == "" || projection == "*" {
return nil
}
cols := splitProjectionList(projection)
seen := make(map[string]struct{}, len(cols))
out := cols[:0]
for _, c := range cols {
if _, dup := seen[c]; dup {
continue
}
seen[c] = struct{}{}
out = append(out, c)
}
return out
}
// splitProjectionList parses a SELECT projection list and returns the
// output column names — the alias when one is present, otherwise the
// bare column identifier. Distinct from splitColumnList, which collapses
// `col AS alias` to the source `col` because it attributes reads to a
// source table; here the alias *is* the produced column name.
func splitProjectionList(list string) []string {
out := []string{}
depth := 0
cur := strings.Builder{}
flush := func() {
seg := strings.TrimSpace(cur.String())
cur.Reset()
if seg == "" {
return
}
if idx := lastTopLevelAS(seg); idx >= 0 {
alias := strings.TrimSpace(stripQuoting(strings.TrimSpace(seg[idx+4:])))
if isPlainSQLIdent(alias) {
out = append(out, alias)
}
return
}
// No alias: strip a `tbl.` qualifier, require a bare identifier.
s := seg
if i := strings.LastIndex(s, "."); i >= 0 {
s = s[i+1:]
}
s = strings.TrimSpace(stripQuoting(s))
if s != "" && s != "*" && isPlainSQLIdent(s) {
out = append(out, s)
}
}
for i := 0; i < len(list); i++ {
c := list[i]
switch c {
case '(':
depth++
case ')':
if depth > 0 {
depth--
}
case ',':
if depth == 0 {
flush()
continue
}
}
cur.WriteByte(c)
}
flush()
return out
}
// lastTopLevelAS returns the byte index of the last ` AS ` keyword in
// seg that sits at paren depth zero, or -1 when there is none. Case-
// insensitive. Keeps a cast's inner ` AS ` (`CAST(x AS int)`) from
// being mistaken for a projection alias.
func lastTopLevelAS(seg string) int {
upper := strings.ToUpper(seg)
depth := 0
last := -1
for i := 0; i+4 <= len(upper); i++ {
switch upper[i] {
case '(':
depth++
case ')':
if depth > 0 {
depth--
}
}
if depth == 0 && upper[i:i+4] == " AS " {
last = i
}
}
return last
}
// ColumnNodeID returns the canonical synthetic ID for a column.
func ColumnNodeID(dialect, schema, table, column string) string {
if dialect == "" {
dialect = "generic"
}
prefix := "col::" + dialect + "::"
if schema == "" {
return prefix + table + "." + column
}
return prefix + schema + "." + table + "." + column
}
// MigrationNodeID is the canonical synthetic ID for a migration
// node. The path component lets agents reach the originating file
// in one step; the prefix matches the synthetic-ID convention
// used by db:: tables and module:: dependencies.
func MigrationNodeID(path string) string {
return "migration::" + path
}
// IsMigrationPath returns true when filePath looks like a SQL
// migration file. Recognised conventions: any .sql file under a
// directory whose name contains "migrate" or "migration"
// (case-insensitive). Matches Rails (db/migrate/), golang-migrate
// (migrations/), Alembic when wrapped (we mostly handle alembic
// via Python parsers separately), and most ORM generators.
func IsMigrationPath(filePath string) bool {
if !strings.HasSuffix(strings.ToLower(filePath), ".sql") {
return false
}
lower := strings.ToLower(filePath)
for _, segment := range []string{"/migrate/", "/migrations/", "/migrate.", "/migrations."} {
if strings.Contains(lower, segment) {
return true
}
}
return strings.HasPrefix(lower, "migrate/") ||
strings.HasPrefix(lower, "migrations/") ||
strings.HasPrefix(lower, "db/migrate/") ||
strings.HasPrefix(lower, "db/migrations/")
}
// TableNodeID returns the canonical synthetic ID for a table
// reference. Mirrors the ecosystem-prefix convention used by
// module:: / external:: / annotation:: / event:: nodes — `db::`
// keeps the table namespace distinct.
//
// dialect is the SQL dialect tag (postgres, mysql, sqlite,
// generic) — included on the ID so cross-dialect projects can
// distinguish a Postgres `users` table from a MySQL one in the
// same graph. The default dialect is "generic" when the caller
// doesn't know.
func TableNodeID(dialect, schema, table string) string {
if dialect == "" {
dialect = "generic"
}
prefix := "db::" + dialect + "::"
if schema == "" {
return prefix + table
}
return prefix + schema + "." + table
}