mirror of
https://github.com/supabase/supabase.git
synced 2026-10-15 22:29:11 +03:00
adds FK async results
This commit is contained in:
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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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_>
|
||||
|
||||
Reference in new issue
Block a user