feat(sql): suggest join conditions from foreign keys

This commit is contained in:
t8y2
2026-06-02 18:08:06 +08:00
parent b10e7cb1cf
commit a54c8cfd93
12 changed files with 632 additions and 82 deletions
@@ -57,8 +57,14 @@ import {
shouldRunSqlSemanticDiagnostics,
type SqlSemanticDiagnostic,
} from "@/lib/sqlSemanticDiagnostics";
import type { SqlCompletionColumn } from "@/lib/sqlCompletion";
import type { DatabaseType, SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database";
import type { SqlCompletionColumn, SqlCompletionForeignKey } from "@/lib/sqlCompletion";
import type {
DatabaseType,
ForeignKeyInfo,
SqlReferenceAnalysis,
SqlTableReference,
SqlTextSpan,
} from "@/types/database";
import { vscodeSelectionLayer } from "@/lib/codemirrorVscodeSelectionLayer";
const props = defineProps<{
@@ -195,6 +201,7 @@ function editorThemeAppearance() {
let cachedTables: Array<{ name: string; schema?: string; type?: "table" | "view" }> = [];
// Persistent column cache keyed by "schema.table" or "table"
const cachedColumnsByTable = new Map<string, SqlCompletionColumn[]>();
const cachedForeignKeysByTable = new Map<string, SqlCompletionForeignKey[]>();
const zoomCommitScheduler = createEditorZoomCommitScheduler((fontSize) => {
if (settingsStore.editorSettings.fontSize === fontSize) return;
@@ -429,6 +436,28 @@ async function ensureColumnsForTable(table: { name: string; schema?: string | nu
cachedColumnsByTable.set(cacheKey, columns);
}
async function ensureForeignKeysForTable(table: { name: string; schema?: string | null }) {
const cacheKey = completionCacheKey(table);
if (cachedForeignKeysByTable.has(cacheKey) || !props.connectionId || props.database == null) return;
const querySchema = table.schema ?? props.database;
try {
const foreignKeys = await api.listForeignKeys(props.connectionId, props.database, querySchema, table.name);
cachedForeignKeysByTable.set(
cacheKey,
foreignKeys.map((foreignKey: ForeignKeyInfo) => ({
name: foreignKey.name,
column: foreignKey.column,
ref_schema: foreignKey.ref_schema,
ref_table: foreignKey.ref_table,
ref_column: foreignKey.ref_column,
})),
);
} catch (e) {
console.warn(`[DBX] Failed to load foreign keys for ${cacheKey}:`, e);
cachedForeignKeysByTable.set(cacheKey, []);
}
}
function createHoverDom(title: string, detail: string, rows: string[] = []) {
const dom = document.createElement("div");
dom.className = "rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md";
@@ -1027,8 +1056,19 @@ async function performAsyncCompletionWithResult(
);
if (epoch !== completionEpoch) return null;
if (completionContext.suggestJoinConditions) {
await Promise.all(
refs.map(async (refTable) => {
if (refTable.columns && refTable.columns.length > 0) return;
await ensureForeignKeysForTable(refTable);
}),
);
if (epoch !== completionEpoch) return null;
}
// Build columnsByTable — from cache or CTE definitions
const columnsByTable = new Map<string, SqlCompletionColumn[]>();
const foreignKeysByTable = new Map<string, SqlCompletionForeignKey[]>();
if (insertColumnsByTable.size > 0) {
for (const [key, cols] of insertColumnsByTable.entries()) {
columnsByTable.set(key, cols);
@@ -1052,6 +1092,10 @@ async function performAsyncCompletionWithResult(
if (cached) {
columnsByTable.set(cacheKey, cached);
}
const cachedForeignKeys = cachedForeignKeysByTable.get(cacheKey);
if (cachedForeignKeys) {
foreignKeysByTable.set(cacheKey, cachedForeignKeys);
}
}
}
@@ -1068,6 +1112,7 @@ async function performAsyncCompletionWithResult(
const items = buildSqlCompletionItemsFromContext(effectiveContext, {
tables,
columnsByTable,
foreignKeysByTable,
schemas: schemaNames,
translations: completionTranslations.value,
snippets: settingsStore.editorSettings.snippets,
@@ -1088,6 +1133,7 @@ function isReferencedTableQualifier(completionContext: ReturnType<typeof getSqlC
async function refreshCompletionCache() {
cachedTables = [];
cachedColumnsByTable.clear();
cachedForeignKeysByTable.clear();
}
onMounted(async () => {
+342 -58
View File
@@ -608,6 +608,14 @@ export interface SqlCompletionColumn {
isNullable?: boolean;
}
export interface SqlCompletionForeignKey {
name: string;
column: string;
ref_schema?: string | null;
ref_table: string;
ref_column: string;
}
export interface SqlCompletionItem {
label: string;
type: "keyword" | "table" | "column" | "snippet" | "function" | "schema";
@@ -671,6 +679,7 @@ export function buildSqlCompletionItems(
input: {
tables: SqlCompletionTable[];
columnsByTable: Map<string, SqlCompletionColumn[]>;
foreignKeysByTable?: Map<string, SqlCompletionForeignKey[]>;
schemas?: string[];
translations?: SqlCompletionTranslations;
dialect?: "mysql" | "postgres" | "sqlserver";
@@ -685,6 +694,7 @@ export function buildSqlCompletionItemsFromContext(
input: {
tables: SqlCompletionTable[];
columnsByTable: Map<string, SqlCompletionColumn[]>;
foreignKeysByTable?: Map<string, SqlCompletionForeignKey[]>;
schemas?: string[];
translations?: SqlCompletionTranslations;
snippets?: SqlSnippet[];
@@ -714,7 +724,7 @@ export function buildSqlCompletionItemsFromContext(
}
if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && context.suggestJoinConditions) {
items.push(...buildJoinConditionItems(context, input.columnsByTable, dialect));
items.push(...buildJoinConditionItems(context, input.columnsByTable, input.foreignKeysByTable, dialect));
}
if (context.suggestKeywords) {
@@ -1836,6 +1846,7 @@ function buildColumnDetail(column: SqlCompletionColumn): string {
function buildJoinConditionItems(
context: SqlCompletionContext,
columnsByTable: Map<string, SqlCompletionColumn[]>,
foreignKeysByTable?: Map<string, SqlCompletionForeignKey[]>,
dialect?: "mysql" | "postgres" | "sqlserver",
): SqlCompletionItem[] {
const refs = context.referencedTables;
@@ -1849,6 +1860,7 @@ function buildJoinConditionItems(
const previousColumns = columnsForReferencedTable(previous, columnsByTable);
const latestColumns = columnsForReferencedTable(latest, columnsByTable);
items.push(
...buildForeignKeyJoinConditionItemsForPair(previous, latest, foreignKeysByTable, context.prefix, dialect),
...buildJoinConditionItemsForPair(previous, previousColumns, latest, latestColumns, context.prefix, dialect),
);
}
@@ -1868,6 +1880,128 @@ function columnsForReferencedTable(
return [];
}
function foreignKeysForReferencedTable(
table: SqlCompletionReferencedTable,
foreignKeysByTable?: Map<string, SqlCompletionForeignKey[]>,
): SqlCompletionForeignKey[] {
if (!foreignKeysByTable) return [];
const keys = table.schema ? [`${table.schema}.${table.name}`, table.name] : [table.name];
for (const key of keys) {
const foreignKeys = foreignKeysByTable.get(key);
if (foreignKeys) return foreignKeys;
}
return [];
}
function buildForeignKeyJoinConditionItemsForPair(
left: SqlCompletionReferencedTable,
right: SqlCompletionReferencedTable,
foreignKeysByTable?: Map<string, SqlCompletionForeignKey[]>,
prefix = "",
dialect?: "mysql" | "postgres" | "sqlserver",
): SqlCompletionItem[] {
if (!foreignKeysByTable) return [];
return [
...buildDirectionalForeignKeyJoinConditionItems(
left,
right,
foreignKeysForReferencedTable(left, foreignKeysByTable),
prefix,
dialect,
),
...buildDirectionalForeignKeyJoinConditionItems(
right,
left,
foreignKeysForReferencedTable(right, foreignKeysByTable),
prefix,
dialect,
),
];
}
function buildDirectionalForeignKeyJoinConditionItems(
owner: SqlCompletionReferencedTable,
referenced: SqlCompletionReferencedTable,
foreignKeys: SqlCompletionForeignKey[],
prefix: string,
dialect?: "mysql" | "postgres" | "sqlserver",
): SqlCompletionItem[] {
const matchingForeignKeys = foreignKeys.filter((foreignKey) =>
referencedTableMatchesName(referenced, foreignKey.ref_table, foreignKey.ref_schema),
);
const groups = groupForeignKeysByConstraint(matchingForeignKeys);
const items: SqlCompletionItem[] = [];
for (const group of groups) {
const parts = group.map((foreignKey) =>
buildJoinConditionPart(owner, foreignKey.column, referenced, foreignKey.ref_column, dialect),
);
const label = parts.map((part) => part.label).join(" AND ");
if (!label || (prefix && !matchesPrefix(label, prefix))) continue;
const apply = parts.map((part) => part.apply).join(" AND ");
items.push({
label,
type: "snippet",
detail: group.length > 1 ? "JOIN condition from composite foreign key" : "JOIN condition from foreign key",
apply,
boost: 3200 + group.length,
});
}
return items;
}
function buildJoinConditionPart(
owner: SqlCompletionReferencedTable,
ownerColumn: string,
referenced: SqlCompletionReferencedTable,
referencedColumn: string,
dialect?: "mysql" | "postgres" | "sqlserver",
): { label: string; apply: string } {
const ownerRef = owner.alias || owner.name;
const referencedRef = referenced.alias || referenced.name;
const ownerApplyRef = owner.alias ? owner.alias : quoteSqlIdentifier(owner.name, dialect);
const referencedApplyRef = referenced.alias ? referenced.alias : quoteSqlIdentifier(referenced.name, dialect);
return {
label: `${ownerRef}.${ownerColumn} = ${referencedRef}.${referencedColumn}`,
apply: `${ownerApplyRef}.${quoteSqlIdentifier(ownerColumn, dialect)} = ${referencedApplyRef}.${quoteSqlIdentifier(referencedColumn, dialect)}`,
};
}
function groupForeignKeysByConstraint(foreignKeys: SqlCompletionForeignKey[]): SqlCompletionForeignKey[][] {
const groups = new Map<string, SqlCompletionForeignKey[]>();
for (const foreignKey of foreignKeys) {
const key = `${foreignKey.name || `${foreignKey.column}->${foreignKey.ref_table}.${foreignKey.ref_column}`}:${foreignKey.ref_table}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(foreignKey);
}
return [...groups.values()];
}
function referencedTableMatchesName(
table: SqlCompletionReferencedTable,
candidate: string,
candidateSchema?: string | null,
): boolean {
const normalizedCandidate = normalizeTableName(candidate);
if (normalizeTableName(table.name) !== normalizedCandidate) return false;
if (!candidateSchema || !table.schema) return true;
return normalizeIdentifierPart(table.schema) === normalizeIdentifierPart(candidateSchema);
}
function normalizeTableName(name: string): string {
return name
.split(".")
.filter(Boolean)
.pop()!
.replace(/^["`[]|["`\]]$/g, "")
.toLowerCase();
}
function normalizeIdentifierPart(name: string): string {
return name.replace(/^["`[]|["`\]]$/g, "").toLowerCase();
}
function buildJoinConditionItemsForPair(
left: SqlCompletionReferencedTable,
leftColumns: SqlCompletionColumn[],
@@ -1884,72 +2018,222 @@ function buildJoinConditionItemsForPair(
const leftTableKey = singularTableName(left.name);
const rightTableKey = singularTableName(right.name);
const leftByName = indexColumnsByLowerName(leftColumns);
const rightByName = indexColumnsByLowerName(rightColumns);
const emittedPairs = new Set<string>();
const addPair = (
leftColumn: SqlCompletionColumn | undefined,
rightColumn: SqlCompletionColumn | undefined,
boost: number,
) => {
if (!leftColumn || !rightColumn || !areJoinColumnTypesCompatible(leftColumn, rightColumn)) return;
const key = `${leftColumn.name.toLowerCase()}:${rightColumn.name.toLowerCase()}`;
if (emittedPairs.has(key)) return;
emittedPairs.add(key);
const label = `${leftRef}.${leftColumn.name} = ${rightRef}.${rightColumn.name}`;
if (prefix && !matchesPrefix(label, prefix)) return;
const apply = `${leftApplyRef}.${quoteSqlIdentifier(leftColumn.name, dialect)} = ${rightApplyRef}.${quoteSqlIdentifier(rightColumn.name, dialect)}`;
items.push({
label,
type: "snippet",
detail: "JOIN condition",
apply,
boost,
});
};
const leftId = leftByName.get("id")?.[0];
const rightId = rightByName.get("id")?.[0];
// Pattern 1: a.id = b.{singular_a}_id (e.g., users.id = orders.user_id)
addPair(leftId, rightByName.get(`${leftTableKey}_id`)?.[0], 2300);
// Pattern 2: a.{singular_b}_id = b.id (e.g., orders.user_id = users.id)
addPair(leftByName.get(`${rightTableKey}_id`)?.[0], rightId, 2300);
// Pattern 3/4: same-name columns, with FK-looking names above generic shared columns.
for (const [name, leftMatches] of leftByName.entries()) {
if (name === "id") continue;
const rightMatches = rightByName.get(name);
if (!rightMatches?.length) continue;
addPair(leftMatches[0], rightMatches[0], name.endsWith("_id") ? 2000 : 1700);
}
// Pattern 5: parent_id -> id (self-referencing / hierarchical)
if (leftTableKey === rightTableKey) {
addPair(leftByName.get("parent_id")?.[0], rightId, 2100);
addPair(leftId, rightByName.get("parent_id")?.[0], 2100);
}
// Pattern 6: created_by / modified_by / owned_by -> users.id
for (const auditColumnName of ["created_by", "modified_by", "owned_by"]) {
addPair(leftId, rightByName.get(auditColumnName)?.[0], 1800);
addPair(leftByName.get(auditColumnName)?.[0], rightId, 1800);
}
// Pattern 7: Generic FK column -> id when table names do not reveal the relationship.
for (const leftColumn of leftColumns) {
for (const rightColumn of rightColumns) {
const leftName = leftColumn.name.toLowerCase();
const rightName = rightColumn.name.toLowerCase();
const leftLabel = `${leftRef}.${leftColumn.name}`;
const rightLabel = `${rightRef}.${rightColumn.name}`;
let boost = 0;
const leftName = leftColumn.name.toLowerCase();
if (leftName !== "id" && leftName.endsWith("_id")) addPair(leftColumn, rightId, 1650);
}
for (const rightColumn of rightColumns) {
const rightName = rightColumn.name.toLowerCase();
if (rightName !== "id" && rightName.endsWith("_id")) addPair(leftId, rightColumn, 1650);
}
// Pattern 1: a.id = b.{singular_a}_id (e.g., users.id = orders.user_id)
if (leftName === "id" && rightName === `${leftTableKey}_id`) {
boost = 2300;
}
// Pattern 2: a.{singular_b}_id = b.id (e.g., orders.user_id = users.id)
else if (rightName === "id" && leftName === `${rightTableKey}_id`) {
boost = 2300;
}
// Pattern 3: Same FK column name in both tables (e.g., both have user_id)
else if (leftName === rightName && leftName.endsWith("_id")) {
boost = 2000;
}
// Pattern 4: Same column name that isn't "id" (e.g., both have "code", "email")
else if (leftName !== "id" && leftName === rightName) {
boost = 1700;
}
// Pattern 5: parent_id → id (self-referencing / hierarchical)
else if (leftName === "parent_id" && rightName === "id" && leftTableKey === rightTableKey) {
boost = 2100;
} else if (rightName === "parent_id" && leftName === "id" && leftTableKey === rightTableKey) {
boost = 2100;
}
// Pattern 6: created_by / modified_by / owned_by → users.id
else if (
leftName === "id" &&
(rightName === "created_by" || rightName === "modified_by" || rightName === "owned_by")
) {
boost = 1800;
} else if (
rightName === "id" &&
(leftName === "created_by" || leftName === "modified_by" || leftName === "owned_by")
) {
boost = 1800;
}
// Pattern 7: Generic FK column → id when table names do not reveal the relationship.
else if (leftName !== "id" && leftName.endsWith("_id") && rightName === "id") {
boost = 1650;
} else if (rightName !== "id" && rightName.endsWith("_id") && leftName === "id") {
boost = 1650;
}
items.push(
...buildCompositeHeuristicJoinConditionItems(left, leftColumns, right, leftByName, rightByName, prefix, dialect),
);
if (!boost) continue;
const label = `${leftLabel} = ${rightLabel}`;
if (prefix && !matchesPrefix(label, prefix)) continue;
const apply = `${leftApplyRef}.${quoteSqlIdentifier(leftColumn.name, dialect)} = ${rightApplyRef}.${quoteSqlIdentifier(rightColumn.name, dialect)}`;
items.push({
label,
type: "snippet",
detail: "JOIN condition",
apply,
boost,
});
return items;
}
function indexColumnsByLowerName(columns: SqlCompletionColumn[]): Map<string, SqlCompletionColumn[]> {
const index = new Map<string, SqlCompletionColumn[]>();
for (const column of columns) {
const key = column.name.toLowerCase();
const existing = index.get(key);
if (existing) existing.push(column);
else index.set(key, [column]);
}
return index;
}
function buildCompositeHeuristicJoinConditionItems(
left: SqlCompletionReferencedTable,
leftColumns: SqlCompletionColumn[],
right: SqlCompletionReferencedTable,
leftByName: Map<string, SqlCompletionColumn[]>,
rightByName: Map<string, SqlCompletionColumn[]>,
prefix: string,
dialect?: "mysql" | "postgres" | "sqlserver",
): SqlCompletionItem[] {
const leftId = leftByName.get("id")?.[0];
const rightId = rightByName.get("id")?.[0];
const leftTableKey = singularTableName(left.name);
const rightTableKey = singularTableName(right.name);
const candidates: Array<{ parent: "left" | "right"; parentId: SqlCompletionColumn; childFk: SqlCompletionColumn }> =
[];
const rightNamedFk = rightByName.get(`${leftTableKey}_id`)?.[0];
const leftNamedFk = leftByName.get(`${rightTableKey}_id`)?.[0];
if (leftId && rightNamedFk && areJoinColumnTypesCompatible(leftId, rightNamedFk)) {
candidates.push({ parent: "left", parentId: leftId, childFk: rightNamedFk });
}
if (rightId && leftNamedFk && areJoinColumnTypesCompatible(leftNamedFk, rightId)) {
candidates.push({ parent: "right", parentId: rightId, childFk: leftNamedFk });
}
if (candidates.length === 0) return [];
const sharedScopeColumns = leftColumns
.map((leftColumn) => {
const name = leftColumn.name.toLowerCase();
const rightColumn = rightByName.get(name)?.[0];
if (!rightColumn || !isLikelyScopeColumnName(name) || !areJoinColumnTypesCompatible(leftColumn, rightColumn)) {
return null;
}
return { leftColumn, rightColumn };
})
.filter((value): value is { leftColumn: SqlCompletionColumn; rightColumn: SqlCompletionColumn } => !!value)
.slice(0, 2);
if (sharedScopeColumns.length === 0) return [];
const leftRef = left.alias || left.name;
const rightRef = right.alias || right.name;
const leftApplyRef = left.alias ? left.alias : quoteSqlIdentifier(left.name, dialect);
const rightApplyRef = right.alias ? right.alias : quoteSqlIdentifier(right.name, dialect);
const items: SqlCompletionItem[] = [];
for (const candidate of candidates.slice(0, 2)) {
const parts = sharedScopeColumns.map(({ leftColumn, rightColumn }) =>
buildHeuristicJoinConditionPart(leftRef, leftApplyRef, leftColumn, rightRef, rightApplyRef, rightColumn, dialect),
);
if (candidate.parent === "left") {
parts.push(
buildHeuristicJoinConditionPart(
leftRef,
leftApplyRef,
candidate.parentId,
rightRef,
rightApplyRef,
candidate.childFk,
dialect,
),
);
} else {
parts.push(
buildHeuristicJoinConditionPart(
leftRef,
leftApplyRef,
candidate.childFk,
rightRef,
rightApplyRef,
candidate.parentId,
dialect,
),
);
}
const label = parts.map((part) => part.label).join(" AND ");
if (prefix && !matchesPrefix(label, prefix)) continue;
items.push({
label,
type: "snippet",
detail: "Likely composite JOIN condition",
apply: parts.map((part) => part.apply).join(" AND "),
boost: 2400 + parts.length,
});
}
return items;
}
function buildHeuristicJoinConditionPart(
leftRef: string,
leftApplyRef: string,
leftColumn: SqlCompletionColumn,
rightRef: string,
rightApplyRef: string,
rightColumn: SqlCompletionColumn,
dialect?: "mysql" | "postgres" | "sqlserver",
): { label: string; apply: string } {
return {
label: `${leftRef}.${leftColumn.name} = ${rightRef}.${rightColumn.name}`,
apply: `${leftApplyRef}.${quoteSqlIdentifier(leftColumn.name, dialect)} = ${rightApplyRef}.${quoteSqlIdentifier(rightColumn.name, dialect)}`,
};
}
function isLikelyScopeColumnName(name: string): boolean {
return (
name !== "id" &&
(name.endsWith("_id") ||
name === "tenant" ||
name === "tenant_id" ||
name === "account_id" ||
name === "workspace_id" ||
name === "organization_id" ||
name === "org_id")
);
}
function areJoinColumnTypesCompatible(left: SqlCompletionColumn, right: SqlCompletionColumn): boolean {
const leftType = normalizeJoinType(left.dataType);
const rightType = normalizeJoinType(right.dataType);
if (!leftType || !rightType) return true;
return leftType === rightType;
}
function normalizeJoinType(dataType?: string): string | null {
if (!dataType) return null;
const type = dataType.toLowerCase();
if (/\b(uuid|uniqueidentifier)\b/.test(type)) return "uuid";
if (/\b(bigint|int8|integer|int|int4|smallint|int2|tinyint|serial|bigserial|number|numeric|decimal)\b/.test(type)) {
return "number";
}
if (/\b(char|text|clob|string|varchar|nvarchar|nchar|uuid)\b/.test(type)) return "text";
if (/\b(bool|boolean|bit)\b/.test(type)) return "boolean";
if (/\b(date|time|timestamp|datetime)\b/.test(type)) return "temporal";
return type.replace(/\(.+\)/, "").trim() || null;
}
function singularTableName(name: string): string {
const lower = name.toLowerCase();
// Irregular plurals
+1
View File
@@ -221,6 +221,7 @@ export interface IndexInfo {
export interface ForeignKeyInfo {
name: string;
column: string;
ref_schema?: string | null;
ref_table: string;
ref_column: string;
}
+3 -2
View File
@@ -1140,11 +1140,11 @@ pub async fn list_indexes(pool: &MySqlPool, database: &str, table: &str) -> Resu
pub async fn list_foreign_keys(pool: &MySqlPool, database: &str, table: &str) -> Result<Vec<ForeignKeyInfo>, String> {
let sql = format!(
"SELECT kcu.CONSTRAINT_NAME, kcu.COLUMN_NAME, \
kcu.REFERENCED_TABLE_NAME, kcu.REFERENCED_COLUMN_NAME \
kcu.REFERENCED_TABLE_SCHEMA, kcu.REFERENCED_TABLE_NAME, kcu.REFERENCED_COLUMN_NAME \
FROM information_schema.KEY_COLUMN_USAGE kcu \
WHERE kcu.TABLE_SCHEMA = {} AND kcu.TABLE_NAME = {} \
AND kcu.REFERENCED_TABLE_NAME IS NOT NULL \
ORDER BY kcu.CONSTRAINT_NAME",
ORDER BY kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION",
quote_value(database),
quote_value(table),
);
@@ -1157,6 +1157,7 @@ pub async fn list_foreign_keys(pool: &MySqlPool, database: &str, table: &str) ->
.map(|row| ForeignKeyInfo {
name: get_str_by_name(row, "CONSTRAINT_NAME"),
column: get_str_by_name(row, "COLUMN_NAME"),
ref_schema: Some(get_str_by_name(row, "REFERENCED_TABLE_SCHEMA")),
ref_table: get_str_by_name(row, "REFERENCED_TABLE_NAME"),
ref_column: get_str_by_name(row, "REFERENCED_COLUMN_NAME"),
})
+4 -3
View File
@@ -214,7 +214,7 @@ pub async fn list_foreign_keys(
) -> Result<Vec<ForeignKeyInfo>, String> {
let sql = format!(
"SELECT ac.CONSTRAINT_NAME, acc.COLUMN_NAME, \
ac2.TABLE_NAME AS R_TABLE, acc2.COLUMN_NAME AS R_COLUMN \
ac2.OWNER AS R_OWNER, ac2.TABLE_NAME AS R_TABLE, acc2.COLUMN_NAME AS R_COLUMN \
FROM ALL_CONSTRAINTS ac \
JOIN ALL_CONS_COLUMNS acc ON ac.CONSTRAINT_NAME = acc.CONSTRAINT_NAME AND ac.OWNER = acc.OWNER \
JOIN ALL_CONSTRAINTS ac2 ON ac.R_CONSTRAINT_NAME = ac2.CONSTRAINT_NAME AND ac.R_OWNER = ac2.OWNER \
@@ -234,8 +234,9 @@ pub async fn list_foreign_keys(
.map(|row| ForeignKeyInfo {
name: get_str(row, 0),
column: get_str(row, 1),
ref_table: get_str(row, 2),
ref_column: get_str(row, 3),
ref_schema: Some(get_str(row, 2)),
ref_table: get_str(row, 3),
ref_column: get_str(row, 4),
})
.collect())
}
+20 -12
View File
@@ -1099,17 +1099,24 @@ pub async fn list_foreign_keys(pool: &Pool, schema: &str, table: &str) -> Result
let client = pool.get().await.map_err(|e| e.to_string())?;
let stmt = client
.prepare_cached(
"SELECT kcu.constraint_name, kcu.column_name, \
ccu.table_name AS ref_table, ccu.column_name AS ref_column \
FROM information_schema.key_column_usage kcu \
"SELECT fk.constraint_name, fk.column_name, \
pk.table_schema AS ref_schema, pk.table_name AS ref_table, pk.column_name AS ref_column \
FROM information_schema.table_constraints tc \
JOIN information_schema.key_column_usage fk \
ON fk.constraint_name = tc.constraint_name \
AND fk.constraint_schema = tc.constraint_schema \
AND fk.table_schema = tc.table_schema \
AND fk.table_name = tc.table_name \
JOIN information_schema.referential_constraints rc \
ON kcu.constraint_name = rc.constraint_name \
AND kcu.constraint_schema = rc.constraint_schema \
JOIN information_schema.constraint_column_usage ccu \
ON rc.unique_constraint_name = ccu.constraint_name \
AND rc.unique_constraint_schema = ccu.constraint_schema \
WHERE kcu.table_schema = $1 AND kcu.table_name = $2 \
ORDER BY kcu.constraint_name",
ON rc.constraint_name = tc.constraint_name \
AND rc.constraint_schema = tc.constraint_schema \
JOIN information_schema.key_column_usage pk \
ON pk.constraint_name = rc.unique_constraint_name \
AND pk.constraint_schema = rc.unique_constraint_schema \
AND pk.ordinal_position = fk.position_in_unique_constraint \
WHERE tc.constraint_type = 'FOREIGN KEY' \
AND fk.table_schema = $1 AND fk.table_name = $2 \
ORDER BY fk.constraint_name, fk.ordinal_position",
)
.await
.map_err(|e| e.to_string())?;
@@ -1120,8 +1127,9 @@ pub async fn list_foreign_keys(pool: &Pool, schema: &str, table: &str) -> Result
.map(|row| ForeignKeyInfo {
name: row.get::<_, String>(0),
column: row.get::<_, String>(1),
ref_table: row.get::<_, String>(2),
ref_column: row.get::<_, String>(3),
ref_schema: Some(row.get::<_, String>(2)),
ref_table: row.get::<_, String>(3),
ref_column: row.get::<_, String>(4),
})
.collect())
}
+1
View File
@@ -451,6 +451,7 @@ pub async fn list_foreign_keys(pool: &SqliteHandle, _schema: &str, table: &str)
Ok(ForeignKeyInfo {
name: format!("fk_{}", row.get::<_, i32>("id")?),
column: row.get("from")?,
ref_schema: None,
ref_table: row.get("table")?,
ref_column: row.get("to")?,
})
+5 -4
View File
@@ -528,14 +528,14 @@ pub async fn list_foreign_keys(
table: &str,
) -> Result<Vec<ForeignKeyInfo>, String> {
let sql = format!(
"SELECT fk.name, c.name, rt.name, rc.name \
"SELECT fk.name, c.name, SCHEMA_NAME(rt.schema_id), rt.name, rc.name \
FROM sys.foreign_keys fk \
JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id \
JOIN sys.columns c ON fkc.parent_object_id = c.object_id AND fkc.parent_column_id = c.column_id \
JOIN sys.tables rt ON fkc.referenced_object_id = rt.object_id \
JOIN sys.columns rc ON fkc.referenced_object_id = rc.object_id AND fkc.referenced_column_id = rc.column_id \
WHERE fk.parent_object_id = OBJECT_ID('{s}.{t}') \
ORDER BY fk.name",
ORDER BY fk.name, fkc.constraint_column_id",
s = schema.replace('\'', "''"),
t = table.replace('\'', "''")
);
@@ -546,8 +546,9 @@ pub async fn list_foreign_keys(
.map(|row| ForeignKeyInfo {
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
column: row.get::<&str, _>(1).unwrap_or("").to_string(),
ref_table: row.get::<&str, _>(2).unwrap_or("").to_string(),
ref_column: row.get::<&str, _>(3).unwrap_or("").to_string(),
ref_schema: Some(row.get::<&str, _>(2).unwrap_or("").to_string()),
ref_table: row.get::<&str, _>(3).unwrap_or("").to_string(),
ref_column: row.get::<&str, _>(4).unwrap_or("").to_string(),
})
.collect())
}
+13
View File
@@ -462,6 +462,13 @@ pub fn diff_foreign_keys(source: &[ForeignKeyInfo], target: &[ForeignKeyInfo]) -
if source_fk.ref_table != target_fk.ref_table {
changes.push(format!("ref table: {} → {}", target_fk.ref_table, source_fk.ref_table));
}
if source_fk.ref_schema != target_fk.ref_schema {
changes.push(format!(
"ref schema: {} → {}",
target_fk.ref_schema.as_deref().unwrap_or(""),
source_fk.ref_schema.as_deref().unwrap_or("")
));
}
if source_fk.ref_column != target_fk.ref_column {
changes.push(format!("ref column: {} → {}", target_fk.ref_column, source_fk.ref_column));
}
@@ -872,6 +879,7 @@ mod tests {
ForeignKeyInfo {
name: if overrides.name.is_empty() { "orders_user_id_fk".to_string() } else { overrides.name },
column: if overrides.column.is_empty() { "user_id".to_string() } else { overrides.column },
ref_schema: overrides.ref_schema,
ref_table: if overrides.ref_table.is_empty() { "users".to_string() } else { overrides.ref_table },
ref_column: if overrides.ref_column.is_empty() { "id".to_string() } else { overrides.ref_column },
}
@@ -929,12 +937,14 @@ mod tests {
foreign_key(ForeignKeyInfo {
name: "orders_user_id_fk".to_string(),
column: String::new(),
ref_schema: None,
ref_table: String::new(),
ref_column: String::new(),
}),
foreign_key(ForeignKeyInfo {
name: "orders_account_id_fk".to_string(),
column: "account_id".to_string(),
ref_schema: None,
ref_table: "accounts".to_string(),
ref_column: String::new(),
}),
@@ -943,12 +953,14 @@ mod tests {
foreign_key(ForeignKeyInfo {
name: "orders_user_id_fk".to_string(),
column: String::new(),
ref_schema: None,
ref_table: "members".to_string(),
ref_column: String::new(),
}),
foreign_key(ForeignKeyInfo {
name: "orders_region_id_fk".to_string(),
column: "region_id".to_string(),
ref_schema: None,
ref_table: "regions".to_string(),
ref_column: String::new(),
}),
@@ -995,6 +1007,7 @@ mod tests {
source: Some(foreign_key(ForeignKeyInfo {
name: "orders_user_id_fk".to_string(),
column: String::new(),
ref_schema: None,
ref_table: "users".to_string(),
ref_column: String::new(),
})),
+4 -1
View File
@@ -271,7 +271,8 @@ fn generate_postgres_foreign_key_ddl(foreign_keys: &[db::ForeignKeyInfo], table:
.map(|foreign_key| quote_identifier(&foreign_key.ref_column, &DatabaseType::Postgres))
.collect::<Vec<_>>()
.join(", ");
let referenced_table = qualified_table(&group[0].ref_table, schema, &DatabaseType::Postgres);
let referenced_schema = group[0].ref_schema.as_deref().unwrap_or(schema);
let referenced_table = qualified_table(&group[0].ref_table, referenced_schema, &DatabaseType::Postgres);
statements.push(format!(
"ALTER TABLE {full_table} ADD CONSTRAINT {} FOREIGN KEY ({columns}) REFERENCES {referenced_table} ({ref_columns})",
quote_identifier(name, &DatabaseType::Postgres)
@@ -2761,12 +2762,14 @@ mod tests {
db::ForeignKeyInfo {
name: "orders_user_id_fkey".to_string(),
column: "user_id".to_string(),
ref_schema: None,
ref_table: "users".to_string(),
ref_column: "id".to_string(),
},
db::ForeignKeyInfo {
name: "orders_user_id_fkey".to_string(),
column: "tenant_id".to_string(),
ref_schema: None,
ref_table: "users".to_string(),
ref_column: "tenant_id".to_string(),
},
+2
View File
@@ -88,6 +88,8 @@ pub struct IndexInfo {
pub struct ForeignKeyInfo {
pub name: String,
pub column: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ref_schema: Option<String>,
pub ref_table: String,
pub ref_column: String,
}
+189
View File
@@ -9,6 +9,7 @@ import {
getSqlCompletionContext,
recordCompletionSelection,
type SqlCompletionColumn,
type SqlCompletionForeignKey,
type SqlCompletionTable,
} from "../../apps/desktop/src/lib/sqlCompletion.ts";
@@ -957,6 +958,194 @@ test("getSqlCompletionContext returns nonAggregatedSelectColumns", () => {
// --- Better FK join inference ---
test("prefers explicit foreign-key join condition with table aliases", () => {
const foreignKeysByTable = new Map<string, SqlCompletionForeignKey[]>([
[
"public.orders",
[{ name: "orders_customer_id_fkey", column: "customer_id", ref_table: "customers", ref_column: "id" }],
],
]);
const sql = "select * from public.orders o join public.customers c on ";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [
{ name: "orders", schema: "public", type: "table" },
{ name: "customers", schema: "public", type: "table" },
],
columnsByTable,
foreignKeysByTable,
});
assert.deepEqual(items[0], {
label: "o.customer_id = c.id",
type: "snippet",
detail: "JOIN condition from foreign key",
apply: "o.customer_id = c.id",
boost: 3201,
});
});
test("suggests explicit foreign-key join when the joined table owns the key", () => {
const foreignKeysByTable = new Map<string, SqlCompletionForeignKey[]>([
[
"public.orders",
[{ name: "orders_customer_id_fkey", column: "customer_id", ref_table: "customers", ref_column: "id" }],
],
]);
const sql = "select * from public.customers c join public.orders o on ";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [
{ name: "customers", schema: "public", type: "table" },
{ name: "orders", schema: "public", type: "table" },
],
columnsByTable,
foreignKeysByTable,
});
const fkJoin = items.find((item) => item.label === "o.customer_id = c.id");
assert.ok(fkJoin, "should suggest FK join when the right side owns the foreign key");
});
test("suggests composite explicit foreign-key join conditions", () => {
const colsWithCompositeFk = new Map<string, SqlCompletionColumn[]>([
[
"public.products",
[
{ name: "tenant_id", table: "products", schema: "public", dataType: "text" },
{ name: "id", table: "products", schema: "public", dataType: "text" },
],
],
[
"public.order_lines",
[
{ name: "tenant_id", table: "order_lines", schema: "public", dataType: "text" },
{ name: "product_id", table: "order_lines", schema: "public", dataType: "text" },
],
],
]);
const foreignKeysByTable = new Map<string, SqlCompletionForeignKey[]>([
[
"public.order_lines",
[
{ name: "order_lines_product_fkey", column: "tenant_id", ref_table: "products", ref_column: "tenant_id" },
{ name: "order_lines_product_fkey", column: "product_id", ref_table: "products", ref_column: "id" },
],
],
]);
const sql = "select * from public.order_lines ol join public.products p on ";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [
{ name: "order_lines", schema: "public", type: "table" },
{ name: "products", schema: "public", type: "table" },
],
columnsByTable: colsWithCompositeFk,
foreignKeysByTable,
});
const fkJoin = items.find((item) => item.label === "ol.tenant_id = p.tenant_id AND ol.product_id = p.id");
assert.ok(fkJoin, "should suggest full composite FK join");
assert.equal(fkJoin?.detail, "JOIN condition from composite foreign key");
});
test("uses referenced schema to disambiguate explicit foreign-key joins", () => {
const foreignKeysByTable = new Map<string, SqlCompletionForeignKey[]>([
[
"sales.orders",
[
{
name: "orders_customer_id_fkey",
column: "customer_id",
ref_schema: "crm",
ref_table: "customers",
ref_column: "id",
},
],
],
]);
const sql = "select * from sales.orders o join public.customers pc on ";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [
{ name: "orders", schema: "sales", type: "table" },
{ name: "customers", schema: "public", type: "table" },
{ name: "customers", schema: "crm", type: "table" },
],
columnsByTable,
foreignKeysByTable,
});
assert.equal(
items.some((item) => item.label === "o.customer_id = pc.id" && item.detail === "JOIN condition from foreign key"),
false,
);
});
test("suggests likely composite joins from shared scope columns and id naming", () => {
const colsWithTenantRelationship = new Map<string, SqlCompletionColumn[]>([
[
"public.orders",
[
{ name: "tenant_id", table: "orders", schema: "public", dataType: "text" },
{ name: "id", table: "orders", schema: "public", dataType: "uuid" },
],
],
[
"public.order_lines",
[
{ name: "tenant_id", table: "order_lines", schema: "public", dataType: "text" },
{ name: "order_id", table: "order_lines", schema: "public", dataType: "uuid" },
{ name: "article_id", table: "order_lines", schema: "public", dataType: "text" },
],
],
]);
const sql = "select * from public.orders o join public.order_lines ol on ";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [
{ name: "orders", schema: "public", type: "table" },
{ name: "order_lines", schema: "public", type: "table" },
],
columnsByTable: colsWithTenantRelationship,
});
const compositeJoin = items.find((item) => item.label === "o.tenant_id = ol.tenant_id AND o.id = ol.order_id");
assert.ok(compositeJoin, "should combine tenant scope with id/FK naming");
assert.equal(compositeJoin?.detail, "Likely composite JOIN condition");
});
test("does not suggest heuristic joins for incompatible column types", () => {
const colsWithIncompatibleIds = new Map<string, SqlCompletionColumn[]>([
[
"public.users",
[
{ name: "id", table: "users", schema: "public", dataType: "uuid" },
{ name: "tenant_id", table: "users", schema: "public", dataType: "text" },
],
],
[
"public.orders",
[
{ name: "user_id", table: "orders", schema: "public", dataType: "bigint" },
{ name: "tenant_id", table: "orders", schema: "public", dataType: "text" },
],
],
]);
const sql = "select * from public.users u join public.orders o on ";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [
{ name: "users", schema: "public", type: "table" },
{ name: "orders", schema: "public", type: "table" },
],
columnsByTable: colsWithIncompatibleIds,
});
assert.equal(
items.some((item) => item.label === "u.id = o.user_id"),
false,
);
assert.equal(
items.some((item) => item.label === "u.tenant_id = o.tenant_id AND u.id = o.user_id"),
false,
);
});
test("suggests join condition for same FK column in both tables", () => {
const colsWithFk = new Map<string, SqlCompletionColumn[]>([
[