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
6 changes: 6 additions & 0 deletions core/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ func (g *GraphJin) newGraphJin(conf *Config,
conf = &Config{Debug: true}
}

// Deep-copy mutable slices/maps so that init never mutates the caller's
// Config. Without this, NormalizeDatabases, finalizeDatabaseSchema, and
// ensureDiscoveredTablesInConfig would accumulate side-effects across
// repeated NewGraphJin calls that share the same *Config.
conf = conf.clone()

t := time.Now()

gj := &graphjinEngine{
Expand Down
26 changes: 26 additions & 0 deletions core/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,32 @@ func (c *Config) Validate() error {
return nil
}

// clone returns a shallow copy of Config with deep copies of the mutable
// slices and maps (Tables, Databases, Roles, etc.) so that engine init
// never mutates the caller's original Config.
func (c *Config) clone() *Config {
out := *c // shallow copy all scalar/string fields

if c.Tables != nil {
out.Tables = make([]Table, len(c.Tables))
copy(out.Tables, c.Tables)
}

if c.Databases != nil {
out.Databases = make(map[string]DatabaseConfig, len(c.Databases))
for k, v := range c.Databases {
out.Databases[k] = v
}
}

if c.Roles != nil {
out.Roles = make([]Role, len(c.Roles))
copy(out.Roles, c.Roles)
}

return &out
}

// NormalizeDatabases ensures the primary database is represented as an entry
// in the Databases map, eliminating special-casing of empty targetDB strings.
// It is idempotent and should be called during core initialization.
Expand Down
108 changes: 74 additions & 34 deletions core/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,24 +267,25 @@ func addVirtualTable(conf *Config, di *sdata.DBInfo, t Table) error {
return fmt.Errorf("polymorphic table: no 'related_to' specified on id column")
}

s, ok := c.getFK(di.Schema)
fk, ok := c.getFK(di.Schema)
if !ok {
return fmt.Errorf("polymorphic table: foreign key must be <type column>.<foreign key column>")
}

di.VTables = append(di.VTables, sdata.VirtualTable{
Name: t.Name,
IDColumn: c.Name,
TypeColumn: s[1],
FKeyColumn: s[2],
TypeColumn: fk.Table,
FKeyColumn: fk.Column,
})

return nil
}

