diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index 09bdc5e3e61..8f12deb386e 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -121,7 +121,7 @@ const DefaultHeader = ({ data }: { data: any }) => { return (
-
+
{/* */}
diff --git a/apps/studio/components/grid/components/header/TableEditorFilters.tsx b/apps/studio/components/grid/components/header/TableEditorFilters.tsx index 0078bf479eb..3db2f1f4dac 100644 --- a/apps/studio/components/grid/components/header/TableEditorFilters.tsx +++ b/apps/studio/components/grid/components/header/TableEditorFilters.tsx @@ -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(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(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 ( -
+
{ + 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 = {} + + 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, 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, + } +} diff --git a/apps/studio/data/table-rows/utils.ts b/apps/studio/data/table-rows/utils.ts index 1cd131be63a..8ed48e2d47d 100644 --- a/apps/studio/data/table-rows/utils.ts +++ b/apps/studio/data/table-rows/utils.ts @@ -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 diff --git a/packages/ui-patterns/FilterBar/FilterBar.tsx b/packages/ui-patterns/FilterBar/FilterBar.tsx index add2948f83d..dc209f9f2e7 100644 --- a/packages/ui-patterns/FilterBar/FilterBar.tsx +++ b/packages/ui-patterns/FilterBar/FilterBar.tsx @@ -1020,12 +1020,17 @@ export function FilterBar({ 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 && ( + + {item.label} + + )} ))}