From b3cbacba8e77810c9801aeedfe70eac7585cd98c Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 10 Jul 2026 11:21:53 -0400 Subject: [PATCH 1/5] feat(databases): render, protect, and filter relationship attributes in browse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relationship attributes (@relationship) reported by describe_table have no explicit flag; detect them by their type/elements naming a sibling table (Harper 4.7 shape — 5.1 omits them entirely, so this stays dormant there). - Browse resolves relationship columns via nested get_attributes selects ({name, select: [relatedPk]} before '*') and renders them as link chips to the related record instead of a blank box (#1278) - Chip links deep-link with ?filters={pk: value}; the browse page applies filters from the URL search on arrival - Add/edit record JSON and random-record generation exclude relationship/ computed attributes — the server rejects writes that assign them, even null (#1202, #1317) - Relationship columns are filterable by sub-property: a picker inserts '.property', translated to search_attribute: [column, subProperty] which the server runs as a join against the related table (#1286) - Plain cells stringify objects/arrays and booleans instead of rendering '[object Object]' or nothing Verified against a local Harper 4.7.28 (features work end-to-end) and the stage 5.1.18 cluster (describe omits relationships; behavior unchanged). Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 23 ++++ .../databases/components/ColumnFilters.tsx | 130 +++++++++++++----- .../components/DatabaseTableView.tsx | 76 ++++++++-- .../components/RelationshipCell.test.ts | 30 ++++ .../databases/components/RelationshipCell.tsx | 53 +++++++ .../databases/components/TableView.tsx | 5 +- .../formatBrowseDataTableHeader.test.ts | 53 +++++++ .../functions/formatBrowseDataTableHeader.ts | 48 ++++++- .../functions/generateRandomRecords.ts | 12 +- .../functions/relationshipAttributes.test.ts | 112 +++++++++++++++ .../functions/relationshipAttributes.ts | 116 ++++++++++++++++ .../databases/modals/AddTableRowModal.tsx | 10 +- .../databases/modals/EditTableRowModal.tsx | 13 +- .../databases/modals/ImportDataModal.tsx | 10 +- src/integrations/api/api.patch.d.ts | 7 + .../database/getSearchByConditions.test.ts | 77 +++++++++++ .../database/getSearchByConditions.ts | 50 ++++++- .../api/instance/database/getSearchByValue.ts | 10 +- 18 files changed, 771 insertions(+), 64 deletions(-) create mode 100644 src/features/instance/databases/components/RelationshipCell.test.ts create mode 100644 src/features/instance/databases/components/RelationshipCell.tsx create mode 100644 src/features/instance/databases/functions/formatBrowseDataTableHeader.test.ts create mode 100644 src/features/instance/databases/functions/relationshipAttributes.test.ts create mode 100644 src/features/instance/databases/functions/relationshipAttributes.ts diff --git a/CLAUDE.md b/CLAUDE.md index c1d160130..952062ea4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,3 +103,26 @@ 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: '', + properties: [...]}`, to-many as `{type: 'array', elements: ''}` — 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 entirely (they're runtime-only, never persisted + to the attribute registry); `@computed` attrs appear only with `include_computed: true`, and + carry `computed: true`. On 5.1 the relationship browse features simply never activate. + +Search wire contract (works on 4.7; validated-but-unresolved 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 (querying the relationship attribute itself returns nothing/errors). diff --git a/src/features/instance/databases/components/ColumnFilters.tsx b/src/features/instance/databases/components/ColumnFilters.tsx index 510868239..041fd1466 100644 --- a/src/features/instance/databases/components/ColumnFilters.tsx +++ b/src/features/instance/databases/components/ColumnFilters.tsx @@ -1,3 +1,5 @@ +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'; @@ -5,8 +7,10 @@ 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'; @@ -34,42 +38,102 @@ export function ColumnFilters({
{headerGroups.map((headerGroup) => ( - {headerGroup.headers.map((header) => ( - - {header.column.columnDef.enableColumnFilter && ( - ( - - - - - - - )} - /> - )} - - ))} + {headerGroup.headers.map((header) => { + const relationshipInfo = header.column.columnDef.meta?.relationshipInfo; + return ( + + {header.column.columnDef.enableColumnFilter && ( + ( + +
+ {relationshipInfo && ( + + )} + + + +
+ +
+ )} + /> + )} +
+ ); + })}
))}
); } + +/** + * 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 ( + + + + + + {relationshipInfo.relatedAttributes.map((attribute) => ( + { + const rest = value.replace(/^\s*\.\S*\s*/, ''); + onChange(`.${attribute.attribute} ${rest}`); + }} + > + {attribute.attribute} + {attribute.type && {attribute.type}} + + ))} + + + ); +} diff --git a/src/features/instance/databases/components/DatabaseTableView.tsx b/src/features/instance/databases/components/DatabaseTableView.tsx index 68392a34d..ad1a23877 100644 --- a/src/features/instance/databases/components/DatabaseTableView.tsx +++ b/src/features/instance/databases/components/DatabaseTableView.tsx @@ -2,6 +2,11 @@ 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, + getRelationshipInfoMap, + syntheticAttributeNames, +} from '@/features/instance/databases/functions/relationshipAttributes'; import { AddTableRowModal } from '@/features/instance/databases/modals/AddTableRowModal'; import { DeleteDatabaseModal } from '@/features/instance/databases/modals/DeleteDatabaseModal'; import { DeleteTableModal } from '@/features/instance/databases/modals/DeleteTableModal'; @@ -33,7 +38,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, @@ -51,7 +56,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'; @@ -91,9 +96,25 @@ 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]); + // Relationship attributes (when the server's describe reports them) 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), + [instanceTable, databaseTables], + ); + const relationshipGetAttributes = useMemo( + () => buildRelationshipGetAttributes(instanceTable, databaseTables), + [instanceTable, databaseTables], + ); + const syntheticAttributes = useMemo( + () => syntheticAttributeNames(instanceTable?.attributes, databaseTables), + [instanceTable, databaseTables], + ); const [selectedIds, setSelectedIds] = useEffectedState(null, allParams); const [isEditModalOpen, setIsEditModalOpen] = useState(false); @@ -116,22 +137,28 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName tableName, ]); - const applyFilters = useCallback(() => { + const translateFilterValues = useCallback((values: Record) => { 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 }); @@ -144,7 +171,32 @@ 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). + const { filters: urlFilters }: { filters?: Record } = useSearch({ strict: false }); + const urlFiltersKey = JSON.stringify([databaseName, tableName, urlFilters ?? null]); + const appliedUrlFiltersKey = useRef(null); + useEffect(function applyFiltersFromUrl() { + if (!urlFilters || !instanceTable || appliedUrlFiltersKey.current === urlFiltersKey) { + return; + } + appliedUrlFiltersKey.current = urlFiltersKey; + resetFiltersForm({ ...urlFilters }); + const conditions = translateFilterValues(urlFilters); + setAppliedSearchConditions(conditions.length ? conditions : null); + showFilters(); + }, [ + urlFilters, + urlFiltersKey, + instanceTable, + resetFiltersForm, + translateFilterValues, + setAppliedSearchConditions, + showFilters, + ]); + + const { dataTableColumns, primaryKey } = formatBrowseDataTableHeader(instanceTable, databaseTables); const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [isImportDataModalOpen, setIsImportDataModalOpen] = useState(false); const [sort, setSort] = useEffectedState( @@ -199,6 +251,7 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName pageSize, pageIndex, onlyIfCached, + getAttributes: relationshipGetAttributes, }; const searchByValueOptions = getSearchByValueOptions(searchByValueParams); const { data: fullTableData, isFetching: tableDataFetching } = useQuery(searchByValueOptions); @@ -214,6 +267,7 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName pageSize, pageIndex, onlyIfCached, + getAttributes: relationshipGetAttributes, }; const searchByConditionsOptions = getSearchByConditionsOptions(searchByConditionsParams); const { data: filteredTableData, isFetching: tableConditionsDataFetching } = useQuery(searchByConditionsOptions); @@ -269,6 +323,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', }, @@ -543,6 +599,7 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName {canAddRecords && instanceTable && isAddModalOpen && ( { + it('reads the related primary key from a resolved to-one value', () => { + expect(relationshipKeyValues({ id: 'p1' }, info)).toEqual(['p1']); + }); + + it('reads each related primary key from a resolved to-many value', () => { + expect(relationshipKeyValues([{ id: 'r1' }, { id: 'r2' }], info)).toEqual(['r1', 'r2']); + }); + + it('is empty for unresolved values (older servers return null or omit the field)', () => { + expect(relationshipKeyValues(null, info)).toEqual([]); + expect(relationshipKeyValues(undefined, info)).toEqual([]); + expect(relationshipKeyValues([null], info)).toEqual([]); + }); + + it('passes through scalar values defensively', () => { + expect(relationshipKeyValues(['p1', 'p2'], info)).toEqual(['p1', 'p2']); + }); +}); diff --git a/src/features/instance/databases/components/RelationshipCell.tsx b/src/features/instance/databases/components/RelationshipCell.tsx new file mode 100644 index 000000000..3a7edadc3 --- /dev/null +++ b/src/features/instance/databases/components/RelationshipCell.tsx @@ -0,0 +1,53 @@ +import { Badge } from '@/components/ui/badge'; +import { RelationshipAttributeInfo } from '@/features/instance/databases/functions/relationshipAttributes'; +import { onClickStopPropagation } from '@/lib/onClickStopPropagation'; +import { buildAbsoluteLinkToDatabasePage } from '@/lib/urls/buildAbsoluteLinkToDatabasePage'; +import { Link, useParams } from '@tanstack/react-router'; + +const MAX_CHIPS = 3; + +/** + * Primary key values of the related records inside a resolved relationship cell value. + * Browse queries resolve relationship attributes to `{ [relatedPrimaryKey]: value }` + * (or an array of those for to-many); older servers may leave the value null/undefined. + */ +export function relationshipKeyValues(value: unknown, info: RelationshipAttributeInfo): unknown[] { + const records = Array.isArray(value) ? value : value == null ? [] : [value]; + return records + .map((record) => + record !== null && typeof record === 'object' + ? (record as Record)[info.relatedPrimaryKey] + : record + ) + .filter((keyValue) => keyValue != null); +} + +export function RelationshipCell({ value, info }: { value: unknown; info: RelationshipAttributeInfo }) { + const params: { organizationId?: string; clusterId?: string; instanceId?: string; databaseName?: string } = useParams( + { strict: false }, + ); + const keyValues = relationshipKeyValues(value, info); + if (!keyValues.length) { + return ; + } + const relatedTableLink = buildAbsoluteLinkToDatabasePage({ ...params, tableName: info.relatedTableName }); + return ( + + {keyValues.slice(0, MAX_CHIPS).map((keyValue, index) => ( + + + {String(keyValue)} + + + ))} + {keyValues.length > MAX_CHIPS && ( + +{keyValues.length - MAX_CHIPS} more + )} + + ); +} diff --git a/src/features/instance/databases/components/TableView.tsx b/src/features/instance/databases/components/TableView.tsx index 9d3be6506..330884d9b 100644 --- a/src/features/instance/databases/components/TableView.tsx +++ b/src/features/instance/databases/components/TableView.tsx @@ -188,9 +188,8 @@ function TableBodyRowCell({ cell }: { cell: Cell }) { style={{ width: `${cell.column.getSize()}px` }} className="px-2 py-2 overflow-x-hidden max-w-32 text-ellipsis whitespace-nowrap" > - {cell.getValue() == '[object Object]' - ? JSON.stringify(cell.getValue()) - : flexRender(cell.column.columnDef.cell, cell.getContext())} + {/* Object/array stringification lives in the column defs (renderPlainCell / RelationshipCell). */} + {flexRender(cell.column.columnDef.cell, cell.getContext())} ); } diff --git a/src/features/instance/databases/functions/formatBrowseDataTableHeader.test.ts b/src/features/instance/databases/functions/formatBrowseDataTableHeader.test.ts new file mode 100644 index 000000000..d75a024cf --- /dev/null +++ b/src/features/instance/databases/functions/formatBrowseDataTableHeader.test.ts @@ -0,0 +1,53 @@ +/** @vitest-environment jsdom */ +import { InstanceDatabaseTableMap, InstanceTable } from '@/integrations/api/api.patch'; +import { describe, expect, it } from 'vitest'; +import { formatBrowseDataTableHeader } from './formatBrowseDataTableHeader'; + +const relReview = { + schema: 'data', + name: 'RelReview', + primary_key: 'id', + attributes: [ + { attribute: 'id', type: 'ID', is_primary_key: true }, + { attribute: 'productId', type: 'ID', indexed: {} }, + { attribute: 'comments', type: 'String' }, + { attribute: 'product', type: 'RelProduct' }, + ], +} as InstanceTable; + +const tables: InstanceDatabaseTableMap = { + RelReview: relReview, + RelProduct: { + schema: 'data', + name: 'RelProduct', + primary_key: 'id', + attributes: [{ attribute: 'id', type: 'ID', is_primary_key: true }], + } as InstanceTable, +}; + +describe('formatBrowseDataTableHeader', () => { + it('marks relationship columns filterable and attaches relationship meta', () => { + const { dataTableColumns } = formatBrowseDataTableHeader(relReview, tables); + const byHeader = Object.fromEntries(dataTableColumns.map((column) => [column.header, column])); + + expect(byHeader.product.enableColumnFilter).toBe(true); + expect(byHeader.product.enableSorting).toBe(false); + expect(byHeader.product.meta?.relationshipInfo).toMatchObject({ + relatedTableName: 'RelProduct', + relatedPrimaryKey: 'id', + isToMany: false, + }); + + expect(byHeader.comments.enableColumnFilter).toBe(false); + expect(byHeader.comments.meta).toBeUndefined(); + }); + + it('does not attach relationship meta without the database table map', () => { + const { dataTableColumns } = formatBrowseDataTableHeader(relReview); + for (const column of dataTableColumns) { + expect(column.meta).toBeUndefined(); + } + const product = dataTableColumns.find((column) => column.header === 'product'); + expect(product?.enableColumnFilter).toBe(false); + }); +}); diff --git a/src/features/instance/databases/functions/formatBrowseDataTableHeader.ts b/src/features/instance/databases/functions/formatBrowseDataTableHeader.ts index 21866da26..7052f72d7 100644 --- a/src/features/instance/databases/functions/formatBrowseDataTableHeader.ts +++ b/src/features/instance/databases/functions/formatBrowseDataTableHeader.ts @@ -1,7 +1,24 @@ -import { InstanceAttribute, InstanceTable } from '@/integrations/api/api.patch'; -import { ColumnDef } from '@tanstack/react-table'; +import { RelationshipCell } from '@/features/instance/databases/components/RelationshipCell'; +import { + getRelationshipInfo, + RelationshipAttributeInfo, +} from '@/features/instance/databases/functions/relationshipAttributes'; +import { InstanceAttribute, InstanceDatabaseTableMap, InstanceTable } from '@/integrations/api/api.patch'; +import { CellContext, ColumnDef } from '@tanstack/react-table'; +import { createElement } from 'react'; -export function formatBrowseDataTableHeader(instanceTable?: InstanceTable): { +declare module '@tanstack/react-table' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + /** Set on relationship columns; drives the cell renderer and the sub-property filter UI. */ + relationshipInfo?: RelationshipAttributeInfo; + } +} + +export function formatBrowseDataTableHeader( + instanceTable?: InstanceTable, + databaseTables?: InstanceDatabaseTableMap, +): { dataTableColumns: Array>>; primaryKey: string; } { @@ -19,14 +36,22 @@ export function formatBrowseDataTableHeader(instanceTable?: InstanceTable): { const timeColumns: ColumnDef>[] = []; for (let i = attributes.length - 1; i >= 0; i--) { const { attribute, type, is_primary_key, indexed } = attributes[i]; + const relationshipInfo = getRelationshipInfo(attributes[i], databaseTables); const dataTableColumn: ColumnDef> = { header: attribute, accessorKey: attribute, enableSorting: Boolean(is_primary_key || indexed), - enableColumnFilter: Boolean(is_primary_key || indexed), + // Relationship columns are filterable via sub-properties (`.name value`), which the + // server executes as a join against the related table. + enableColumnFilter: Boolean(is_primary_key || indexed || relationshipInfo), // enableResizing: true, size: sizeByAttributeType(type), + cell: relationshipInfo + ? (context: CellContext, unknown>) => + createElement(RelationshipCell, { value: context.getValue(), info: relationshipInfo }) + : renderPlainCell, + meta: relationshipInfo ? { relationshipInfo } : undefined, }; if (is_primary_key) { primaryKeyColumns.push(dataTableColumn); @@ -44,6 +69,21 @@ export function formatBrowseDataTableHeader(instanceTable?: InstanceTable): { }; } +/** + * Default cell: objects and arrays render as JSON instead of the default renderer's + * `[object Object]`/blank output, and booleans render as text (React renders `false` as nothing). + */ +function renderPlainCell(context: CellContext, unknown>) { + const value = context.getValue(); + if (value == null) { + return null; + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); +} + function sizeByAttributeType(type: InstanceAttribute['type']) { switch (type) { case 'Id': diff --git a/src/features/instance/databases/functions/generateRandomRecords.ts b/src/features/instance/databases/functions/generateRandomRecords.ts index 65d1545e1..da7a01a7a 100644 --- a/src/features/instance/databases/functions/generateRandomRecords.ts +++ b/src/features/instance/databases/functions/generateRandomRecords.ts @@ -1,4 +1,5 @@ -import { InstanceAttribute } from '@/integrations/api/api.patch'; +import { isSyntheticAttribute } from '@/features/instance/databases/functions/relationshipAttributes'; +import { InstanceAttribute, InstanceDatabaseTableMap } from '@/integrations/api/api.patch'; const FIRST_NAMES = [ 'Ada', @@ -142,14 +143,19 @@ function randomValue(attribute: InstanceAttribute): unknown { } /** Attributes that random rows should fill: everything except the primary key (Harper - * auto-assigns it on insert), system timestamps, and binary columns. */ -export function randomizableAttributes(attributes: InstanceAttribute[] | undefined): InstanceAttribute[] { + * auto-assigns it on insert), system timestamps, binary columns, and relationship/computed + * attributes (the server rejects records that assign those). */ +export function randomizableAttributes( + attributes: InstanceAttribute[] | undefined, + databaseTables?: InstanceDatabaseTableMap, +): InstanceAttribute[] { return (attributes ?? []).filter((attr) => !attr.is_primary_key && attr.attribute !== '__createdtime__' && attr.attribute !== '__updatedtime__' && attr.type !== 'Bytes' && attr.type !== 'Blob' + && !isSyntheticAttribute(attr, databaseTables) ); } diff --git a/src/features/instance/databases/functions/relationshipAttributes.test.ts b/src/features/instance/databases/functions/relationshipAttributes.test.ts new file mode 100644 index 000000000..961fc7a64 --- /dev/null +++ b/src/features/instance/databases/functions/relationshipAttributes.test.ts @@ -0,0 +1,112 @@ +import { InstanceDatabaseTableMap, InstanceTable } from '@/integrations/api/api.patch'; +import { describe, expect, it } from 'vitest'; +import { + buildRelationshipGetAttributes, + getRelationshipInfo, + getRelationshipInfoMap, + isSyntheticAttribute, + syntheticAttributeNames, +} from './relationshipAttributes'; + +// Shapes copied from a Harper 4.7 describe_table response for: +// type RelProduct @table { id: ID! @primaryKey ... reviews: [RelReview] @relationship(to: "productId") } +// type RelReview @table { id: ID! @primaryKey productId: ID @indexed product: RelProduct @relationship(from: "productId") } +const relProduct = { + schema: 'data', + name: 'RelProduct', + primary_key: 'id', + attributes: [ + { attribute: 'id', type: 'ID', is_primary_key: true }, + { attribute: 'name', type: 'String', indexed: {} }, + { attribute: 'price', type: 'Float' }, + { attribute: 'tags', type: 'array', elements: 'String' }, + { attribute: 'reviews', type: 'array', elements: 'RelReview' }, + ], +} as InstanceTable; + +const relReview = { + schema: 'data', + name: 'RelReview', + primary_key: 'id', + attributes: [ + { attribute: 'id', type: 'ID', is_primary_key: true }, + { attribute: 'productId', type: 'ID', indexed: {} }, + { attribute: 'rating', type: 'Int', indexed: {} }, + { + attribute: 'product', + type: 'RelProduct', + properties: [{ name: 'id', type: 'ID' }, { name: 'name', type: 'String' }], + }, + ], +} as InstanceTable; + +const tables: InstanceDatabaseTableMap = { RelProduct: relProduct, RelReview: relReview }; + +describe('getRelationshipInfo', () => { + it('detects a to-one relationship by its table-named type', () => { + const info = getRelationshipInfo(relReview.attributes[3], tables); + expect(info).toMatchObject({ relatedTableName: 'RelProduct', relatedPrimaryKey: 'id', isToMany: false }); + // The related table's own relationship attribute (reviews) is not offered as a sub-property. + expect(info?.relatedAttributes.map((a) => a.attribute)).toEqual(['id', 'name', 'price', 'tags']); + }); + + it('detects a to-many relationship by its table-named array elements', () => { + const info = getRelationshipInfo(relProduct.attributes[4], tables); + expect(info).toMatchObject({ relatedTableName: 'RelReview', relatedPrimaryKey: 'id', isToMany: true }); + expect(info?.relatedAttributes.map((a) => a.attribute)).toEqual(['id', 'productId', 'rating']); + }); + + it('ignores scalar, scalar-array, and primary key attributes', () => { + expect(getRelationshipInfo(relProduct.attributes[0], tables)).toBeUndefined(); // ID pk + expect(getRelationshipInfo(relProduct.attributes[1], tables)).toBeUndefined(); // String + expect(getRelationshipInfo(relProduct.attributes[3], tables)).toBeUndefined(); // [String] + }); + + it('ignores computed attributes even if their type matches a table', () => { + expect(getRelationshipInfo({ attribute: 'x', type: 'RelProduct', computed: true }, tables)).toBeUndefined(); + }); + + it('returns undefined without the database table map', () => { + expect(getRelationshipInfo(relReview.attributes[3], undefined)).toBeUndefined(); + }); + + it('falls back to hash_attribute for the related primary key', () => { + const legacy = { ...tables, RelProduct: { ...relProduct, primary_key: undefined, hash_attribute: 'id' } }; + expect(getRelationshipInfo(relReview.attributes[3], legacy)?.relatedPrimaryKey).toBe('id'); + }); +}); + +describe('getRelationshipInfoMap', () => { + it('maps only relationship attributes', () => { + expect(Object.keys(getRelationshipInfoMap(relProduct, tables))).toEqual(['reviews']); + expect(Object.keys(getRelationshipInfoMap(relReview, tables))).toEqual(['product']); + }); +}); + +describe('isSyntheticAttribute / syntheticAttributeNames', () => { + it('marks computed and relationship attributes', () => { + expect(isSyntheticAttribute({ attribute: 'totalPrice', type: 'Float', computed: true }, tables)).toBe(true); + expect(isSyntheticAttribute(relReview.attributes[3], tables)).toBe(true); + expect(isSyntheticAttribute(relReview.attributes[1], tables)).toBe(false); + }); + + it('collects the names', () => { + expect(syntheticAttributeNames(relReview.attributes, tables)).toEqual(['product']); + expect(syntheticAttributeNames(undefined, tables)).toEqual([]); + }); +}); + +describe('buildRelationshipGetAttributes', () => { + it('selects each relationship by related primary key, with * last', () => { + expect(buildRelationshipGetAttributes(relProduct, tables)).toEqual([ + { name: 'reviews', select: ['id'] }, + '*', + ]); + }); + + it('returns undefined when the table has no relationships', () => { + const plain = { ...relProduct, attributes: relProduct.attributes.slice(0, 4) }; + expect(buildRelationshipGetAttributes(plain, tables)).toBeUndefined(); + expect(buildRelationshipGetAttributes(undefined, tables)).toBeUndefined(); + }); +}); diff --git a/src/features/instance/databases/functions/relationshipAttributes.ts b/src/features/instance/databases/functions/relationshipAttributes.ts new file mode 100644 index 000000000..2935b29b8 --- /dev/null +++ b/src/features/instance/databases/functions/relationshipAttributes.ts @@ -0,0 +1,116 @@ +import { InstanceAttribute, InstanceDatabaseTableMap, InstanceTable } from '@/integrations/api/api.patch'; + +/** + * Relationship attributes (`@relationship` in a table schema) are computed at read time from a + * foreign key: they cannot be written, and `describe_table` carries no explicit flag for them. + * The detectable signal is their type: a to-one relationship's `type` is the related table's + * name, and a to-many relationship is an `array` whose `elements` is the related table's name. + * Newer Harper versions (5.1+) omit relationship attributes from describe entirely, in which + * case none of this activates and browse behaves as before. + */ +export interface RelationshipAttributeInfo { + /** Name of the related table (within the same database). */ + relatedTableName: string; + /** Primary key attribute of the related table. */ + relatedPrimaryKey: string; + /** Attributes of the related table, for sub-property pickers and filter value casting. */ + relatedAttributes: InstanceAttribute[]; + /** True for one-to-many/many-to-many (the value is an array of related records). */ + isToMany: boolean; +} + +/** The table another attribute's type points at, if any: `type` for to-one, `elements` for to-many. */ +function relatedTableNameOf( + attribute: InstanceAttribute, + databaseTables: InstanceDatabaseTableMap, +): string | undefined { + const typeName = attribute.type === 'array' ? attribute.elements : attribute.type; + return typeName && databaseTables[typeName] ? typeName : undefined; +} + +export function getRelationshipInfo( + attribute: InstanceAttribute, + databaseTables: InstanceDatabaseTableMap | undefined, +): RelationshipAttributeInfo | undefined { + if (!databaseTables || attribute.is_primary_key || attribute.computed) { + return undefined; + } + const relatedTableName = relatedTableNameOf(attribute, databaseTables); + const relatedTable = relatedTableName ? databaseTables[relatedTableName] : undefined; + if (!relatedTableName || !relatedTable) { + return undefined; + } + const relatedPrimaryKey = relatedTable.primary_key ?? relatedTable.hash_attribute; + if (!relatedPrimaryKey) { + return undefined; + } + return { + relatedTableName, + relatedPrimaryKey, + // Exclude the related table's own relationship/computed attributes: filters join one hop + // (`[column, subProperty]`), so deeper synthetic attributes can't be queried through here. + relatedAttributes: (relatedTable.attributes ?? []).filter( + (related) => !related.computed && !relatedTableNameOf(related, databaseTables), + ), + isToMany: attribute.type === 'array', + }; +} + +/** Map of attribute name → relationship info for every relationship attribute of a table. */ +export function getRelationshipInfoMap( + instanceTable: InstanceTable | undefined, + databaseTables: InstanceDatabaseTableMap | undefined, +): Record { + const map: Record = {}; + for (const attribute of instanceTable?.attributes ?? []) { + const info = getRelationshipInfo(attribute, databaseTables); + if (info) { + map[attribute.attribute] = info; + } + } + return map; +} + +/** + * Attributes the server computes and refuses to accept in writes: `@computed` attributes and + * relationship attributes. Including one in an insert/update — even as `null` — fails with + * "Computed property X may not be directly assigned a value". + */ +export function isSyntheticAttribute( + attribute: InstanceAttribute, + databaseTables: InstanceDatabaseTableMap | undefined, +): boolean { + return Boolean(attribute.computed) || getRelationshipInfo(attribute, databaseTables) !== undefined; +} + +export function syntheticAttributeNames( + attributes: InstanceAttribute[] | undefined, + databaseTables: InstanceDatabaseTableMap | undefined, +): string[] { + return (attributes ?? []) + .filter((attribute) => isSyntheticAttribute(attribute, databaseTables)) + .map((attribute) => attribute.attribute); +} + +export type GetAttribute = string | { name: string; select: string[] }; + +/** + * `get_attributes` list that resolves relationship attributes to their related records' primary + * keys. Plain `['*']` returns raw records with relationship attributes missing (blank cells); + * naming them with a nested select resolves them. The `'*'` must come last: a leading `'*'` + * makes the server return raw records and ignore the rest of the list. + * Returns undefined when the table has no relationship attributes (callers fall back to `['*']`). + */ +export function buildRelationshipGetAttributes( + instanceTable: InstanceTable | undefined, + databaseTables: InstanceDatabaseTableMap | undefined, +): GetAttribute[] | undefined { + const selects: GetAttribute[] = []; + for (const attribute of instanceTable?.attributes ?? []) { + const info = getRelationshipInfo(attribute, databaseTables); + if (info) { + selects.push({ name: attribute.attribute, select: [info.relatedPrimaryKey] }); + } + } + return selects.length ? [...selects, '*'] : undefined; +} diff --git a/src/features/instance/databases/modals/AddTableRowModal.tsx b/src/features/instance/databases/modals/AddTableRowModal.tsx index 5aaf5c21b..0639912fa 100644 --- a/src/features/instance/databases/modals/AddTableRowModal.tsx +++ b/src/features/instance/databases/modals/AddTableRowModal.tsx @@ -2,8 +2,9 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; +import { isSyntheticAttribute } from '@/features/instance/databases/functions/relationshipAttributes'; import { useMonacoTheme } from '@/hooks/useMonacoTheme'; -import { InstanceAttribute, InstanceTable } from '@/integrations/api/api.patch'; +import { InstanceAttribute, InstanceDatabaseTableMap, InstanceTable } from '@/integrations/api/api.patch'; import { useInsertTableRecords } from '@/integrations/api/instance/database/insertTableRecords'; import { Editor } from '@/lib/monaco/MonacoEditor'; import { pluralize } from '@/lib/pluralize'; @@ -15,10 +16,12 @@ import { toast } from 'sonner'; export function AddTableRowModal({ isModalOpen, instanceTable, + databaseTables, setIsModalOpen, refreshTable, }: { instanceTable: InstanceTable; + databaseTables?: InstanceDatabaseTableMap; isModalOpen: boolean; setIsModalOpen: (open: boolean) => void; refreshTable: () => void; @@ -38,13 +41,16 @@ export function AddTableRowModal({ if ( attribute.is_primary_key || attribute.attribute === '__createdtime__' || attribute.attribute === '__updatedtime__' + // Relationship/computed attributes are read-only: the server rejects records that + // assign them, even with null. + || isSyntheticAttribute(attribute, databaseTables) ) { continue; } sample[attribute.attribute] = defaultByAttributeType(attribute.type); } return JSON.stringify(sample, null, 4); - }, [instanceTable]); + }, [instanceTable, databaseTables]); const onValidate = useCallback((markers: unknown[]) => { setMadeChanges(true); diff --git a/src/features/instance/databases/modals/EditTableRowModal.tsx b/src/features/instance/databases/modals/EditTableRowModal.tsx index 70729fb23..ac22a93ce 100644 --- a/src/features/instance/databases/modals/EditTableRowModal.tsx +++ b/src/features/instance/databases/modals/EditTableRowModal.tsx @@ -12,6 +12,7 @@ export function EditTableRowModal({ setIsModalOpen, isModalOpen, primaryKey, + syntheticAttributes, data, onSaveChanges, onDeleteRecord, @@ -23,6 +24,9 @@ export function EditTableRowModal({ setIsModalOpen: (open: boolean) => void; isModalOpen: boolean; primaryKey: string; + /** Relationship/computed attribute names — read-only, so they are hidden from the editable JSON + * (saving a record that assigns one fails, even with null). */ + syntheticAttributes?: string[]; data: { __createdtime__?: number; __updatedtime__?: number; [record: string]: unknown }[]; onSaveChanges: (data: Record[]) => void; onDeleteRecord: (data: unknown[]) => void; @@ -35,9 +39,14 @@ export function EditTableRowModal({ const [updatedTableRecordData, setUpdatedTableRecordData] = useState(); const value = useMemo(() => { - const dataWithoutTimes = data?.map(({ __createdtime__, __updatedtime__, ...rowWithoutTime }) => rowWithoutTime); + const dataWithoutTimes = data?.map(({ __createdtime__, __updatedtime__, ...rowWithoutTime }) => { + for (const synthetic of syntheticAttributes ?? []) { + delete rowWithoutTime[synthetic]; + } + return rowWithoutTime; + }); return JSON.stringify(dataWithoutTimes, null, 4); - }, [data]); + }, [data, syntheticAttributes]); const onValidate = useCallback((markers: unknown[]) => { setMadeChanges(true); setIsValidJSON(markers.length === 0); diff --git a/src/features/instance/databases/modals/ImportDataModal.tsx b/src/features/instance/databases/modals/ImportDataModal.tsx index ad86b69ef..5c1341591 100644 --- a/src/features/instance/databases/modals/ImportDataModal.tsx +++ b/src/features/instance/databases/modals/ImportDataModal.tsx @@ -177,7 +177,10 @@ export function ImportDataModal({ const tableWillBeCreated = !!instanceDatabaseMap && !!watchedTable && !tableExists; // Random data needs existing columns to model values on, so the option only appears // when the target table already has some beyond the primary key and system fields. - const fillableAttributes = randomizableAttributes(targetTable?.attributes); + const fillableAttributes = randomizableAttributes( + targetTable?.attributes, + instanceDatabaseMap?.[watchedDatabase || 'data'], + ); const canGenerateRandom = fillableAttributes.length > 0; const isRandomDataset = datasetId === RANDOM_DATASET_ID; @@ -230,7 +233,10 @@ export function ImportDataModal({ let source: ImportSource; if (values.method === 'sample' && values.datasetId === RANDOM_DATASET_ID) { - const attributes = randomizableAttributes(instanceDatabaseMap?.[database]?.[table]?.attributes); + const attributes = randomizableAttributes( + instanceDatabaseMap?.[database]?.[table]?.attributes, + instanceDatabaseMap?.[database], + ); if (attributes.length === 0) { form.setError('datasetId', { message: 'Random data needs an existing table with columns — pick a table that has some.', diff --git a/src/integrations/api/api.patch.d.ts b/src/integrations/api/api.patch.d.ts index 72b639517..bacf9e731 100644 --- a/src/integrations/api/api.patch.d.ts +++ b/src/integrations/api/api.patch.d.ts @@ -164,8 +164,15 @@ export interface InstanceTable { export interface InstanceAttribute { attribute: string; + /** Scalar type name, `'array'`, or — for relationship attributes — the related table's type name. */ type?: 'ID' | 'String' | 'Int' | 'Long' | 'Float' | 'BigInt' | 'Boolean' | 'Any' | 'Date' | 'Bytes' | 'Blob' | string; + /** Element type of an `array` attribute: a scalar name, or a table name for to-many relationships. */ + elements?: string; is_primary_key?: boolean; indexed?: boolean | unknown; nullable?: boolean; + /** True for `@computed` attributes (server only returns them when asked with `include_computed`). */ + computed?: boolean; + /** Sub-fields of an object-typed attribute (includes to-one relationship targets on Harper 4.x). */ + properties?: Array<{ name: string; type?: string }>; } diff --git a/src/integrations/api/instance/database/getSearchByConditions.test.ts b/src/integrations/api/instance/database/getSearchByConditions.test.ts index 22bd99c00..da543bda1 100644 --- a/src/integrations/api/instance/database/getSearchByConditions.test.ts +++ b/src/integrations/api/instance/database/getSearchByConditions.test.ts @@ -333,3 +333,80 @@ describe('translateColumnFilterToSearchCondition', () => { return { attribute: 'col', type }; } }); + +describe('translateColumnFilterToSearchCondition for relationship columns', () => { + const relationshipInfo = { + relatedTableName: 'RelProduct', + relatedPrimaryKey: 'id', + relatedAttributes: [ + { attribute: 'id', type: 'ID' }, + { attribute: 'name', type: 'String' }, + { attribute: 'rating', type: 'Int' }, + ], + isToMany: false, + }; + + it('translates ".subProperty value" into a join path condition', () => { + expect( + translateColumnFilterToSearchCondition('product', '.name Anvil', undefined, relationshipInfo), + ).toEqual({ + search_attribute: ['product', 'name'], + search_type: 'equals', + search_value: 'Anvil', + }); + }); + + it('casts the value by the sub-property type and honors comparators', () => { + expect( + translateColumnFilterToSearchCondition('product', '.rating >= 4', undefined, relationshipInfo), + ).toEqual({ + search_attribute: ['product', 'rating'], + search_type: 'greater_than_equal', + search_value: 4, + }); + }); + + it('supports starts_with on sub-properties', () => { + expect( + translateColumnFilterToSearchCondition('product', '.name Anv*', undefined, relationshipInfo), + ).toEqual({ + search_attribute: ['product', 'name'], + search_type: 'starts_with', + search_value: 'Anv', + }); + }); + + it('strips a redundant leading column name (product.name value)', () => { + expect( + translateColumnFilterToSearchCondition('product', 'product.name Anvil', undefined, relationshipInfo), + ).toEqual({ + search_attribute: ['product', 'name'], + search_type: 'equals', + search_value: 'Anvil', + }); + }); + + it('auto-casts unknown sub-properties', () => { + expect( + translateColumnFilterToSearchCondition('product', '.custom 42', undefined, relationshipInfo), + ).toEqual({ + search_attribute: ['product', 'custom'], + search_type: 'equals', + search_value: 42, + }); + }); + + it('requires a sub-property prefix', () => { + expect(() => translateColumnFilterToSearchCondition('product', 'Anvil', undefined, relationshipInfo)) + .toThrowError(/\.id value/); + }); + + it('splits multiple conditions with & like plain columns', () => { + expect( + translateColumnFilterToSearchConditions('product', '.rating >= 4 & .rating < 9', undefined, relationshipInfo), + ).toEqual([ + { search_attribute: ['product', 'rating'], search_type: 'greater_than_equal', search_value: 4 }, + { search_attribute: ['product', 'rating'], search_type: 'less_than', search_value: 9 }, + ]); + }); +}); diff --git a/src/integrations/api/instance/database/getSearchByConditions.ts b/src/integrations/api/instance/database/getSearchByConditions.ts index b021f43a9..73523af8d 100644 --- a/src/integrations/api/instance/database/getSearchByConditions.ts +++ b/src/integrations/api/instance/database/getSearchByConditions.ts @@ -1,4 +1,8 @@ import { InstanceClientIdConfig } from '@/config/instanceClientConfig'; +import { + GetAttribute, + RelationshipAttributeInfo, +} from '@/features/instance/databases/functions/relationshipAttributes'; import { InstanceAttribute } from '@/integrations/api/api.patch'; import { translateKnownBooleanTypedValue } from '@/lib/boolean/translateKnownBooleanTypedValue'; import { autoCast } from '@/lib/casting/autoCast'; @@ -13,6 +17,8 @@ interface GetSearchByConditionsParams extends InstanceClientIdConfig { pageIndex: number; pageSize: number; onlyIfCached: boolean; + /** Defaults to `['*']` (raw records). Pass nested selects to resolve relationship attributes. */ + getAttributes?: GetAttribute[]; headers?: Record; } @@ -31,7 +37,9 @@ type Comparator = | 'starts_with'; export interface SearchCondition { - search_attribute: string; + /** A single attribute, or a path into a relationship (e.g. `['owner', 'name']`) which the + * server executes as a join against the related table. */ + search_attribute: string | string[]; search_type: Comparator; search_value: unknown; } @@ -44,7 +52,7 @@ interface SearchByConditionsRequest { sort?: { attribute: string; descending: boolean }; offset: number; limit: number; - get_attributes?: string[]; + get_attributes?: GetAttribute[]; onlyIfCached: boolean; noCacheStore: boolean; } @@ -60,6 +68,7 @@ export function getSearchByConditionsOptions(params: GetSearchByConditionsParams pageIndex, pageSize, onlyIfCached, + getAttributes, } = params; // starts_with, equals, etc return queryOptions({ @@ -75,6 +84,7 @@ export function getSearchByConditionsOptions(params: GetSearchByConditionsParams pageIndex || 0, pageSize || 0, onlyIfCached, + getAttributes ?? null, ] as const, staleTime: 60_000, gcTime: 5_000, @@ -92,13 +102,14 @@ export function getSearchByConditions>({ pageIndex, pageSize, onlyIfCached, + getAttributes, headers, }: Omit) { return instanceClient.post( '/', { operation: 'search_by_conditions', - get_attributes: ['*'], + get_attributes: getAttributes ?? ['*'], database: databaseName, table: tableName, conditions: conditions!, @@ -116,19 +127,24 @@ export function translateColumnFilterToSearchConditions( key: string, rawValues: string, attribute: InstanceAttribute | undefined, + relationshipInfo?: RelationshipAttributeInfo, ): SearchCondition[] { const split = rawValues.split(/ & /); - return split.map(rawValue => translateColumnFilterToSearchCondition(key, rawValue, attribute)); + return split.map(rawValue => translateColumnFilterToSearchCondition(key, rawValue, attribute, relationshipInfo)); } export function translateColumnFilterToSearchCondition( key: string, rawValue: string, attribute: InstanceAttribute | undefined, + relationshipInfo?: RelationshipAttributeInfo, ): SearchCondition { if (rawValue.startsWith(key)) { rawValue = rawValue.substring(key.length); } + if (relationshipInfo) { + return translateRelationshipColumnFilter(key, rawValue, relationshipInfo); + } const { comparator, value } = parseComparator(rawValue); switch (attribute?.type) { case 'ID': @@ -185,6 +201,32 @@ export function translateColumnFilterToSearchCondition( } } +/** + * A relationship column filters on a sub-property of the related table — `.name Anvil`, + * `.rating >=4` — which the server runs as a join (`search_attribute: [column, subProperty]`). + * Filtering the relationship itself has no meaning to the server, so a missing `.subProperty` + * prefix is an error rather than a silently empty result. + */ +function translateRelationshipColumnFilter( + key: string, + rawValue: string, + relationshipInfo: RelationshipAttributeInfo, +): SearchCondition { + const match = rawValue.match(/^\s*\.([^.\s]+)\s*(.*)$/); + if (!match) { + throw new Error( + `Filter ${key} on a property of ${relationshipInfo.relatedTableName}, e.g. ".${relationshipInfo.relatedPrimaryKey} value".`, + ); + } + const [, subProperty, rest] = match; + const subAttribute = relationshipInfo.relatedAttributes.find((related) => related.attribute === subProperty); + const condition = translateColumnFilterToSearchCondition(subProperty, rest, subAttribute); + return { + ...condition, + search_attribute: [key, subProperty], + }; +} + const comparatorEqualityPrefixMappings: Record = { // equals '=== ': 'equals', diff --git a/src/integrations/api/instance/database/getSearchByValue.ts b/src/integrations/api/instance/database/getSearchByValue.ts index 452844a6d..87cc62c07 100644 --- a/src/integrations/api/instance/database/getSearchByValue.ts +++ b/src/integrations/api/instance/database/getSearchByValue.ts @@ -1,4 +1,5 @@ import { InstanceClientIdConfig } from '@/config/instanceClientConfig'; +import { GetAttribute } from '@/features/instance/databases/functions/relationshipAttributes'; import { queryOptions } from '@tanstack/react-query'; interface GetSearchByValueParams extends InstanceClientIdConfig { @@ -10,6 +11,8 @@ interface GetSearchByValueParams extends InstanceClientIdConfig { pageIndex: number; pageSize: number; onlyIfCached: boolean; + /** Defaults to `['*']` (raw records). Pass nested selects to resolve relationship attributes. */ + getAttributes?: GetAttribute[]; headers?: Record; } @@ -22,7 +25,7 @@ interface SearchByValueRequest { sort?: { attribute: string; descending: boolean }; offset: number; limit: number; - get_attributes?: string[]; + get_attributes?: GetAttribute[]; onlyIfCached: boolean; noCacheStore: boolean; } @@ -38,6 +41,7 @@ export function getSearchByValueOptions(params: GetSearchByValueParams) { pageIndex, pageSize, onlyIfCached, + getAttributes, } = params; return queryOptions({ enabled, @@ -52,6 +56,7 @@ export function getSearchByValueOptions(params: GetSearchByValueParams) { pageIndex || 0, pageSize || 0, onlyIfCached, + getAttributes ?? null, ] as const, retry: false, staleTime: 60_000, @@ -69,6 +74,7 @@ export async function getSearchByValue>({ pageIndex, pageSize, onlyIfCached, + getAttributes, headers, }: Omit) { const customizedSort = sort.attribute.length && !(sort.attribute === searchAttribute && !sort.descending); @@ -76,7 +82,7 @@ export async function getSearchByValue>({ '/', { operation: 'search_by_value', - get_attributes: ['*'], + get_attributes: getAttributes ?? ['*'], database: databaseName, table: tableName, search_attribute: searchAttribute, From 585f5a79e0b7c62f59d028fbd5483d9d8f4bf9ae Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 10 Jul 2026 11:32:26 -0400 Subject: [PATCH 2/5] feat(databases): collapse foreign-key columns behind their relationship column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relationship chips show the same key values the foreign key stores (the related record's primary key), so showing both 'product' and 'productId' is redundant. The foreign key backing a relationship is inferred by naming convention — product→productId/product_id, pets→petIds (describe_table does not expose the @relationship from/to mapping) — and hidden by default. It stays listed in the Columns picker, and re-showing it stores an explicit override that wins over the default, so unconventional pairings or raw-key sorting are one click away. Reverse (to:) relationships have no local key and collapse nothing. Record add/edit JSON is unaffected: the foreign key remains the writable field. Co-Authored-By: Claude Fable 5 --- .../components/DatabaseTableView.tsx | 10 +++- .../functions/relationshipAttributes.test.ts | 49 +++++++++++++++++++ .../functions/relationshipAttributes.ts | 47 ++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/src/features/instance/databases/components/DatabaseTableView.tsx b/src/features/instance/databases/components/DatabaseTableView.tsx index ad1a23877..f7a442e57 100644 --- a/src/features/instance/databases/components/DatabaseTableView.tsx +++ b/src/features/instance/databases/components/DatabaseTableView.tsx @@ -4,6 +4,7 @@ import { useInstanceClientIdParams } from '@/config/useInstanceClient'; import { formatBrowseDataTableHeader } from '@/features/instance/databases/functions/formatBrowseDataTableHeader'; import { buildRelationshipGetAttributes, + collapsedForeignKeyNames, getRelationshipInfoMap, syntheticAttributeNames, } from '@/features/instance/databases/functions/relationshipAttributes'; @@ -426,10 +427,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(instanceTable, databaseTables).map((name) => [name, false])), + ...storedColumnVisibility, + }), [instanceTable, databaseTables, storedColumnVisibility]); const openDeleteTable = useSetWatchedValue('ShowDeleteTable', true); const openDeleteDatabase = useSetWatchedValue('ShowDeleteDatabase', true); diff --git a/src/features/instance/databases/functions/relationshipAttributes.test.ts b/src/features/instance/databases/functions/relationshipAttributes.test.ts index 961fc7a64..e26e689ce 100644 --- a/src/features/instance/databases/functions/relationshipAttributes.test.ts +++ b/src/features/instance/databases/functions/relationshipAttributes.test.ts @@ -2,9 +2,11 @@ import { InstanceDatabaseTableMap, InstanceTable } from '@/integrations/api/api. import { describe, expect, it } from 'vitest'; import { buildRelationshipGetAttributes, + collapsedForeignKeyNames, getRelationshipInfo, getRelationshipInfoMap, isSyntheticAttribute, + relationshipForeignKeyName, syntheticAttributeNames, } from './relationshipAttributes'; @@ -110,3 +112,50 @@ describe('buildRelationshipGetAttributes', () => { expect(buildRelationshipGetAttributes(undefined, tables)).toBeUndefined(); }); }); + +describe('relationshipForeignKeyName / collapsedForeignKeyNames', () => { + it('pairs a to-one relationship with its conventional foreign key', () => { + const info = getRelationshipInfo(relReview.attributes[3], tables)!; + expect(relationshipForeignKeyName('product', info, relReview.attributes, tables)).toBe('productId'); + }); + + it('matches snake_case foreign keys case-insensitively', () => { + const attributes = [ + { attribute: 'id', type: 'ID', is_primary_key: true }, + { attribute: 'product_id', type: 'ID', indexed: {} }, + { attribute: 'product', type: 'RelProduct' }, + ]; + const info = getRelationshipInfo(attributes[2], tables)!; + expect(relationshipForeignKeyName('product', info, attributes, tables)).toBe('product_id'); + }); + + it('pairs a many-to-many relationship with its plural/singular id-array key', () => { + const attributes = [ + { attribute: 'id', type: 'ID', is_primary_key: true }, + { attribute: 'petIds', type: 'array', elements: 'ID', indexed: {} }, + { attribute: 'pets', type: 'array', elements: 'RelReview' }, + ]; + const info = getRelationshipInfo(attributes[2], tables)!; + expect(relationshipForeignKeyName('pets', info, attributes, tables)).toBe('petIds'); + }); + + it('finds nothing for reverse (to:) relationships whose key lives on the other table', () => { + const info = getRelationshipInfo(relProduct.attributes[4], tables)!; + expect(relationshipForeignKeyName('reviews', info, relProduct.attributes, tables)).toBeUndefined(); + }); + + it('never pairs with the primary key or another relationship', () => { + const attributes = [ + { attribute: 'productId', type: 'ID', is_primary_key: true }, + { attribute: 'product', type: 'RelProduct' }, + ]; + const info = getRelationshipInfo(attributes[1], tables)!; + expect(relationshipForeignKeyName('product', info, attributes, tables)).toBeUndefined(); + }); + + it('collects collapsed keys per table', () => { + expect(collapsedForeignKeyNames(relReview, tables)).toEqual(['productId']); + expect(collapsedForeignKeyNames(relProduct, tables)).toEqual([]); + expect(collapsedForeignKeyNames(undefined, tables)).toEqual([]); + }); +}); diff --git a/src/features/instance/databases/functions/relationshipAttributes.ts b/src/features/instance/databases/functions/relationshipAttributes.ts index 2935b29b8..60aa7e48b 100644 --- a/src/features/instance/databases/functions/relationshipAttributes.ts +++ b/src/features/instance/databases/functions/relationshipAttributes.ts @@ -92,6 +92,53 @@ export function syntheticAttributeNames( .map((attribute) => attribute.attribute); } +/** + * The stored foreign-key attribute a relationship reads from, inferred by naming convention — + * `product` → `productId`, `pets` → `petIds` — since describe_table does not expose the + * `@relationship(from/to)` mapping. Browse collapses the pair into just the relationship column + * (the chips show the same key values), with the foreign key re-showable from the Columns picker + * in case the inference is wrong for an unconventionally named key. + */ +export function relationshipForeignKeyName( + attributeName: string, + info: RelationshipAttributeInfo, + attributes: InstanceAttribute[], + databaseTables: InstanceDatabaseTableMap | undefined, +): string | undefined { + const bases = [attributeName]; + if (info.isToMany && attributeName.endsWith('s')) { + bases.push(attributeName.slice(0, -1)); + } + const suffixes = info.isToMany ? ['ids', '_ids'] : ['id', '_id']; + const candidates = new Set(bases.flatMap((base) => suffixes.map((suffix) => (base + suffix).toLowerCase()))); + return attributes.find((attribute) => + !attribute.is_primary_key + && !attribute.computed + && !getRelationshipInfo(attribute, databaseTables) + && candidates.has(attribute.attribute.toLowerCase()) + )?.attribute; +} + +/** Foreign-key column names browse hides by default because a relationship column covers them. */ +export function collapsedForeignKeyNames( + instanceTable: InstanceTable | undefined, + databaseTables: InstanceDatabaseTableMap | undefined, +): string[] { + const attributes = instanceTable?.attributes ?? []; + const names = new Set(); + for (const attribute of attributes) { + const info = getRelationshipInfo(attribute, databaseTables); + if (!info) { + continue; + } + const foreignKey = relationshipForeignKeyName(attribute.attribute, info, attributes, databaseTables); + if (foreignKey) { + names.add(foreignKey); + } + } + return [...names]; +} + export type GetAttribute = string | { name: string; select: string[] }; /** From 6eb8b070730a05ed85e7f159daf6c6e4e17acff0 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 10 Jul 2026 11:43:59 -0400 Subject: [PATCH 3/5] feat(databases): reverse-key links for legacy relationships + readable link chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tables created before Harper 5 carry legacy relationship attributes whose describe output is just {type: 'array'} with no element type (e.g. Albums.tracks): detect those by matching the attribute name against sibling table names (tracks → Tracks/Track). They can't be resolved via nested selects, so the cell instead links to the related table filtered by the reverse foreign key — inferred from a back-reference to-one relationship on the related table (RelReview.product → productId) or the Id naming convention (Tracks.albumId) — landing on exactly this row's related records. Resolved to-many cells reuse the same link for '+N more'. Chips now use the app's link blue (blue / blue-300 in dark) instead of outline's text-foreground, so they read as links in both themes instead of blending into neighboring values. Co-Authored-By: Claude Fable 5 --- .../databases/components/RelationshipCell.tsx | 59 ++++++++++++-- .../functions/formatBrowseDataTableHeader.ts | 8 +- .../functions/relationshipAttributes.test.ts | 61 +++++++++++++++ .../functions/relationshipAttributes.ts | 77 +++++++++++++++++-- 4 files changed, 191 insertions(+), 14 deletions(-) diff --git a/src/features/instance/databases/components/RelationshipCell.tsx b/src/features/instance/databases/components/RelationshipCell.tsx index 3a7edadc3..535fc0950 100644 --- a/src/features/instance/databases/components/RelationshipCell.tsx +++ b/src/features/instance/databases/components/RelationshipCell.tsx @@ -6,6 +6,10 @@ import { Link, useParams } from '@tanstack/react-router'; const MAX_CHIPS = 3; +/** The app's link blue (readable in both themes, unlike the near-background primary purple). */ +const linkChipClassName = 'text-blue dark:text-blue-300 border-blue/40 dark:border-blue-300/40 ' + + '[a&]:hover:bg-blue/10 [a&]:hover:underline'; + /** * Primary key values of the related records inside a resolved relationship cell value. * Browse queries resolve relationship attributes to `{ [relatedPrimaryKey]: value }` @@ -22,19 +26,50 @@ export function relationshipKeyValues(value: unknown, info: RelationshipAttribut .filter((keyValue) => keyValue != null); } -export function RelationshipCell({ value, info }: { value: unknown; info: RelationshipAttributeInfo }) { +export function RelationshipCell({ + value, + rowKeyValue, + info, +}: { + value: unknown; + /** This row's primary key value, for reverse-key links into the related table. */ + rowKeyValue?: unknown; + info: RelationshipAttributeInfo; +}) { const params: { organizationId?: string; clusterId?: string; instanceId?: string; databaseName?: string } = useParams( { strict: false }, ); const keyValues = relationshipKeyValues(value, info); + const relatedTableLink = buildAbsoluteLinkToDatabasePage({ ...params, tableName: info.relatedTableName }); + // Filter on the related table's own key pointing back at this row: all related records at once. + const reverseSearch = info.reverseForeignKey && rowKeyValue != null + ? { filters: { [info.reverseForeignKey]: String(rowKeyValue) } } + : undefined; + if (!keyValues.length) { - return ; + // Unresolved (legacy attribute registries, or servers that don't resolve relationships): + // without values, the reverse key is still enough to link to the related records. + if (!reverseSearch) { + return ; + } + return ( + + + {info.relatedTableName} → + + + ); } - const relatedTableLink = buildAbsoluteLinkToDatabasePage({ ...params, tableName: info.relatedTableName }); + const overflow = keyValues.length - MAX_CHIPS; return ( {keyValues.slice(0, MAX_CHIPS).map((keyValue, index) => ( - + ))} - {keyValues.length > MAX_CHIPS && ( - +{keyValues.length - MAX_CHIPS} more - )} + {overflow > 0 && (reverseSearch + ? ( + + +{overflow} more + + ) + : +{overflow} more)} ); } diff --git a/src/features/instance/databases/functions/formatBrowseDataTableHeader.ts b/src/features/instance/databases/functions/formatBrowseDataTableHeader.ts index 7052f72d7..2b0bd4baf 100644 --- a/src/features/instance/databases/functions/formatBrowseDataTableHeader.ts +++ b/src/features/instance/databases/functions/formatBrowseDataTableHeader.ts @@ -36,7 +36,7 @@ export function formatBrowseDataTableHeader( const timeColumns: ColumnDef>[] = []; for (let i = attributes.length - 1; i >= 0; i--) { const { attribute, type, is_primary_key, indexed } = attributes[i]; - const relationshipInfo = getRelationshipInfo(attributes[i], databaseTables); + const relationshipInfo = getRelationshipInfo(attributes[i], databaseTables, instanceTable); const dataTableColumn: ColumnDef> = { header: attribute, @@ -49,7 +49,11 @@ export function formatBrowseDataTableHeader( size: sizeByAttributeType(type), cell: relationshipInfo ? (context: CellContext, unknown>) => - createElement(RelationshipCell, { value: context.getValue(), info: relationshipInfo }) + createElement(RelationshipCell, { + value: context.getValue(), + rowKeyValue: context.row.original[primaryKey], + info: relationshipInfo, + }) : renderPlainCell, meta: relationshipInfo ? { relationshipInfo } : undefined, }; diff --git a/src/features/instance/databases/functions/relationshipAttributes.test.ts b/src/features/instance/databases/functions/relationshipAttributes.test.ts index e26e689ce..c83adb63c 100644 --- a/src/features/instance/databases/functions/relationshipAttributes.test.ts +++ b/src/features/instance/databases/functions/relationshipAttributes.test.ts @@ -159,3 +159,64 @@ describe('relationshipForeignKeyName / collapsedForeignKeyNames', () => { expect(collapsedForeignKeyNames(undefined, tables)).toEqual([]); }); }); + +describe('legacy elements-less relationships and reverse foreign keys', () => { + // Shapes copied from a live 5.1.18 cluster whose Albums/Tracks tables were created under an + // older Harper: the legacy attribute registry kept `tracks` but without its element type. + const albums = { + schema: 'data', + name: 'Albums', + primary_key: 'id', + attributes: [ + { attribute: 'id', type: 'ID', is_primary_key: true }, + { attribute: 'name', type: 'String', indexed: {} }, + { attribute: 'tracks', type: 'array' }, + ], + } as InstanceTable; + const tracksTable = { + schema: 'data', + name: 'Tracks', + primary_key: 'id', + attributes: [ + { attribute: 'id', type: 'ID', is_primary_key: true }, + { attribute: 'albumId', type: 'ID', indexed: {} }, + { attribute: 'name', type: 'String', indexed: {} }, + ], + } as InstanceTable; + const albumTables: InstanceDatabaseTableMap = { Albums: albums, Tracks: tracksTable }; + + it('detects an elements-less array by matching its name against sibling tables', () => { + const info = getRelationshipInfo(albums.attributes[2], albumTables, albums); + expect(info).toMatchObject({ + relatedTableName: 'Tracks', + isToMany: true, + resolvable: false, + reverseForeignKey: 'albumId', + }); + }); + + it('does not treat scalar arrays as relationships even if a table shares their name', () => { + const withTagsTable: InstanceDatabaseTableMap = { + ...albumTables, + Tags: tracksTable, + }; + expect(getRelationshipInfo({ attribute: 'tags', type: 'array', elements: 'String' }, withTagsTable)) + .toBeUndefined(); + }); + + it('omits unresolvable relationships from get_attributes', () => { + expect(buildRelationshipGetAttributes(albums, albumTables)).toBeUndefined(); + }); + + it('only infers the reverse key when the owner table is known', () => { + expect(getRelationshipInfo(albums.attributes[2], albumTables)?.reverseForeignKey).toBeUndefined(); + }); + + it('finds the reverse key through the related table back-reference relationship', () => { + // RelProduct.reviews: RelReview carries `product` (to-one back at RelProduct) whose + // foreign key is productId — the naming convention (relproductid) would never match. + const info = getRelationshipInfo(relProduct.attributes[4], tables, relProduct); + expect(info?.reverseForeignKey).toBe('productId'); + expect(info?.resolvable).toBe(true); + }); +}); diff --git a/src/features/instance/databases/functions/relationshipAttributes.ts b/src/features/instance/databases/functions/relationshipAttributes.ts index 60aa7e48b..7ccd93089 100644 --- a/src/features/instance/databases/functions/relationshipAttributes.ts +++ b/src/features/instance/databases/functions/relationshipAttributes.ts @@ -17,6 +17,12 @@ export interface RelationshipAttributeInfo { relatedAttributes: InstanceAttribute[]; /** True for one-to-many/many-to-many (the value is an array of related records). */ isToMany: boolean; + /** False when describe carried no element type (legacy attribute registries): the values + * can't be requested via a nested select, so cells fall back to the reverse-key link. */ + resolvable: boolean; + /** Attribute on the related table pointing back at this row's primary key (e.g. Tracks.albumId + * for Albums.tracks). Lets cells link to the related table filtered to this row's records. */ + reverseForeignKey?: string; } /** The table another attribute's type points at, if any: `type` for to-one, `elements` for to-many. */ @@ -24,13 +30,32 @@ function relatedTableNameOf( attribute: InstanceAttribute, databaseTables: InstanceDatabaseTableMap, ): string | undefined { - const typeName = attribute.type === 'array' ? attribute.elements : attribute.type; - return typeName && databaseTables[typeName] ? typeName : undefined; + if (attribute.type === 'array') { + if (attribute.elements) { + return databaseTables[attribute.elements] ? attribute.elements : undefined; + } + // Tables created before Harper 5 can carry legacy relationship attributes whose describe + // output is just {type: 'array'} with no element type. Fall back to matching the attribute + // name against sibling table names (tracks → Tracks / Track). + return findSiblingTableByName(databaseTables, attribute.attribute); + } + return attribute.type && databaseTables[attribute.type] ? attribute.type : undefined; +} + +function findSiblingTableByName( + databaseTables: InstanceDatabaseTableMap, + attributeName: string, +): string | undefined { + const lowered = attributeName.toLowerCase(); + const variants = new Set([lowered, `${lowered}s`, lowered.replace(/s$/, '')]); + return Object.keys(databaseTables).find((tableName) => variants.has(tableName.toLowerCase())); } export function getRelationshipInfo( attribute: InstanceAttribute, databaseTables: InstanceDatabaseTableMap | undefined, + /** The table the attribute belongs to; when given, to-many infos get a `reverseForeignKey`. */ + ownerTable?: InstanceTable, ): RelationshipAttributeInfo | undefined { if (!databaseTables || attribute.is_primary_key || attribute.computed) { return undefined; @@ -44,6 +69,7 @@ export function getRelationshipInfo( if (!relatedPrimaryKey) { return undefined; } + const isToMany = attribute.type === 'array'; return { relatedTableName, relatedPrimaryKey, @@ -52,10 +78,51 @@ export function getRelationshipInfo( relatedAttributes: (relatedTable.attributes ?? []).filter( (related) => !related.computed && !relatedTableNameOf(related, databaseTables), ), - isToMany: attribute.type === 'array', + isToMany, + resolvable: !isToMany || Boolean(attribute.elements), + reverseForeignKey: isToMany && ownerTable + ? inferReverseForeignKey(ownerTable, relatedTable, databaseTables) + : undefined, }; } +/** + * The attribute on the related table that points back at the owner table's primary key — + * Tracks.albumId for Albums.tracks. Preferred source: a to-one relationship on the related table + * whose target is the owner table (its foreign key is the reverse key by definition). Fallback: + * the `Id` naming convention. + */ +function inferReverseForeignKey( + ownerTable: InstanceTable, + relatedTable: InstanceTable, + databaseTables: InstanceDatabaseTableMap, +): string | undefined { + const relatedAttributes = relatedTable.attributes ?? []; + for (const attribute of relatedAttributes) { + const backReference = getRelationshipInfo(attribute, databaseTables); + if (backReference && !backReference.isToMany && backReference.relatedTableName === ownerTable.name) { + const foreignKey = relationshipForeignKeyName( + attribute.attribute, + backReference, + relatedAttributes, + databaseTables, + ); + if (foreignKey) { + return foreignKey; + } + } + } + const base = ownerTable.name.toLowerCase(); + const candidates = new Set( + [base, base.replace(/s$/, '')].flatMap((name) => [`${name}id`, `${name}_id`]), + ); + return relatedAttributes.find((attribute) => + !attribute.is_primary_key + && !attribute.computed + && candidates.has(attribute.attribute.toLowerCase()) + )?.attribute; +} + /** Map of attribute name → relationship info for every relationship attribute of a table. */ export function getRelationshipInfoMap( instanceTable: InstanceTable | undefined, @@ -63,7 +130,7 @@ export function getRelationshipInfoMap( ): Record { const map: Record = {}; for (const attribute of instanceTable?.attributes ?? []) { - const info = getRelationshipInfo(attribute, databaseTables); + const info = getRelationshipInfo(attribute, databaseTables, instanceTable); if (info) { map[attribute.attribute] = info; } @@ -155,7 +222,7 @@ export function buildRelationshipGetAttributes( const selects: GetAttribute[] = []; for (const attribute of instanceTable?.attributes ?? []) { const info = getRelationshipInfo(attribute, databaseTables); - if (info) { + if (info?.resolvable) { selects.push({ name: attribute.attribute, select: [info.relatedPrimaryKey] }); } } From ad876004255612be748c8c425000b362e8671c13 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 10 Jul 2026 14:28:17 -0400 Subject: [PATCH 4/5] feat(databases): read relationships from component schemas for Harper 5.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harper 5.1 omits @relationship attributes from describe_table entirely (an unintentional regression from the v5.0 rewrite: describe now reads the persisted attribute registry, which never holds runtime-only relationship attrs — traced to Harper's RE-7/#1183, no server flag to re-surface them). So browse also parses the component schema.graphql files (get_components + get_component_file) to learn relationships — the only source on 5.1, and authoritative for the exact from/to key mappings even on 4.7. - schemaRelationships.ts: fetch + parse schema files into database->table-> relationship declarations (reuses the applications schema parser) - getRelationshipInfoMap merges describe-detected and schema-declared relationships; foreignKeyAttribute is now exact (from:) when declared - schema-only relationships (absent from describe) render as a synthesized column: to-one reads the stored foreign key from the row; to-many links to the related table filtered by the reverse key (to:) - filtering a relationship by its related primary key queries the local foreign key directly (indexed; works on 5.1's ops API which can't join) - clearing URL filters (nav away / back to bare table) now clears the stale filter inputs, keeping them in step with appliedSearchConditions Verified end-to-end on the live 5.1.18 Anvils cluster: Product shows a 'category' chip (from stored categoryId), Category shows a 'products' reverse-link, both navigations filter correctly. Full suite 1308 pass; tsc/oxlint/dprint clean. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 36 +++-- .../components/DatabaseTableView.tsx | 46 ++++-- .../databases/components/RelationshipCell.tsx | 15 +- .../formatBrowseDataTableHeader.test.ts | 23 ++- .../functions/formatBrowseDataTableHeader.ts | 50 ++++-- .../functions/relationshipAttributes.test.ts | 79 +++++++++- .../functions/relationshipAttributes.ts | 85 +++++++--- .../functions/schemaRelationships.test.ts | 77 +++++++++ .../functions/schemaRelationships.ts | 147 ++++++++++++++++++ .../database/getSearchByConditions.ts | 8 + 10 files changed, 497 insertions(+), 69 deletions(-) create mode 100644 src/features/instance/databases/functions/schemaRelationships.test.ts create mode 100644 src/features/instance/databases/functions/schemaRelationships.ts diff --git a/CLAUDE.md b/CLAUDE.md index 952062ea4..550cf4636 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,12 +117,30 @@ What `describe_table` reports differs by server version (verified empirically, J properties: [...]}`, to-many as `{type: 'array', elements: ''}` — 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 entirely (they're runtime-only, never persisted - to the attribute registry); `@computed` attrs appear only with `include_computed: true`, and - carry `computed: true`. On 5.1 the relationship browse features simply never activate. - -Search wire contract (works on 4.7; validated-but-unresolved 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 (querying the relationship attribute itself returns nothing/errors). +- **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). diff --git a/src/features/instance/databases/components/DatabaseTableView.tsx b/src/features/instance/databases/components/DatabaseTableView.tsx index f7a442e57..8ddad50ca 100644 --- a/src/features/instance/databases/components/DatabaseTableView.tsx +++ b/src/features/instance/databases/components/DatabaseTableView.tsx @@ -8,6 +8,7 @@ import { 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'; @@ -101,12 +102,16 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName const tableFromMap = databaseTables?.[tableName]; const instanceTable = describeTableData ?? tableFromMap; const attributesMap = useMemo(() => keyBy(instanceTable?.attributes ?? [], 'attribute'), [instanceTable]); - // Relationship attributes (when the server's describe reports them) 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. + // 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), - [instanceTable, databaseTables], + () => getRelationshipInfoMap(instanceTable, databaseTables, schemaRelationships), + [instanceTable, databaseTables, schemaRelationships], ); const relationshipGetAttributes = useMemo( () => buildRelationshipGetAttributes(instanceTable, databaseTables), @@ -174,19 +179,29 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName // 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). + // 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 } = useSearch({ strict: false }); const urlFiltersKey = JSON.stringify([databaseName, tableName, urlFilters ?? null]); const appliedUrlFiltersKey = useRef(null); - useEffect(function applyFiltersFromUrl() { - if (!urlFilters || !instanceTable || appliedUrlFiltersKey.current === urlFiltersKey) { + useEffect(function syncFiltersFromUrl() { + if (!instanceTable || appliedUrlFiltersKey.current === urlFiltersKey) { return; } + const isInitialSync = appliedUrlFiltersKey.current === null; appliedUrlFiltersKey.current = urlFiltersKey; - resetFiltersForm({ ...urlFilters }); - const conditions = translateFilterValues(urlFilters); - setAppliedSearchConditions(conditions.length ? conditions : null); - showFilters(); + 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, @@ -195,9 +210,10 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName translateFilterValues, setAppliedSearchConditions, showFilters, + clearFilters, ]); - const { dataTableColumns, primaryKey } = formatBrowseDataTableHeader(instanceTable, databaseTables); + const { dataTableColumns, primaryKey } = formatBrowseDataTableHeader(instanceTable, relationshipInfoMap); const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [isImportDataModalOpen, setIsImportDataModalOpen] = useState(false); const [sort, setSort] = useEffectedState( @@ -435,9 +451,9 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName // 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(instanceTable, databaseTables).map((name) => [name, false])), + ...Object.fromEntries(collapsedForeignKeyNames(relationshipInfoMap).map((name) => [name, false])), ...storedColumnVisibility, - }), [instanceTable, databaseTables, storedColumnVisibility]); + }), [relationshipInfoMap, storedColumnVisibility]); const openDeleteTable = useSetWatchedValue('ShowDeleteTable', true); const openDeleteDatabase = useSetWatchedValue('ShowDeleteDatabase', true); diff --git a/src/features/instance/databases/components/RelationshipCell.tsx b/src/features/instance/databases/components/RelationshipCell.tsx index 535fc0950..3361ae712 100644 --- a/src/features/instance/databases/components/RelationshipCell.tsx +++ b/src/features/instance/databases/components/RelationshipCell.tsx @@ -28,18 +28,25 @@ export function relationshipKeyValues(value: unknown, info: RelationshipAttribut export function RelationshipCell({ value, - rowKeyValue, + record, + primaryKey, info, }: { value: unknown; - /** This row's primary key value, for reverse-key links into the related table. */ - rowKeyValue?: unknown; + /** The full row, for the stored foreign key and the primary key (reverse-key links). */ + record?: Record; + primaryKey?: string; info: RelationshipAttributeInfo; }) { const params: { organizationId?: string; clusterId?: string; instanceId?: string; databaseName?: string } = useParams( { strict: false }, ); - const keyValues = relationshipKeyValues(value, info); + const rowKeyValue = primaryKey ? record?.[primaryKey] : undefined; + let keyValues = relationshipKeyValues(value, info); + if (!keyValues.length && info.foreignKeyAttribute && record) { + // Unresolved by the server, but the stored foreign key holds the related key(s) directly. + keyValues = relationshipKeyValues(record[info.foreignKeyAttribute], info); + } const relatedTableLink = buildAbsoluteLinkToDatabasePage({ ...params, tableName: info.relatedTableName }); // Filter on the related table's own key pointing back at this row: all related records at once. const reverseSearch = info.reverseForeignKey && rowKeyValue != null diff --git a/src/features/instance/databases/functions/formatBrowseDataTableHeader.test.ts b/src/features/instance/databases/functions/formatBrowseDataTableHeader.test.ts index d75a024cf..b884d1033 100644 --- a/src/features/instance/databases/functions/formatBrowseDataTableHeader.test.ts +++ b/src/features/instance/databases/functions/formatBrowseDataTableHeader.test.ts @@ -2,6 +2,7 @@ import { InstanceDatabaseTableMap, InstanceTable } from '@/integrations/api/api.patch'; import { describe, expect, it } from 'vitest'; import { formatBrowseDataTableHeader } from './formatBrowseDataTableHeader'; +import { getRelationshipInfoMap } from './relationshipAttributes'; const relReview = { schema: 'data', @@ -27,7 +28,7 @@ const tables: InstanceDatabaseTableMap = { describe('formatBrowseDataTableHeader', () => { it('marks relationship columns filterable and attaches relationship meta', () => { - const { dataTableColumns } = formatBrowseDataTableHeader(relReview, tables); + const { dataTableColumns } = formatBrowseDataTableHeader(relReview, getRelationshipInfoMap(relReview, tables)); const byHeader = Object.fromEntries(dataTableColumns.map((column) => [column.header, column])); expect(byHeader.product.enableColumnFilter).toBe(true); @@ -42,7 +43,7 @@ describe('formatBrowseDataTableHeader', () => { expect(byHeader.comments.meta).toBeUndefined(); }); - it('does not attach relationship meta without the database table map', () => { + it('does not attach relationship meta without relationship info', () => { const { dataTableColumns } = formatBrowseDataTableHeader(relReview); for (const column of dataTableColumns) { expect(column.meta).toBeUndefined(); @@ -50,4 +51,22 @@ describe('formatBrowseDataTableHeader', () => { const product = dataTableColumns.find((column) => column.header === 'product'); expect(product?.enableColumnFilter).toBe(false); }); + + it('synthesizes columns for schema-declared relationships missing from describe', () => { + // Harper 5.1 regime: describe carries only the stored attributes. + const described = { ...relReview, attributes: relReview.attributes.slice(0, 3) }; + const infoMap = getRelationshipInfoMap(described, tables, [ + { attribute: 'product', relatedTableName: 'RelProduct', isToMany: false, from: 'productId' }, + ]); + const { dataTableColumns } = formatBrowseDataTableHeader(described, infoMap); + const product = dataTableColumns.find((column) => column.header === 'product'); + expect(product).toBeDefined(); + expect(product?.enableColumnFilter).toBe(true); + expect(product?.enableSorting).toBe(false); + expect(product?.meta?.relationshipInfo).toMatchObject({ + relatedTableName: 'RelProduct', + foreignKeyAttribute: 'productId', + resolvable: false, + }); + }); }); diff --git a/src/features/instance/databases/functions/formatBrowseDataTableHeader.ts b/src/features/instance/databases/functions/formatBrowseDataTableHeader.ts index 2b0bd4baf..6bbd2d6c6 100644 --- a/src/features/instance/databases/functions/formatBrowseDataTableHeader.ts +++ b/src/features/instance/databases/functions/formatBrowseDataTableHeader.ts @@ -1,9 +1,6 @@ import { RelationshipCell } from '@/features/instance/databases/components/RelationshipCell'; -import { - getRelationshipInfo, - RelationshipAttributeInfo, -} from '@/features/instance/databases/functions/relationshipAttributes'; -import { InstanceAttribute, InstanceDatabaseTableMap, InstanceTable } from '@/integrations/api/api.patch'; +import { RelationshipAttributeInfo } from '@/features/instance/databases/functions/relationshipAttributes'; +import { InstanceAttribute, InstanceTable } from '@/integrations/api/api.patch'; import { CellContext, ColumnDef } from '@tanstack/react-table'; import { createElement } from 'react'; @@ -17,7 +14,7 @@ declare module '@tanstack/react-table' { export function formatBrowseDataTableHeader( instanceTable?: InstanceTable, - databaseTables?: InstanceDatabaseTableMap, + relationshipInfoMap: Record = {}, ): { dataTableColumns: Array>>; primaryKey: string; @@ -34,9 +31,19 @@ export function formatBrowseDataTableHeader( const sortableColumns: ColumnDef>[] = []; const normalColumns: ColumnDef>[] = []; const timeColumns: ColumnDef>[] = []; + + const relationshipCell = + (info: RelationshipAttributeInfo) => (context: CellContext, unknown>) => + createElement(RelationshipCell, { + value: context.getValue(), + record: context.row.original, + primaryKey, + info, + }); + for (let i = attributes.length - 1; i >= 0; i--) { const { attribute, type, is_primary_key, indexed } = attributes[i]; - const relationshipInfo = getRelationshipInfo(attributes[i], databaseTables, instanceTable); + const relationshipInfo = relationshipInfoMap[attribute]; const dataTableColumn: ColumnDef> = { header: attribute, @@ -47,14 +54,7 @@ export function formatBrowseDataTableHeader( enableColumnFilter: Boolean(is_primary_key || indexed || relationshipInfo), // enableResizing: true, size: sizeByAttributeType(type), - cell: relationshipInfo - ? (context: CellContext, unknown>) => - createElement(RelationshipCell, { - value: context.getValue(), - rowKeyValue: context.row.original[primaryKey], - info: relationshipInfo, - }) - : renderPlainCell, + cell: relationshipInfo ? relationshipCell(relationshipInfo) : renderPlainCell, meta: relationshipInfo ? { relationshipInfo } : undefined, }; if (is_primary_key) { @@ -67,6 +67,26 @@ export function formatBrowseDataTableHeader( normalColumns.push(dataTableColumn); } } + + // Relationships known only from component schemas (Harper 5.1 omits relationship attributes + // from describe) get a synthesized column: rows carry no value for them, so the cell renders + // from the stored foreign key or the reverse-key link. + const describedAttributes = new Set(attributes.map((attribute) => attribute.attribute)); + for (const [attribute, relationshipInfo] of Object.entries(relationshipInfoMap)) { + if (describedAttributes.has(attribute)) { + continue; + } + normalColumns.push({ + header: attribute, + accessorKey: attribute, + enableSorting: false, + enableColumnFilter: true, + size: sizeByAttributeType('String'), + cell: relationshipCell(relationshipInfo), + meta: { relationshipInfo }, + }); + } + return { dataTableColumns: [...primaryKeyColumns, ...sortableColumns, ...normalColumns, ...timeColumns], primaryKey, diff --git a/src/features/instance/databases/functions/relationshipAttributes.test.ts b/src/features/instance/databases/functions/relationshipAttributes.test.ts index c83adb63c..f50ee0ab6 100644 --- a/src/features/instance/databases/functions/relationshipAttributes.test.ts +++ b/src/features/instance/databases/functions/relationshipAttributes.test.ts @@ -153,10 +153,10 @@ describe('relationshipForeignKeyName / collapsedForeignKeyNames', () => { expect(relationshipForeignKeyName('product', info, attributes, tables)).toBeUndefined(); }); - it('collects collapsed keys per table', () => { - expect(collapsedForeignKeyNames(relReview, tables)).toEqual(['productId']); - expect(collapsedForeignKeyNames(relProduct, tables)).toEqual([]); - expect(collapsedForeignKeyNames(undefined, tables)).toEqual([]); + it('collects collapsed keys from the relationship info map', () => { + expect(collapsedForeignKeyNames(getRelationshipInfoMap(relReview, tables))).toEqual(['productId']); + expect(collapsedForeignKeyNames(getRelationshipInfoMap(relProduct, tables))).toEqual([]); + expect(collapsedForeignKeyNames({})).toEqual([]); }); }); @@ -220,3 +220,74 @@ describe('legacy elements-less relationships and reverse foreign keys', () => { expect(info?.resolvable).toBe(true); }); }); + +describe('getRelationshipInfoMap with schema-declared relationships', () => { + // Harper 5.1 regime: describe reports only stored attributes; the component schema declares + // category/products with exact from/to mappings. + const category = { + schema: 'data', + name: 'Category', + primary_key: 'id', + attributes: [ + { attribute: 'id', type: 'ID', is_primary_key: true }, + { attribute: 'name', type: 'String', indexed: {} }, + ], + } as InstanceTable; + const product = { + schema: 'data', + name: 'Product', + primary_key: 'id', + attributes: [ + { attribute: 'id', type: 'ID', is_primary_key: true }, + { attribute: 'name', type: 'String', indexed: {} }, + { attribute: 'categoryId', type: 'ID', indexed: {} }, + ], + } as InstanceTable; + const catalogTables: InstanceDatabaseTableMap = { Category: category, Product: product }; + + it('adds a to-one relationship with its exact stored key', () => { + const map = getRelationshipInfoMap(product, catalogTables, [ + { attribute: 'category', relatedTableName: 'Category', isToMany: false, from: 'categoryId' }, + ]); + expect(map.category).toMatchObject({ + relatedTableName: 'Category', + relatedPrimaryKey: 'id', + isToMany: false, + resolvable: false, + foreignKeyAttribute: 'categoryId', + }); + expect(collapsedForeignKeyNames(map)).toEqual(['categoryId']); + }); + + it('adds a reverse to-many relationship with its exact reverse key', () => { + const map = getRelationshipInfoMap(category, catalogTables, [ + { attribute: 'products', relatedTableName: 'Product', isToMany: true, to: 'categoryId' }, + ]); + expect(map.products).toMatchObject({ + relatedTableName: 'Product', + isToMany: true, + resolvable: false, + reverseForeignKey: 'categoryId', + }); + expect(map.products.foreignKeyAttribute).toBeUndefined(); + }); + + it('keeps resolvable=true when describe also reports the attribute (Harper 4.x)', () => { + const describedProduct = { + ...product, + attributes: [...product.attributes, { attribute: 'category', type: 'Category' }], + }; + const map = getRelationshipInfoMap(describedProduct, catalogTables, [ + { attribute: 'category', relatedTableName: 'Category', isToMany: false, from: 'categoryId' }, + ]); + expect(map.category.resolvable).toBe(true); + expect(map.category.foreignKeyAttribute).toBe('categoryId'); + }); + + it('skips declarations whose related table is unknown', () => { + const map = getRelationshipInfoMap(product, catalogTables, [ + { attribute: 'vendor', relatedTableName: 'Vendor', isToMany: false, from: 'vendorId' }, + ]); + expect(map.vendor).toBeUndefined(); + }); +}); diff --git a/src/features/instance/databases/functions/relationshipAttributes.ts b/src/features/instance/databases/functions/relationshipAttributes.ts index 7ccd93089..5232044ba 100644 --- a/src/features/instance/databases/functions/relationshipAttributes.ts +++ b/src/features/instance/databases/functions/relationshipAttributes.ts @@ -1,3 +1,4 @@ +import { SchemaRelationship } from '@/features/instance/databases/functions/schemaRelationships'; import { InstanceAttribute, InstanceDatabaseTableMap, InstanceTable } from '@/integrations/api/api.patch'; /** @@ -17,12 +18,18 @@ export interface RelationshipAttributeInfo { relatedAttributes: InstanceAttribute[]; /** True for one-to-many/many-to-many (the value is an array of related records). */ isToMany: boolean; - /** False when describe carried no element type (legacy attribute registries): the values - * can't be requested via a nested select, so cells fall back to the reverse-key link. */ + /** False when the server can't resolve the values via a nested select (the attribute is + * missing from describe, or describe carried no element type): cells fall back to the + * stored foreign key or the reverse-key link. */ resolvable: boolean; /** Attribute on the related table pointing back at this row's primary key (e.g. Tracks.albumId * for Albums.tracks). Lets cells link to the related table filtered to this row's records. */ reverseForeignKey?: string; + /** Stored attribute on this table holding the related key(s) (e.g. Product.categoryId for + * Product.category). Exact when it comes from `@relationship(from:)` in a component schema, + * inferred by naming convention otherwise. Backs the column collapse, unresolved-cell + * rendering, and primary-key filter shortcicuits. */ + foreignKeyAttribute?: string; } /** The table another attribute's type points at, if any: `type` for to-one, `elements` for to-many. */ @@ -70,20 +77,39 @@ export function getRelationshipInfo( return undefined; } const isToMany = attribute.type === 'array'; - return { + const info: RelationshipAttributeInfo = { relatedTableName, relatedPrimaryKey, - // Exclude the related table's own relationship/computed attributes: filters join one hop - // (`[column, subProperty]`), so deeper synthetic attributes can't be queried through here. - relatedAttributes: (relatedTable.attributes ?? []).filter( - (related) => !related.computed && !relatedTableNameOf(related, databaseTables), - ), + relatedAttributes: joinableAttributes(relatedTable, databaseTables), isToMany, resolvable: !isToMany || Boolean(attribute.elements), reverseForeignKey: isToMany && ownerTable ? inferReverseForeignKey(ownerTable, relatedTable, databaseTables) : undefined, }; + if (ownerTable) { + info.foreignKeyAttribute = relationshipForeignKeyName( + attribute.attribute, + info, + ownerTable.attributes ?? [], + databaseTables, + ); + } + return info; +} + +/** + * The related table's attributes that filters can join through. Excludes its own + * relationship/computed attributes: filters join one hop (`[column, subProperty]`), so deeper + * synthetic attributes can't be queried through here. + */ +function joinableAttributes( + relatedTable: InstanceTable, + databaseTables: InstanceDatabaseTableMap, +): InstanceAttribute[] { + return (relatedTable.attributes ?? []).filter( + (related) => !related.computed && !relatedTableNameOf(related, databaseTables), + ); } /** @@ -123,10 +149,18 @@ function inferReverseForeignKey( )?.attribute; } -/** Map of attribute name → relationship info for every relationship attribute of a table. */ +/** + * Map of attribute name → relationship info for every relationship attribute of a table. + * + * Two sources merge here: attributes reported by describe (detected by their table-named + * type/elements, keys inferred by convention), and relationships declared in component schema + * files (`schemaRelationships`) — the only source on Harper 5.1, and authoritative for the + * `from:`/`to:` key mappings wherever available. + */ export function getRelationshipInfoMap( instanceTable: InstanceTable | undefined, databaseTables: InstanceDatabaseTableMap | undefined, + schemaRelationships?: SchemaRelationship[], ): Record { const map: Record = {}; for (const attribute of instanceTable?.attributes ?? []) { @@ -135,6 +169,24 @@ export function getRelationshipInfoMap( map[attribute.attribute] = info; } } + for (const declared of schemaRelationships ?? []) { + const relatedTable = databaseTables?.[declared.relatedTableName]; + const relatedPrimaryKey = relatedTable?.primary_key ?? relatedTable?.hash_attribute; + if (!relatedTable || !relatedPrimaryKey || !databaseTables) { + continue; + } + const detected = map[declared.attribute]; + map[declared.attribute] = { + relatedTableName: declared.relatedTableName, + relatedPrimaryKey, + relatedAttributes: joinableAttributes(relatedTable, databaseTables), + isToMany: declared.isToMany, + // Nested selects only resolve when the server reports the attribute in describe. + resolvable: detected?.resolvable ?? false, + reverseForeignKey: (declared.isToMany ? declared.to : undefined) ?? detected?.reverseForeignKey, + foreignKeyAttribute: declared.from ?? detected?.foreignKeyAttribute, + }; + } return map; } @@ -188,19 +240,12 @@ export function relationshipForeignKeyName( /** Foreign-key column names browse hides by default because a relationship column covers them. */ export function collapsedForeignKeyNames( - instanceTable: InstanceTable | undefined, - databaseTables: InstanceDatabaseTableMap | undefined, + relationshipInfoMap: Record, ): string[] { - const attributes = instanceTable?.attributes ?? []; const names = new Set(); - for (const attribute of attributes) { - const info = getRelationshipInfo(attribute, databaseTables); - if (!info) { - continue; - } - const foreignKey = relationshipForeignKeyName(attribute.attribute, info, attributes, databaseTables); - if (foreignKey) { - names.add(foreignKey); + for (const info of Object.values(relationshipInfoMap)) { + if (info.foreignKeyAttribute) { + names.add(info.foreignKeyAttribute); } } return [...names]; diff --git a/src/features/instance/databases/functions/schemaRelationships.test.ts b/src/features/instance/databases/functions/schemaRelationships.test.ts new file mode 100644 index 000000000..5e27d110d --- /dev/null +++ b/src/features/instance/databases/functions/schemaRelationships.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { parseSchemaRelationships } from './schemaRelationships'; + +// The live anvils app schema this feature was built against. +const anvilsSchema = `type Category @table @export @sealed { + id: ID @primaryKey + name: String! @indexed + products: [Product] @relationship(to: "categoryId") +} + +# Product catalog for the Anvils store +type Product @table @export @sealed { + id: ID @primaryKey + name: String! @indexed + description: String + priceCents: Int! + tags: [String] + embedding: [Float] @indexed(type: "HNSW", distance: "cosine", M: 16) + categoryId: ID @indexed + category: Category @relationship(from: "categoryId") + createdAt: Date @createdTime + updatedAt: Date @updatedTime +} +`; + +describe('parseSchemaRelationships', () => { + it('extracts relationships with exact from/to mappings', () => { + const map = parseSchemaRelationships([anvilsSchema]); + expect(map.data.Category).toEqual([ + { attribute: 'products', relatedTableName: 'Product', isToMany: true, from: undefined, to: 'categoryId' }, + ]); + expect(map.data.Product).toEqual([ + { attribute: 'category', relatedTableName: 'Category', isToMany: false, from: 'categoryId', to: undefined }, + ]); + }); + + it('honors @table(table:, database:) overrides and same-database resolution', () => { + const schema = `type Owner @table(table: "owners", database: "blog") { + id: ID @primaryKey + posts: [Post] @relationship(to: "ownerId") +} + +type Post @table(database: "blog") { + id: ID @primaryKey + ownerId: ID @indexed + owner: Owner @relationship(from: "ownerId") +} +`; + const map = parseSchemaRelationships([schema]); + expect(map.blog.owners[0]).toMatchObject({ attribute: 'posts', relatedTableName: 'Post' }); + expect(map.blog.Post[0]).toMatchObject({ attribute: 'owner', relatedTableName: 'owners' }); + expect(map.data).toBeUndefined(); + }); + + it('resolves types across separate schema files, and skips unknown types', () => { + const categoryOnly = `type Category @table { + id: ID @primaryKey + products: [Product] @relationship(to: "categoryId") + ghosts: [Ghost] @relationship(to: "categoryId") +} +`; + const productOnly = `type Product @table { + id: ID @primaryKey + categoryId: ID @indexed +} +`; + const map = parseSchemaRelationships([categoryOnly, productOnly]); + expect(map.data.Category).toEqual([ + { attribute: 'products', relatedTableName: 'Product', isToMany: true, from: undefined, to: 'categoryId' }, + ]); + }); + + it('ignores unparseable sources and tables without relationships', () => { + expect(parseSchemaRelationships(['type Broken @table {'])).toEqual({}); + expect(parseSchemaRelationships(['type Plain @table { id: ID @primaryKey }'])).toEqual({}); + }); +}); diff --git a/src/features/instance/databases/functions/schemaRelationships.ts b/src/features/instance/databases/functions/schemaRelationships.ts new file mode 100644 index 000000000..5575c04be --- /dev/null +++ b/src/features/instance/databases/functions/schemaRelationships.ts @@ -0,0 +1,147 @@ +import { InstanceClientIdConfig } from '@/config/instanceClientConfig'; +import { parseSchema } from '@/features/instance/applications/lib/schema/parseSchema'; +import { + baseTypeName, + findArg, + findDirective, + isListType, + stringArgValue, +} from '@/features/instance/applications/lib/schema/types'; +import { getComponentFile } from '@/integrations/api/instance/applications/getComponentFile'; +import { APIDirectoryEntry, APIFileEntry, getComponents } from '@/integrations/api/instance/applications/getComponents'; +import { queryOptions } from '@tanstack/react-query'; + +/** + * Relationship declared in a component's `schema.graphql`. + * + * Harper 5.1 omits relationship attributes from `describe_table` entirely (they are runtime-only, + * never persisted to the attribute registry), so on those servers the schema files are the only + * place browse can learn that relationships exist — and they carry more than describe ever did: + * the exact `@relationship(from:/to:)` key mappings. + */ +export interface SchemaRelationship { + attribute: string; + relatedTableName: string; + isToMany: boolean; + /** `@relationship(from:)` — the attribute on this table storing the related key(s). */ + from?: string; + /** `@relationship(to:)` — the attribute on the related table pointing back at this table. */ + to?: string; +} + +/** database → table → relationships declared for that table. */ +export type SchemaRelationshipMap = Record>; + +const DEFAULT_DATABASE = 'data'; +/** Backstop against pathological component trees; a real instance has a handful of schemas. */ +const MAX_SCHEMA_FILES = 25; + +export function getSchemaRelationshipsQueryOptions(params: InstanceClientIdConfig & { enabled?: boolean }) { + return queryOptions({ + queryKey: [params.entityId, 'schema-relationships'] as const, + staleTime: 60_000, + retry: false, + enabled: params.enabled !== false, + queryFn: async (): Promise => { + // Best-effort: browse must keep working for users who can't read component files + // (or on servers without these operations), so any failure yields an empty map. + try { + const tree = await getComponents(params); + const files = collectGraphqlFiles(tree).slice(0, MAX_SCHEMA_FILES); + const sources = await Promise.all( + files.map(({ project, file }) => + getComponentFile({ ...params, project, file }) + .then((response) => response.message) + .catch(() => undefined) + ), + ); + return parseSchemaRelationships(sources.filter((source): source is string => typeof source === 'string')); + } catch { + return {}; + } + }, + }); +} + +function collectGraphqlFiles(root: APIDirectoryEntry | undefined): Array<{ project: string; file: string }> { + const files: Array<{ project: string; file: string }> = []; + for (const component of root?.entries ?? []) { + if (!('entries' in component)) { + continue; + } + const walk = (entries: Array, prefix: string) => { + for (const entry of entries) { + if ('entries' in entry) { + walk(entry.entries, `${prefix}${entry.name}/`); + } else if (entry.name.endsWith('.graphql')) { + files.push({ project: component.name, file: `${prefix}${entry.name}` }); + } + } + }; + walk(component.entries, ''); + } + return files; +} + +/** Parse schema sources into relationship declarations, resolving type names to table names. */ +export function parseSchemaRelationships(sources: string[]): SchemaRelationshipMap { + interface ParsedTable { + typeName: string; + tableName: string; + database: string; + relationships: Array & { relatedTypeName: string }>; + } + const tables: ParsedTable[] = []; + for (const source of sources) { + const { document, ok } = parseSchema(source); + if (!ok) { + continue; + } + for (const segment of document.segments) { + if (segment.kind !== 'table') { + continue; + } + const { table } = segment; + const tableDirective = findDirective(table.directives, 'table'); + const tableName = stringArgValue(findArg(tableDirective, 'table')?.value) ?? table.typeName; + const database = stringArgValue(findArg(tableDirective, 'database')?.value) ?? DEFAULT_DATABASE; + const relationships: ParsedTable['relationships'] = []; + for (const field of table.fields) { + const relationship = findDirective(field.directives, 'relationship'); + if (!relationship) { + continue; + } + relationships.push({ + attribute: field.name, + relatedTypeName: baseTypeName(field.type), + isToMany: isListType(field.type), + from: stringArgValue(findArg(relationship, 'from')?.value), + to: stringArgValue(findArg(relationship, 'to')?.value), + }); + } + tables.push({ typeName: table.typeName, tableName, database, relationships }); + } + } + + const map: SchemaRelationshipMap = {}; + for (const table of tables) { + if (!table.relationships.length) { + continue; + } + const resolved: SchemaRelationship[] = []; + for (const { relatedTypeName, ...relationship } of table.relationships) { + // Related types live in the same database; resolve the GraphQL type name to the + // (possibly @table(table:)-renamed) table name. + const related = tables.find((candidate) => + candidate.typeName === relatedTypeName && candidate.database === table.database + ); + if (related) { + resolved.push({ ...relationship, relatedTableName: related.tableName }); + } + } + if (resolved.length) { + (map[table.database] ??= {})[table.tableName] = resolved; + } + } + return map; +} diff --git a/src/integrations/api/instance/database/getSearchByConditions.ts b/src/integrations/api/instance/database/getSearchByConditions.ts index 73523af8d..5959c12d1 100644 --- a/src/integrations/api/instance/database/getSearchByConditions.ts +++ b/src/integrations/api/instance/database/getSearchByConditions.ts @@ -221,6 +221,14 @@ function translateRelationshipColumnFilter( const [, subProperty, rest] = match; const subAttribute = relationshipInfo.relatedAttributes.find((related) => related.attribute === subProperty); const condition = translateColumnFilterToSearchCondition(subProperty, rest, subAttribute); + if (subProperty === relationshipInfo.relatedPrimaryKey && relationshipInfo.foreignKeyAttribute) { + // The stored foreign key holds exactly the related primary key: query it directly (uses + // its index, and works on servers whose operations API can't execute relationship joins). + return { + ...condition, + search_attribute: relationshipInfo.foreignKeyAttribute, + }; + } return { ...condition, search_attribute: [key, subProperty], From 13be5613b27452da6822f2cd3b07659161399c1e Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 10 Jul 2026 14:38:03 -0400 Subject: [PATCH 5/5] fix(databases): green the build and collapse only exact foreign keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review fixes: - Build was red: commit 6eb8b07 added a required `resolvable` to RelationshipAttributeInfo but left the RelationshipCell.test.ts and getSearchByConditions.test.ts fixtures constructing it without the field, so `tsc -b` failed with 8 errors. (`npx tsc --noEmit` missed them because the root tsconfig excludes tests; `tsc -b`, which CI runs, includes them.) Fixed the fixtures and added coverage for the related-primary-key filter shortcut. - collapsedForeignKeyNames could hide a real column by naming-convention guess (a to-one 'product' collapsing an independent 'productId' that the relationship doesn't actually read from). foreignKeyAttribute is now taken ONLY from an exact schema `@relationship(from:)` — never guessed — so column collapse, unresolved-cell rendering, and the PK filter shortcut only ever act on the column the relationship truly reads. Describe-detected relationships resolve their values via nested select, so they never needed the guess. Full suite 1310 pass; tsc -b / oxlint / dprint clean. Live 5.1 behavior unchanged (schema is present there, so the exact from: key still drives collapse). Co-Authored-By: Claude Opus 4.8 --- .../components/RelationshipCell.test.ts | 1 + .../functions/relationshipAttributes.test.ts | 12 +++++-- .../functions/relationshipAttributes.ts | 35 +++++++++---------- .../database/getSearchByConditions.test.ts | 26 ++++++++++++++ 4 files changed, 52 insertions(+), 22 deletions(-) diff --git a/src/features/instance/databases/components/RelationshipCell.test.ts b/src/features/instance/databases/components/RelationshipCell.test.ts index 037a6cfeb..33174d1b6 100644 --- a/src/features/instance/databases/components/RelationshipCell.test.ts +++ b/src/features/instance/databases/components/RelationshipCell.test.ts @@ -7,6 +7,7 @@ const info: RelationshipAttributeInfo = { relatedPrimaryKey: 'id', relatedAttributes: [], isToMany: false, + resolvable: true, }; describe('relationshipKeyValues', () => { diff --git a/src/features/instance/databases/functions/relationshipAttributes.test.ts b/src/features/instance/databases/functions/relationshipAttributes.test.ts index f50ee0ab6..1de3cf546 100644 --- a/src/features/instance/databases/functions/relationshipAttributes.test.ts +++ b/src/features/instance/databases/functions/relationshipAttributes.test.ts @@ -153,9 +153,15 @@ describe('relationshipForeignKeyName / collapsedForeignKeyNames', () => { expect(relationshipForeignKeyName('product', info, attributes, tables)).toBeUndefined(); }); - it('collects collapsed keys from the relationship info map', () => { - expect(collapsedForeignKeyNames(getRelationshipInfoMap(relReview, tables))).toEqual(['productId']); - expect(collapsedForeignKeyNames(getRelationshipInfoMap(relProduct, tables))).toEqual([]); + it('collapses only exact schema foreign keys, never convention-guessed ones', () => { + // Describe-only (no schema): the foreign key is not known exactly, so nothing collapses — + // a real column is never hidden on a naming-convention guess. + expect(collapsedForeignKeyNames(getRelationshipInfoMap(relReview, tables))).toEqual([]); + // With the schema's exact `from:`, the backing key collapses. + const withSchema = getRelationshipInfoMap(relReview, tables, [ + { attribute: 'product', relatedTableName: 'RelProduct', isToMany: false, from: 'productId' }, + ]); + expect(collapsedForeignKeyNames(withSchema)).toEqual(['productId']); expect(collapsedForeignKeyNames({})).toEqual([]); }); }); diff --git a/src/features/instance/databases/functions/relationshipAttributes.ts b/src/features/instance/databases/functions/relationshipAttributes.ts index 5232044ba..56c317a05 100644 --- a/src/features/instance/databases/functions/relationshipAttributes.ts +++ b/src/features/instance/databases/functions/relationshipAttributes.ts @@ -26,9 +26,9 @@ export interface RelationshipAttributeInfo { * for Albums.tracks). Lets cells link to the related table filtered to this row's records. */ reverseForeignKey?: string; /** Stored attribute on this table holding the related key(s) (e.g. Product.categoryId for - * Product.category). Exact when it comes from `@relationship(from:)` in a component schema, - * inferred by naming convention otherwise. Backs the column collapse, unresolved-cell - * rendering, and primary-key filter shortcicuits. */ + * Product.category), taken EXACTLY from `@relationship(from:)` in a component schema — never + * guessed by naming convention, so the column collapse, unresolved-cell rendering, and + * primary-key filter shortcut never act on a column the relationship doesn't actually read. */ foreignKeyAttribute?: string; } @@ -77,7 +77,12 @@ export function getRelationshipInfo( return undefined; } const isToMany = attribute.type === 'array'; - const info: RelationshipAttributeInfo = { + // foreignKeyAttribute is intentionally NOT inferred here: describe alone can't tell which stored + // column a relationship reads from, and guessing by name convention risks collapsing/reading an + // unrelated column. It is set only from an exact schema `@relationship(from:)` in + // getRelationshipInfoMap. Describe-reported relationships resolve their values via nested select + // (resolvable), so they never need it. + return { relatedTableName, relatedPrimaryKey, relatedAttributes: joinableAttributes(relatedTable, databaseTables), @@ -87,15 +92,6 @@ export function getRelationshipInfo( ? inferReverseForeignKey(ownerTable, relatedTable, databaseTables) : undefined, }; - if (ownerTable) { - info.foreignKeyAttribute = relationshipForeignKeyName( - attribute.attribute, - info, - ownerTable.attributes ?? [], - databaseTables, - ); - } - return info; } /** @@ -184,7 +180,8 @@ export function getRelationshipInfoMap( // Nested selects only resolve when the server reports the attribute in describe. resolvable: detected?.resolvable ?? false, reverseForeignKey: (declared.isToMany ? declared.to : undefined) ?? detected?.reverseForeignKey, - foreignKeyAttribute: declared.from ?? detected?.foreignKeyAttribute, + // Only the exact declared key — describe-detected relationships carry none (see above). + foreignKeyAttribute: declared.from, }; } return map; @@ -212,11 +209,11 @@ export function syntheticAttributeNames( } /** - * The stored foreign-key attribute a relationship reads from, inferred by naming convention — - * `product` → `productId`, `pets` → `petIds` — since describe_table does not expose the - * `@relationship(from/to)` mapping. Browse collapses the pair into just the relationship column - * (the chips show the same key values), with the foreign key re-showable from the Columns picker - * in case the inference is wrong for an unconventionally named key. + * Guess the stored foreign-key attribute a relationship reads from by naming convention — + * `product` → `productId`, `pets` → `petIds`. Used only as a heuristic to locate the REVERSE key + * of a to-many relationship (see `inferReverseForeignKey`), where a wrong guess merely yields a + * link that filters to nothing. The forward foreign key that drives column collapse and cell + * rendering is NEVER guessed — it comes only from an exact schema `@relationship(from:)`. */ export function relationshipForeignKeyName( attributeName: string, diff --git a/src/integrations/api/instance/database/getSearchByConditions.test.ts b/src/integrations/api/instance/database/getSearchByConditions.test.ts index da543bda1..73c260c46 100644 --- a/src/integrations/api/instance/database/getSearchByConditions.test.ts +++ b/src/integrations/api/instance/database/getSearchByConditions.test.ts @@ -344,6 +344,7 @@ describe('translateColumnFilterToSearchCondition for relationship columns', () = { attribute: 'rating', type: 'Int' }, ], isToMany: false, + resolvable: true, }; it('translates ".subProperty value" into a join path condition', () => { @@ -409,4 +410,29 @@ describe('translateColumnFilterToSearchCondition for relationship columns', () = { search_attribute: ['product', 'rating'], search_type: 'less_than', search_value: 9 }, ]); }); + + it('queries the local foreign key directly when filtering by the related primary key', () => { + // With a known stored foreign key, filtering `product` by its id resolves to the FK column + // (indexed, and works on servers whose ops API cannot execute relationship joins) rather + // than the `[relationship, subProperty]` join path. + const withForeignKey = { ...relationshipInfo, foreignKeyAttribute: 'productId' }; + expect( + translateColumnFilterToSearchCondition('product', '.id p1', undefined, withForeignKey), + ).toEqual({ + search_attribute: 'productId', + search_type: 'equals', + search_value: 'p1', + }); + }); + + it('still joins for non-primary-key sub-properties even when the foreign key is known', () => { + const withForeignKey = { ...relationshipInfo, foreignKeyAttribute: 'productId' }; + expect( + translateColumnFilterToSearchCondition('product', '.name Anvil', undefined, withForeignKey), + ).toEqual({ + search_attribute: ['product', 'name'], + search_type: 'equals', + search_value: 'Anvil', + }); + }); });