// addForeignKeys adds foreign keys to the database info
// targetDB is the database name to process (after normalization, all tables have Database set)
func addForeignKeys(conf *Config, di *sdata.DBInfo, targetDB string) error {
// addForeignKeys adds foreign keys to the database info.
// targetDB is the database name to process (after normalization, all tables have Database set).
// allDBInfos provides access to other databases' metadata for cross-database FK resolution.
func addForeignKeys(conf *Config, di *sdata.DBInfo, targetDB string, allDBInfos map[string]*sdata.DBInfo) error {
for _, t := range conf.Tables {
// After normalization, every table has a Database set.
if t.Database != targetDB {
Expand All @@ -298,16 +299,17 @@ func addForeignKeys(conf *Config, di *sdata.DBInfo, targetDB string) error {
if c.ForeignKey == "" {
continue
}
if err := addForeignKey(conf, di, c, t); err != nil {
if err := addForeignKey(conf, di, c, t, allDBInfos); err != nil {
return err
}
}
}
return nil
}

// addForeignKey adds a foreign key to the database info
func addForeignKey(conf *Config, di *sdata.DBInfo, c Column, t Table) error {
// addForeignKey adds a foreign key to the database info.
// allDBInfos is used to resolve cross-database FK references.
func addForeignKey(conf *Config, di *sdata.DBInfo, c Column, t Table, allDBInfos map[string]*sdata.DBInfo) error {
// Use di.Schema as default if table schema is not specified
schema := t.Schema
if schema == "" {
Expand All @@ -318,43 +320,64 @@ func addForeignKey(conf *Config, di *sdata.DBInfo, c Column, t Table) error {
return fmt.Errorf("config: add foreign key: %w", err)
}

v, ok := c.getFK(di.Schema)
fk, ok := c.getFK(di.Schema)
if !ok {
return fmt.Errorf(
"config: invalid foreign key defined for table '%s' and column '%s': %s",
t.Name, c.Name, c.ForeignKey)
}

// Cross-database FK: resolve against the target database's DBInfo
if fk.Database != "" {
targetDI, ok := allDBInfos[fk.Database]
if !ok {
return fmt.Errorf(
"config: foreign key for table '%s' and column '%s' references unknown database '%s'",
t.Name, c.Name, fk.Database)
}

c3, err := targetDI.GetColumn(fk.Schema, fk.Table, fk.Column)
if err != nil {
return fmt.Errorf(
"config: foreign key for table '%s' and column '%s' points to unknown table '%s:%s.%s' column '%s'",
t.Name, c.Name, fk.Database, fk.Schema, fk.Table, fk.Column)
}

c1.FKeyDatabase = fk.Database
c1.FKeySchema = fk.Schema
c1.FKeyTable = fk.Table
c1.FKeyCol = c3.Name
return nil
}

// check if it's a polymorphic foreign key
if _, err := di.GetColumn(schema, t.Name, v[1]); err == nil {
c2, err := di.GetColumn(schema, t.Name, v[2])
if _, err := di.GetColumn(schema, t.Name, fk.Table); err == nil {
c2, err := di.GetColumn(schema, t.Name, fk.Column)
if err != nil {
return fmt.Errorf(
"config: invalid column '%s' for polymorphic relationship on table '%s' and column '%s'",
v[2], t.Name, c.Name)
fk.Column, t.Name, c.Name)
}

c1.FKeySchema = schema
c1.FKeyTable = v[1]
c1.FKeyTable = fk.Table
c1.FKeyCol = c2.Name
return nil
}

fks, fkt, fkc := v[0], v[1], v[2]

c3, err := di.GetColumn(fks, fkt, fkc)
c3, err := di.GetColumn(fk.Schema, fk.Table, fk.Column)
if err != nil {
return fmt.Errorf(
"config: foreign key for table '%s' and column '%s' points to unknown table '%s.%s' and column '%s'",
t.Name, c.Name, fks, fkt, fkc)
t.Name, c.Name, fk.Schema, fk.Table, fk.Column)
}

c1.FKeySchema = fks
c1.FKeyTable = fkt
c1.FKeySchema = fk.Schema
c1.FKeyTable = fk.Table
c1.FKeyCol = c3.Name

// Check if this is a recursive FK (same table pointing to itself)
if fks == schema && fkt == t.Name {
if fk.Schema == schema && fk.Table == t.Name {
c1.FKRecursive = true
}

Expand Down Expand Up @@ -493,21 +516,38 @@ func (r *Role) GetTable(schema, name string) *RoleTable {
return r.tm[name]
}

// getFK returns the foreign key for the column
func (c *Column) getFK(defaultSchema string) ([3]string, bool) {
var ret [3]string
var ok bool
// fkTarget holds the parsed components of a foreign key reference.
type fkTarget struct {
Database string // Target database (empty = same database)
Schema string
Table string
Column string
}

v := strings.SplitN(c.ForeignKey, ".", 3)
if len(v) == 2 {
ret = [3]string{defaultSchema, v[0], v[1]}
ok = true
}
if len(v) == 3 {
ret = [3]string{v[0], v[1], v[2]}
ok = true
// getFK parses the foreign key reference string.
// Supported formats:
// - "table.column" -> same db, default schema
// - "schema.table.column" -> same db, explicit schema
// - "database:table.column" -> cross-db, default schema
// - "database:schema.table.column" -> cross-db, explicit schema
func (c *Column) getFK(defaultSchema string) (fkTarget, bool) {
fk := c.ForeignKey

var database string
if idx := strings.IndexByte(fk, ':'); idx != -1 {
database = fk[:idx]
fk = fk[idx+1:]
}

v := strings.SplitN(fk, ".", 3)
switch len(v) {
case 2:
return fkTarget{Database: database, Schema: defaultSchema, Table: v[0], Column: v[1]}, true
case 3:
return fkTarget{Database: database, Schema: v[0], Table: v[1], Column: v[2]}, true
default:
return fkTarget{}, false
}
return ret, ok
}

// sanitize trims the value
Expand Down
14 changes: 13 additions & 1 deletion core/init_multidb.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ func (gj *graphjinEngine) finalizeDatabaseSchema(ctx *dbContext) error {
}

// Process foreign keys configured for this database
if err := addForeignKeys(gj.conf, ctx.dbinfo, ctx.name); err != nil {
if err := addForeignKeys(gj.conf, ctx.dbinfo, ctx.name, gj.collectDBInfos()); err != nil {
return fmt.Errorf("database %s: add foreign keys failed: %w", ctx.name, err)
}

Expand Down Expand Up @@ -375,6 +375,18 @@ func (gj *graphjinEngine) ensureDiscoveredTablesInConfig(ctx *dbContext) {
}
}

// collectDBInfos returns a map of database name -> DBInfo for all initialized databases.
// Used to resolve cross-database foreign key references during config processing.
func (gj *graphjinEngine) collectDBInfos() map[string]*sdata.DBInfo {
m := make(map[string]*sdata.DBInfo, len(gj.databases))
for name, ctx := range gj.databases {
if ctx.dbinfo != nil {
m[name] = ctx.dbinfo
}
}
return m
}

// OptionSetDatabases sets multiple database connections for multi-database mode.
// The connections map should use the same keys as Config.Databases.
// Only stores bare dbContexts — full initialization happens in discoverAllDatabases
Expand Down
61 changes: 61 additions & 0 deletions core/internal/sdata/multidb_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,64 @@ func TestDBTableWithDatabaseInTestDBInfo(t *testing.T) {
_ = table.Database
}
}

// TestAddCrossDatabaseRelShadowNode verifies that addColumnRels creates a
// shadow node for cross-database FK targets and the resulting edge has
// different databases on left and right (so IsCrossDatabase returns true).
func TestAddCrossDatabaseRelShadowNode(t *testing.T) {
// Create a DBInfo with a table that has a cross-database FK
cols := []DBColumn{
{Schema: "public", Table: "job_crew", Name: "id", Type: "bigint", NotNull: true, PrimaryKey: true, UniqueKey: true},
{Schema: "public", Table: "job_crew", Name: "employee_id", Type: "integer",
FKeyDatabase: "ats", FKeySchema: "public", FKeyTable: "employees", FKeyCol: "id"},
}

di := NewDBInfo("postgres", 140000, "public", "ats_orders", cols, nil, nil)

// Tag the table with its database
for i := range di.Tables {
di.Tables[i].Database = "ats_orders"
}

schema, err := NewDBSchema(di, nil)
if err != nil {
t.Fatalf("NewDBSchema() error: %v", err)
}

// The shadow node for "employees" should exist in the schema
_, err = schema.Find("public", "employees")
if err != nil {
t.Fatalf("shadow table 'employees' not found in schema: %v", err)
}

// Find the path from job_crew to employees — should exist
path, err := schema.FindPath("job_crew", "employees", "")
if err != nil {
t.Fatalf("FindPath() error: %v", err)
}

if len(path) == 0 {
t.Fatal("expected non-empty path from job_crew to employees")
}

// The relationship should be cross-database
rel := PathToRel(path[0])
if !rel.IsCrossDatabase() {
t.Error("expected IsCrossDatabase() = true for cross-database FK relationship")
}
}

// TestFKeyDatabaseFieldOnDBColumn verifies the FKeyDatabase field exists and works.
func TestFKeyDatabaseFieldOnDBColumn(t *testing.T) {
col := DBColumn{
Name: "employee_id",
FKeyDatabase: "ats",
FKeySchema: "public",
FKeyTable: "employees",
FKeyCol: "id",
}

if col.FKeyDatabase != "ats" {
t.Errorf("FKeyDatabase = %q, want %q", col.FKeyDatabase, "ats")
}
}
46 changes: 42 additions & 4 deletions core/internal/sdata/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,16 +238,24 @@ func (s *DBSchema) addColumnRels(t DBTable) error {
c.FKeySchema = t.Schema
}

if c.FKeyCol == "" {
continue
}

// Cross-database FK: create a shadow node for the foreign table
if c.FKeyDatabase != "" {
if err = s.addCrossDatabaseRel(t, c); err != nil {
return err
}
continue
}

v, ok := s.tindex[(c.FKeySchema + ":" + c.FKeyTable)]
if !ok {
return fmt.Errorf("foreign key table not found: %s.%s", c.FKeySchema, c.FKeyTable)
}
ft := s.tables[v.nodeID]

if c.FKeyCol == "" {
continue
}

fc, ok := ft.getColumn(c.FKeyCol)
if !ok {
return fmt.Errorf("foreign key column not found: %s.%s", c.FKeyTable, c.FKeyCol)
Expand All @@ -271,6 +279,36 @@ func (s *DBSchema) addColumnRels(t DBTable) error {
return nil
}

// addCrossDatabaseRel adds a relationship edge for a cross-database foreign key.
// It creates a shadow node in the local schema graph representing the foreign table
// in the target database. This shadow node exists only for path-finding; actual SQL
// compilation uses the target database's own schema/compiler.
func (s *DBSchema) addCrossDatabaseRel(t DBTable, c DBColumn) error {
shadowKey := c.FKeySchema + ":" + c.FKeyTable

var shadowTable DBTable
if v, exists := s.tindex[shadowKey]; exists {
shadowTable = s.tables[v.nodeID]
} else {
shadowTable = DBTable{
Name: c.FKeyTable,
Schema: c.FKeySchema,
Database: c.FKeyDatabase,
}
s.addNode(shadowTable)
}

// Shadow column representing the FK target column
fc := DBColumn{
Name: c.FKeyCol,
Schema: c.FKeySchema,
Table: c.FKeyTable,
Database: c.FKeyDatabase,
}

return s.addToGraph(t, c, shadowTable, fc, RelOneToMany)
}

// addVirtual adds a virtual table to the schema
func (s *DBSchema) addVirtual(vt VirtualTable) error {
s.virtualTables[vt.Name] = vt
Expand Down
Loading
Loading