adds FK async results

This commit is contained in:
Jonathan Summers-Muir committed 2025-04-23 17:19:46 +08:00
1 parent 41aa1698ff
commit 615976cb7c
5 files changed
+440 -13

No files matched your search

@@ -121,7 +121,7 @@ const DefaultHeader = ({ data }: { data: any }) => {
return (
<div className="flex items-center gap-4 justify-between grow">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 grow">
{/* <DataTableFilter table={tableAdapter} /> */}
<TableEditorFilters />
</div>
@@ -1,4 +1,4 @@
import { useMemo, useState, useRef } from 'react'
import { useMemo, useState, useRef, useEffect } from 'react'
import type { Filter, FilterOperator } from '@supabase/pg-meta/src/query'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import {
@@ -9,11 +9,31 @@ import {
} from 'ui-patterns/FilterBar'
import { useTableFilter } from '../../hooks/useTableFilter'
import { FilterOperatorOptions } from './filter/Filter.constants'
import { useFilterOptions } from './filter/TableEditorFilters.utils'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useTablesQuery } from 'data/tables/tables-query'
export const TableEditorFilters = () => {
const { filters, onApplyFilters } = useTableFilter()
const snap = useTableEditorTableStateSnapshot()
const { project } = useProjectContext()
const filterBarRef = useRef<HTMLDivElement>(null)
const { isForeignKey, createForeignKeyOptionsFunction } = useFilterOptions()
// Fetch tables data to ensure we have relationships loaded
// This will provide a fallback if the relationships aren't loaded in the table state
const { data: tablesData } = useTablesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: snap.table?.schema || undefined,
includeColumns: true,
})
// Find the current table in the tables data to get relationships if needed
const tableWithRelationships = useMemo(() => {
if (!tablesData || !snap.table?.id) return null
return tablesData.find((t) => t.id === snap.table?.id)
}, [tablesData, snap.table?.id])
// Convert hook filters to FilterGroup format
const initialFilters: FilterGroup = useMemo(() => {
@@ -25,17 +45,57 @@ export const TableEditorFilters = () => {
value: filter.value,
})),
}
}, []) // Empty dependency array - only run once on initial render
}, [filters])
// Initialize with filters from the hook
const [internalFilters, setInternalFilters] = useState<FilterGroup>(initialFilters)
const [freeformText, setFreeformText] = useState('')
// Debugging the relationships
useEffect(() => {
// Log detailed table information to debug relationships
console.log('TABLE STATE DETAILS:')
console.log('- Table name:', snap.table?.name)
console.log('- Schema:', snap.table?.schema)
console.log('- Columns:', snap.table?.columns?.length)
// Check if we have relationships either from the state or the tables query
const relationships =
(snap.table as any)?.relationships || (tableWithRelationships as any)?.relationships
console.log('- Relationships:', relationships)
// If we have relationships, log their structure
if (relationships && Array.isArray(relationships)) {
console.log('RELATIONSHIP DETAILS:')
relationships.forEach((rel: any, i: number) => {
console.log(`Relationship ${i}:`, {
source: rel.source_column_name,
target: `${rel.target_table_schema}.${rel.target_table_name}.${rel.target_column_name}`,
})
})
} else {
console.log('NO RELATIONSHIPS FOUND. Check if table metadata includes relationship info.')
}
}, [snap.table, tableWithRelationships])
const filterProperties = useMemo(() => {
const properties: FilterProperty[] = []
// Get column info directly from snap
if (snap.table?.columns && Array.isArray(snap.table.columns)) {
// Access relationships safely - prefer the snap data, but fall back to tables query data
const relationships =
(snap.table as any)?.relationships || (tableWithRelationships as any)?.relationships
const schema = snap.table.schema || 'public'
console.log('Building filter properties with:', {
columnCount: snap.table.columns.length,
hasRelationships: !!relationships && Array.isArray(relationships),
relationshipCount: relationships?.length,
schema,
})
snap.table.columns.forEach((column: { name: string; format?: string }) => {
if (column.name) {
// Determine type based on column format
@@ -46,19 +106,40 @@ export const TableEditorFilters = () => {
type = 'date'
}
// Check if this column is a foreign key
const isFK = isForeignKey(relationships, column.name)
// For any foreign key column, use the dynamic foreign key options function
const options = isFK
? createForeignKeyOptionsFunction(schema, relationships, column.name)
: []
console.log(`Column '${column.name}':`, {
isFK,
format: column.format,
type,
hasOptionsFunction: isFK && typeof options === 'function',
})
properties.push({
label: column.name,
name: column.name,
type,
operators: FilterOperatorOptions.map((op) => op.value),
options: [],
options,
})
}
})
}
return properties
}, [snap.table?.columns])
}, [
snap.table?.columns,
snap.table?.schema,
tableWithRelationships,
isForeignKey,
createForeignKeyOptionsFunction,
])
const handleFilterChange = (filterGroup: FilterGroup) => {
// Update internal state
@@ -83,7 +164,7 @@ export const TableEditorFilters = () => {
}
return (
<div className="flex-1" ref={filterBarRef}>
<div ref={filterBarRef} className="w-full">
<FilterBar
filterProperties={filterProperties}
filters={internalFilters}
@@ -0,0 +1,286 @@
import { useCallback } from 'react'
import { executeSql } from 'data/sql/execute-sql-query'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
/**
* Hook for filter options in the table editor
*/
export const useFilterOptions = () => {
const { project } = useProjectContext()
/**
* Check if a column is a foreign key
*/
const isForeignKey = useCallback(
(relationships: any[] | undefined, columnName: string): boolean => {
if (!relationships || !Array.isArray(relationships)) return false
const isFK = relationships.some((rel) => rel.source_column_name === columnName)
return isFK
},
[]
)
/**
* Get the relationship object for a column if it's a foreign key
*/
const getForeignKeyRelationship = useCallback(
(relationships: any[] | undefined, columnName: string) => {
if (!relationships || !Array.isArray(relationships)) return null
const rel = relationships.find((rel) => rel.source_column_name === columnName) || null
return rel
},
[]
)
/**
* Create a function that fetches options for any foreign key column
* Returns an async function as required by FilterBar component
*/
const createForeignKeyOptionsFunction = useCallback(
(schema: string, relationships: any[] | undefined, columnName: string) => {
// Return an async function that returns options in format expected by FilterBar
return async (search?: string) => {
console.log(`Fetching options for column '${columnName}'`, { search })
if (!project?.ref) {
console.error('Cannot fetch foreign key options: No project ref available')
return []
}
try {
// Get the relationship for this column
const relationship = getForeignKeyRelationship(relationships, columnName)
if (!relationship) {
console.error(`No relationship metadata found for column '${columnName}'`)
return []
}
const targetSchema = relationship.target_table_schema
const targetTable = relationship.target_table_name
const targetColumn = relationship.target_column_name
console.log(
`Foreign key relationship found: '${columnName}' → '${targetSchema}.${targetTable}.${targetColumn}'`
)
// Get all columns of the target table with their data types
const columnsQuery = `
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = '${targetSchema}'
AND table_name = '${targetTable}'
ORDER BY ordinal_position;
`
let availableColumns: string[] = []
let descriptiveColumns: string[] = []
let searchableColumns: string[] = []
const descriptiveColumnPatterns = [
'name',
'title',
'label',
'description',
'address',
'surname',
'firstname',
'lastname',
'first_name',
'last_name',
'email',
'username',
'display',
]
// Exact column name matches to prioritize first
const exactColumnMatches = ['email', 'name', 'title', 'username', 'display_name']
let columnTypes: Record<string, string> = {}
try {
const columnsResponse = await executeSql({
projectRef: project.ref,
connectionString: project.connectionString,
sql: columnsQuery,
})
if (columnsResponse && !('error' in columnsResponse)) {
// Store column data types for debugging
columnTypes =
columnsResponse.result?.reduce((acc: Record<string, string>, row: any) => {
acc[row.column_name] = row.data_type
return acc
}, {}) || {}
availableColumns =
columnsResponse.result
?.map((row: any) => row.column_name)
.filter((col: string) => col !== targetColumn) || []
// Find exact matches
const exactMatches = availableColumns.filter((column) =>
exactColumnMatches.some((match) => column.toLowerCase() === match.toLowerCase())
)
// Find partial matches
const partialMatches = availableColumns.filter(
(column) =>
!exactMatches.includes(column) && // Don't include columns already in exactMatches
descriptiveColumnPatterns.some((pattern) =>
column.toLowerCase().includes(pattern.toLowerCase())
)
)
// Define searchable columns - these are the columns we'll search against
searchableColumns = [...exactMatches, ...partialMatches]
// Define display columns - these are shown in the results
// Prioritize exact matches, then partial matches
if (exactMatches.length < 3) {
descriptiveColumns = [
...exactMatches,
...partialMatches.slice(0, 3 - exactMatches.length),
]
} else {
descriptiveColumns = exactMatches.slice(0, 3)
}
console.log(
`Available columns in ${targetTable}:`,
availableColumns
.map((col) => `${col} (${columnTypes[col] || 'unknown type'})`)
.join(', ')
)
console.log(
`Searchable columns:`,
searchableColumns
.map((col) => `${col} (${columnTypes[col] || 'unknown type'})`)
.join(', ')
)
console.log(
`Display columns:`,
descriptiveColumns
.map((col) => `${col} (${columnTypes[col] || 'unknown type'})`)
.join(', ')
)
}
} catch (error) {
console.error('Error fetching table columns:', error)
}
// Prioritize descriptive columns for display, then fall back to first few columns
const columnsToInclude =
descriptiveColumns.length > 0
? descriptiveColumns.slice(0, 3)
: availableColumns.slice(0, 3)
console.log(
`Columns to include in query:`,
columnsToInclude
.map((col) => `${col} (${columnTypes[col] || 'unknown type'})`)
.join(', ')
)
// Build WHERE clause to search across multiple columns
let whereClause = ''
if (search && search.trim() !== '') {
const searchColumns = [...searchableColumns]
// Always include the target column in search
if (!searchColumns.includes(targetColumn)) {
searchColumns.unshift(targetColumn)
}
// Create a WHERE clause that searches across all searchable columns
const searchConditions = searchColumns
.map((col) => `${col}::text ILIKE '%${search}%'`)
.join(' OR ')
whereClause = `WHERE (${searchConditions})`
console.log(`Search WHERE clause: ${whereClause}`)
}
// SQL query to get values from the target table matching any searchable column
// Include prioritized columns in the results
const sql =
columnsToInclude.length > 0
? `
SELECT DISTINCT
${targetColumn}::text as value,
${columnsToInclude.map((col) => `${col}::text as "${col}"`).join(', ')}
FROM ${targetSchema}.${targetTable}
${whereClause}
ORDER BY ${targetColumn}::text
LIMIT 5
`
: `
SELECT DISTINCT
${targetColumn}::text as value
FROM ${targetSchema}.${targetTable}
${whereClause}
ORDER BY ${targetColumn}::text
LIMIT 5
`
console.log('Executing SQL query:', sql)
const response = await executeSql({
projectRef: project.ref,
connectionString: project.connectionString,
sql,
})
if (!response) {
console.error('SQL query returned no response')
return []
}
if ('error' in response) {
console.error('SQL query error:', response.error)
return []
}
// Log the raw results to help debug
console.log('Raw results:', response.result)
// Map to array of objects with value and label properties
// Use the ID as the value (for filtering) and formatted string as the label (for display)
const options =
response.result?.map((row: any) => {
// Generate a formatted label with additional info if available
let label = row.value
// Include prioritized descriptive columns in the display label
const additionalInfo = columnsToInclude
.map((col) => row[col])
.filter((val) => val !== null && val !== undefined && val !== '')
if (additionalInfo.length > 0) {
// Format: additional info comma separated
label = `${additionalInfo.join(', ')}`
}
// Return object with separate value and label
return {
value: row.value,
label: label,
}
}) || []
console.log(`Found ${options.length} options for '${columnName}'`, options)
return options
} catch (error) {
console.error(`Error fetching options for '${columnName}':`, error)
return []
}
}
},
[project, getForeignKeyRelationship]
)
return {
isForeignKey,
createForeignKeyOptionsFunction,
}
}
+60 -5
View File
@@ -3,20 +3,75 @@ import { isNumericalColumn } from 'components/grid/utils/types'
import { Entity, isTableLike } from 'data/table-editor/table-editor-types'
/**
* temporary fix until we implement a better filter UI
* which validate input value base on the column type
* Format filter value based on column type
* Handle foreign keys by ensuring they're properly formatted for the target column
*/
export function formatFilterValue(table: SupaTable, filter: Filter) {
const column = table.columns.find((x) => x.name == filter.column)
if (column && isNumericalColumn(column.format)) {
if (!column) {
console.log(`formatFilterValue: No column found for ${filter.column}`)
return filter.value
}
console.log(`Formatting filter for column ${filter.column} (${column.format}):`, {
originalValue: filter.value,
operator: filter.operator,
})
// Handle numerical columns
if (isNumericalColumn(column.format)) {
const numberValue = Number(filter.value)
// Supports BigInt filter values
if (Number.isNaN(numberValue) || numberValue > Number.MAX_SAFE_INTEGER) return filter.value
else return Number(filter.value)
if (Number.isNaN(numberValue) || numberValue > Number.MAX_SAFE_INTEGER) {
console.log(`Keeping as string for large/invalid number: ${filter.value}`)
return filter.value
} else {
console.log(`Converting to number: ${filter.value} -> ${numberValue}`)
return Number(filter.value)
}
}
// Check if this is a foreign key column
const isForeignKey = hasForeignKeyRelationship(table, filter.column)
if (isForeignKey) {
console.log(`Column ${filter.column} is a foreign key`)
// Foreign keys should match the target column type
// For UUID formats, ensure no spaces
if (column.format === 'uuid' && typeof filter.value === 'string') {
const trimmed = filter.value.trim()
console.log(`Trimming UUID value: "${filter.value}" -> "${trimmed}"`)
return trimmed
}
// For numeric foreign keys that might be passed as strings from the UI
if (isNumericalColumn(column.format) && !isNaN(Number(filter.value))) {
const numberValue = Number(filter.value)
console.log(`Converting FK string to number: ${filter.value} -> ${numberValue}`)
return numberValue
}
}
console.log(`Using original value for ${filter.column}: ${filter.value}`)
return filter.value
}
/**
* Check if a column has a foreign key relationship
*/
function hasForeignKeyRelationship(table: SupaTable, columnName: string): boolean {
if (!table) return false
// Access relationships if they exist on the table
const relationships = (table as any)?.relationships
if (!relationships || !Array.isArray(relationships)) return false
const hasFK = relationships.some((rel: any) => rel.source_column_name === columnName)
console.log(`Column ${columnName} foreign key check:`, { hasFK, relationships })
return hasFK
}
export function getPrimaryKeys({ table }: { table: Entity }): {
primaryKeys?: string[]
error?: ServiceError
+7 -2
View File
@@ -1020,12 +1020,17 @@ export function FilterBar({
<CommandItem_Shadcn_
key={item.value}
onSelect={() => handleCommandSelect(item.value)}
className={`text-xs font-mono ${
className={`text-xs font-mono flex items-center gap-3 ${
index === selectedCommandIndex ? 'bg-surface-400' : ''
}`}
>
{item.icon}
{item.label}
{item.value}
{item.label && (
<span className="text-xs text-foreground-lighter/50 bg-surface-75 py-0.5 rounded px-1">
{item.label}
</span>
)}
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>