Skip to content
Open
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
41 changes: 41 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,44 @@ To drive a Radix dropdown/menu (e.g. `src/components/ui/dropdownMenu.tsx`) in js
is `menu`. Disabled items have `aria-disabled="true"` and/or `data-disabled`.

Working example: `src/features/instance/databases/components/PickColumnsDropdown.test.tsx`.

## Browse — relationship/computed attributes vary by Harper version

`@relationship` and `@computed` attributes are read-only: Harper rejects any insert/update
whose record merely CONTAINS the key ("Computed property X may not be directly assigned a
value", even for `null`). Strip them from record-editor JSON (see
`functions/relationshipAttributes.ts`).

What `describe_table` reports differs by server version (verified empirically, Jul 2026):

- **Harper 4.7**: relationship attrs ARE listed — to-one as `{attribute, type: '<RelatedTable>',
properties: [...]}`, to-many as `{type: 'array', elements: '<RelatedTable>'}` — but there is
NO explicit relationship flag: the only signal is that `type`/`elements` names a sibling
table. `@computed` attrs are omitted.
- **Harper 5.1**: relationship attrs are omitted from describe entirely; `@computed` attrs appear
only with `include_computed: true`, and carry `computed: true`.

The 5.1 omission is an **unintentional regression**, not a deliberate change (traced in the Harper
source, Jul 2026). describe's `pushAtt` never filtered relationships and hasn't changed; what
changed is the v5.0 "big lift" rewrite made describe read `table.attributes` rebuilt from the
persisted attribute registry (`attributesDbi`), and relationships are runtime-only — `table()`'s
persistence loop `continue`s past them so they're never written to the registry (same in 4.7, but
4.7's describe read the schema-applied in-memory list that still held them). Harper commit
`3017e097c` (RE-7 / #1183) treats this as a bug but only partially fixes it, and there is NO
`include_relationships`-style flag to re-surface them.

Because of that, browse ALSO reads relationships from the component `schema.graphql` files
(`functions/schemaRelationships.ts` → `get_components` + `get_component_file`, parsed with the
applications schema parser). This is the ONLY source on 5.1, and it's authoritative for the exact
`@relationship(from:/to:)` key mappings even on 4.7. A schema-only relationship (not in describe)
renders as a synthesized column: to-one reads the stored foreign key (`from:`) directly from the
row; to-many links to the related table filtered by the reverse key (`to:`). `getRelationshipInfoMap`
merges the describe-detected and schema-declared sources.

Search wire contract (works on 4.7; describe-resolvable there, FK-based on 5.1): `get_attributes`
accepts nested selects `{name, select: [...]}` to resolve relationships, but a LEADING `'*'` makes
the server return raw records and ignore the rest of the list — relationship selects must come
before the `'*'`. `search_by_conditions` accepts `search_attribute: ['rel', 'subProp']` as a join
into the related table; when the sub-property IS the related primary key AND we know the local
foreign key, we query the FK directly instead (indexed, and works on 5.1's ops API which can't
execute relationship joins).
130 changes: 97 additions & 33 deletions src/features/instance/databases/components/ColumnFilters.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdownMenu';
import { Form } from '@/components/ui/form/Form';
import { FormControl } from '@/components/ui/form/FormControl';
import { FormField } from '@/components/ui/form/FormField';
import { FormItem } from '@/components/ui/form/FormItem';
import { FormMessage } from '@/components/ui/form/FormMessage';
import { Input } from '@/components/ui/input';
import { TableCell, TableHeader, TableRow } from '@/components/ui/table';
import { RelationshipAttributeInfo } from '@/features/instance/databases/functions/relationshipAttributes';
import { cn } from '@/lib/cn';
import { HeaderGroup } from '@tanstack/react-table';
import { ChevronDownIcon } from 'lucide-react';
import { KeyboardEvent, useCallback } from 'react';
import { UseFormReturn } from 'react-hook-form';
import { z } from 'zod';
Expand Down Expand Up @@ -34,42 +38,102 @@ export function ColumnFilters<TData>({
<Form {...columnFiltersForm}>
{headerGroups.map((headerGroup) => (
<TableRow key={headerGroup.id} className="border-none">
{headerGroup.headers.map((header) => (
<TableCell
key={header.id}
style={{ width: `${header.column.getSize()}px` }}
className={cn(
'sticky z-10 bg-card dark:bg-black-dark border-b border-border',
'top-[calc(var(--spacing(32))+var(--spacing(10)))]',
)}
>
{header.column.columnDef.enableColumnFilter && (
<FormField
control={columnFiltersForm.control}
name={header.id}
render={({ field }) => (
<FormItem className="border-r border-r-black">
<FormControl>
<Input
{...field}
type="text"
autoCapitalize="none"
autoComplete="off"
className="rounded-none"
onKeyDown={handleSubmit}
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</TableCell>
))}
{headerGroup.headers.map((header) => {
const relationshipInfo = header.column.columnDef.meta?.relationshipInfo;
return (
<TableCell
key={header.id}
style={{ width: `${header.column.getSize()}px` }}
className={cn(
'sticky z-10 bg-card dark:bg-black-dark border-b border-border',
'top-[calc(var(--spacing(32))+var(--spacing(10)))]',
)}
>
{header.column.columnDef.enableColumnFilter && (
<FormField
control={columnFiltersForm.control}
name={header.id}
render={({ field }) => (
<FormItem className="border-r border-r-black">
<div className="flex items-center">
{relationshipInfo && (
<SubPropertyPicker
relationshipInfo={relationshipInfo}
value={field.value ?? ''}
onChange={field.onChange}
/>
)}
<FormControl>
<Input
{...field}
type="text"
autoCapitalize="none"
autoComplete="off"
className="rounded-none"
onKeyDown={handleSubmit}
value={field.value ?? ''}
placeholder={relationshipInfo
? `.${relationshipInfo.relatedPrimaryKey} value`
: undefined}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
)}
</TableCell>
);
})}
</TableRow>
))}
</Form>
</TableHeader>
);
}

/**
* Relationship columns filter on a property of the related table (the server joins through the
* relationship). This picker inserts the `.property` prefix; the comparator/value syntax after it
* is the same as any other column filter.
*/
function SubPropertyPicker({
relationshipInfo,
value,
onChange,
}: {
relationshipInfo: RelationshipAttributeInfo;
value: string;
onChange: (value: string) => void;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-none shrink-0"
title={`Filter by a ${relationshipInfo.relatedTableName} property`}
>
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start">
{relationshipInfo.relatedAttributes.map((attribute) => (
<DropdownMenuItem
key={attribute.attribute}
onClick={() => {
const rest = value.replace(/^\s*\.\S*\s*/, '');
onChange(`.${attribute.attribute} ${rest}`);
}}
>
{attribute.attribute}
{attribute.type && <span className="text-muted-foreground text-xs">{attribute.type}</span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
102 changes: 92 additions & 10 deletions src/features/instance/databases/components/DatabaseTableView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdownMenu';
import { useInstanceClientIdParams } from '@/config/useInstanceClient';
import { formatBrowseDataTableHeader } from '@/features/instance/databases/functions/formatBrowseDataTableHeader';
import {
buildRelationshipGetAttributes,
collapsedForeignKeyNames,
getRelationshipInfoMap,
syntheticAttributeNames,
} from '@/features/instance/databases/functions/relationshipAttributes';
import { getSchemaRelationshipsQueryOptions } from '@/features/instance/databases/functions/schemaRelationships';
import { AddTableRowModal } from '@/features/instance/databases/modals/AddTableRowModal';
import { DeleteDatabaseModal } from '@/features/instance/databases/modals/DeleteDatabaseModal';
import { DeleteTableModal } from '@/features/instance/databases/modals/DeleteTableModal';
Expand Down Expand Up @@ -33,7 +40,7 @@ import { onClickStopPropagation } from '@/lib/onClickStopPropagation';
import { buildAbsoluteLinkToDatabasePage } from '@/lib/urls/buildAbsoluteLinkToDatabasePage';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate, useParams } from '@tanstack/react-router';
import { Link, useNavigate, useParams, useSearch } from '@tanstack/react-router';
import { Row, VisibilityState } from '@tanstack/react-table';
import {
BrushCleaningIcon,
Expand All @@ -51,7 +58,7 @@ import {
Trash2Icon,
TrashIcon,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { ColumnFiltersSchema } from './ColumnFilters';
Expand Down Expand Up @@ -91,9 +98,29 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName
// describe_table backfills this table's count asynchronously. Render schema from whichever arrives first
// -- the map is usually ready before describe_table -- so columns and records are never gated on the
// (slower) count scan. describe_table wins once present: it's the per-table, refresh-invalidated copy.
const tableFromMap = instanceDatabaseMap?.[databaseName]?.[tableName];
const databaseTables = instanceDatabaseMap?.[databaseName];
const tableFromMap = databaseTables?.[tableName];
const instanceTable = describeTableData ?? tableFromMap;
const attributesMap = useMemo(() => keyBy(instanceTable?.attributes ?? [], 'attribute'), [instanceTable]);
// Newer Harper servers omit relationship attributes from describe entirely; the component
// schema files still declare them (with exact from/to key mappings), so browse reads those too.
const { data: schemaRelationshipMap } = useQuery(getSchemaRelationshipsQueryOptions(instanceParams));
const schemaRelationships = schemaRelationshipMap?.[databaseName]?.[tableName];
// Relationship attributes get resolved cell values, link chips, and sub-property filters;
// they are also excluded from record add/edit JSON since the server rejects writes that
// assign them.
const relationshipInfoMap = useMemo(
() => getRelationshipInfoMap(instanceTable, databaseTables, schemaRelationships),
[instanceTable, databaseTables, schemaRelationships],
);
const relationshipGetAttributes = useMemo(
() => buildRelationshipGetAttributes(instanceTable, databaseTables),
[instanceTable, databaseTables],
);
const syntheticAttributes = useMemo(
() => syntheticAttributeNames(instanceTable?.attributes, databaseTables),
[instanceTable, databaseTables],
);
const [selectedIds, setSelectedIds] = useEffectedState<null | unknown[]>(null, allParams);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);

Expand All @@ -116,22 +143,28 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName
tableName,
]);

const applyFilters = useCallback(() => {
const translateFilterValues = useCallback((values: Record<string, string | undefined>) => {
const conditions: SearchCondition[] = [];
for (const key in columnFiltersValues) {
if (columnFiltersValues[key]?.length) {
for (const key in values) {
const value = values[key];
if (value?.length) {
try {
conditions.push(
...translateColumnFilterToSearchConditions(key, columnFiltersValues[key], attributesMap[key]),
...translateColumnFilterToSearchConditions(key, value, attributesMap[key], relationshipInfoMap[key]),
);
} catch (err) {
toast.error(String(err));
}
}
}
return conditions;
}, [attributesMap, relationshipInfoMap]);

const applyFilters = useCallback(() => {
const conditions = translateFilterValues(columnFiltersValues);
setAppliedSearchConditions(conditions.length ? conditions : null);
resetFiltersForm({ ...columnFiltersValues });
}, [attributesMap, resetFiltersForm, columnFiltersValues]);
}, [translateFilterValues, resetFiltersForm, columnFiltersValues]);
const clearFilters = useCallback(() => {
// Note sure why we need to resetFiltersForm twice here...
resetFiltersForm({}, { keepValues: false, keepDirtyValues: false, keepDefaultValues: false });
Expand All @@ -144,7 +177,43 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName
return clearFilters();
}, [allParams, clearFilters]);

const { dataTableColumns, primaryKey } = formatBrowseDataTableHeader(instanceTable);
// Deep links can carry filters (?filters={column: "value"}) — relationship cell chips use this
// to land on the related table filtered to the linked record. Applied once per distinct search,
// after the schema arrives (translation needs attribute types). When the URL filters go away
// (navigating to another table, or back to the bare table), the filter inputs are cleared too
// so they don't linger stale — `appliedSearchConditions` already resets on table change, and
// the visible inputs should stay in step with it.
const { filters: urlFilters }: { filters?: Record<string, string> } = useSearch({ strict: false });
const urlFiltersKey = JSON.stringify([databaseName, tableName, urlFilters ?? null]);
const appliedUrlFiltersKey = useRef<string | null>(null);
useEffect(function syncFiltersFromUrl() {
if (!instanceTable || appliedUrlFiltersKey.current === urlFiltersKey) {
return;
}
const isInitialSync = appliedUrlFiltersKey.current === null;
appliedUrlFiltersKey.current = urlFiltersKey;
if (urlFilters) {
resetFiltersForm({ ...urlFilters });
const conditions = translateFilterValues(urlFilters);
setAppliedSearchConditions(conditions.length ? conditions : null);
showFilters();
} else if (!isInitialSync) {
// Skip the initial mount (nothing to clear yet); otherwise drop the stale inputs a
// prior URL filter (or another table) left behind.
clearFilters();
}
}, [
urlFilters,
urlFiltersKey,
instanceTable,
resetFiltersForm,
translateFilterValues,
setAppliedSearchConditions,
showFilters,
clearFilters,
]);

const { dataTableColumns, primaryKey } = formatBrowseDataTableHeader(instanceTable, relationshipInfoMap);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isImportDataModalOpen, setIsImportDataModalOpen] = useState(false);
const [sort, setSort] = useEffectedState(
Expand Down Expand Up @@ -199,6 +268,7 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName
pageSize,
pageIndex,
onlyIfCached,
getAttributes: relationshipGetAttributes,
};
const searchByValueOptions = getSearchByValueOptions(searchByValueParams);
const { data: fullTableData, isFetching: tableDataFetching } = useQuery(searchByValueOptions);
Expand All @@ -214,6 +284,7 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName
pageSize,
pageIndex,
onlyIfCached,
getAttributes: relationshipGetAttributes,
};
const searchByConditionsOptions = getSearchByConditionsOptions(searchByConditionsParams);
const { data: filteredTableData, isFetching: tableConditionsDataFetching } = useQuery(searchByConditionsOptions);
Expand Down Expand Up @@ -269,6 +340,8 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName
const allResultsAsCSV = {
pageIndex: 0,
pageSize: 1_000_000,
// Raw records only: resolved relationship objects don't serialize usefully into CSV cells.
getAttributes: undefined,
headers: {
Accept: 'text/csv',
},
Expand Down Expand Up @@ -370,10 +443,17 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName
[navigate],
);

const [columnVisibility, setColumnVisibility] = useSessionStorage(
const [storedColumnVisibility, setColumnVisibility] = useSessionStorage(
`ColumnDisplayed/${databaseName}/${tableName}` as 'ColumnDisplayed/{database}/{table}',
{} satisfies VisibilityState,
);
// A relationship column shows the same key values as the foreign key backing it (and links
// them), so the foreign-key column is collapsed away by default. The user's own choices win:
// re-showing it from the Columns picker stores an explicit `true` that overrides the default.
const columnVisibility = useMemo((): VisibilityState => ({
...Object.fromEntries(collapsedForeignKeyNames(relationshipInfoMap).map((name) => [name, false])),
...storedColumnVisibility,
}), [relationshipInfoMap, storedColumnVisibility]);

const openDeleteTable = useSetWatchedValue('ShowDeleteTable', true);
const openDeleteDatabase = useSetWatchedValue('ShowDeleteDatabase', true);
Expand Down Expand Up @@ -543,6 +623,7 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName
{canAddRecords && instanceTable && isAddModalOpen && (
<AddTableRowModal
instanceTable={instanceTable}
databaseTables={databaseTables}
isModalOpen={isAddModalOpen}
refreshTable={refreshTable}
setIsModalOpen={setIsAddModalOpen}
Expand All @@ -554,6 +635,7 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName
setIsModalOpen={setIsEditModalOpen}
isModalOpen={isEditModalOpen}
primaryKey={primaryKey}
syntheticAttributes={syntheticAttributes}
data={searchByIdData?.data}
onSaveChanges={onRecordUpdate}
onDeleteRecord={onDeleteRecord}
Expand Down
Loading