diff --git a/CLAUDE.md b/CLAUDE.md index c1d160130..550cf4636 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,3 +103,44 @@ To drive a Radix dropdown/menu (e.g. `src/components/ui/dropdownMenu.tsx`) in js is `menu`. Disabled items have `aria-disabled="true"` and/or `data-disabled`. Working example: `src/features/instance/databases/components/PickColumnsDropdown.test.tsx`. + +## Browse — relationship/computed attributes vary by Harper version + +`@relationship` and `@computed` attributes are read-only: Harper rejects any insert/update +whose record merely CONTAINS the key ("Computed property X may not be directly assigned a +value", even for `null`). Strip them from record-editor JSON (see +`functions/relationshipAttributes.ts`). + +What `describe_table` reports differs by server version (verified empirically, Jul 2026): + +- **Harper 4.7**: relationship attrs ARE listed — to-one as `{attribute, type: '', + 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 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/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..8ddad50ca 100644 --- a/src/features/instance/databases/components/DatabaseTableView.tsx +++ b/src/features/instance/databases/components/DatabaseTableView.tsx @@ -2,6 +2,13 @@ import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdownMenu'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; import { formatBrowseDataTableHeader } from '@/features/instance/databases/functions/formatBrowseDataTableHeader'; +import { + buildRelationshipGetAttributes, + collapsedForeignKeyNames, + getRelationshipInfoMap, + syntheticAttributeNames, +} from '@/features/instance/databases/functions/relationshipAttributes'; +import { getSchemaRelationshipsQueryOptions } from '@/features/instance/databases/functions/schemaRelationships'; import { AddTableRowModal } from '@/features/instance/databases/modals/AddTableRowModal'; import { DeleteDatabaseModal } from '@/features/instance/databases/modals/DeleteDatabaseModal'; import { DeleteTableModal } from '@/features/instance/databases/modals/DeleteTableModal'; @@ -33,7 +40,7 @@ import { onClickStopPropagation } from '@/lib/onClickStopPropagation'; import { buildAbsoluteLinkToDatabasePage } from '@/lib/urls/buildAbsoluteLinkToDatabasePage'; import { zodResolver } from '@hookform/resolvers/zod'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Link, useNavigate, useParams } from '@tanstack/react-router'; +import { Link, useNavigate, useParams, useSearch } from '@tanstack/react-router'; import { Row, VisibilityState } from '@tanstack/react-table'; import { BrushCleaningIcon, @@ -51,7 +58,7 @@ import { Trash2Icon, TrashIcon, } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { ColumnFiltersSchema } from './ColumnFilters'; @@ -91,9 +98,29 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName // describe_table backfills this table's count asynchronously. Render schema from whichever arrives first // -- the map is usually ready before describe_table -- so columns and records are never gated on the // (slower) count scan. describe_table wins once present: it's the per-table, refresh-invalidated copy. - const tableFromMap = instanceDatabaseMap?.[databaseName]?.[tableName]; + const databaseTables = instanceDatabaseMap?.[databaseName]; + const tableFromMap = databaseTables?.[tableName]; const instanceTable = describeTableData ?? tableFromMap; const attributesMap = useMemo(() => keyBy(instanceTable?.attributes ?? [], 'attribute'), [instanceTable]); + // Newer Harper servers omit relationship attributes from describe entirely; the component + // schema files still declare them (with exact from/to key mappings), so browse reads those too. + const { data: schemaRelationshipMap } = useQuery(getSchemaRelationshipsQueryOptions(instanceParams)); + const schemaRelationships = schemaRelationshipMap?.[databaseName]?.[tableName]; + // Relationship attributes get resolved cell values, link chips, and sub-property filters; + // they are also excluded from record add/edit JSON since the server rejects writes that + // assign them. + const relationshipInfoMap = useMemo( + () => getRelationshipInfoMap(instanceTable, databaseTables, schemaRelationships), + [instanceTable, databaseTables, schemaRelationships], + ); + const relationshipGetAttributes = useMemo( + () => buildRelationshipGetAttributes(instanceTable, databaseTables), + [instanceTable, databaseTables], + ); + const syntheticAttributes = useMemo( + () => syntheticAttributeNames(instanceTable?.attributes, databaseTables), + [instanceTable, databaseTables], + ); const [selectedIds, setSelectedIds] = useEffectedState(null, allParams); const [isEditModalOpen, setIsEditModalOpen] = useState(false); @@ -116,22 +143,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 +177,43 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName return clearFilters(); }, [allParams, clearFilters]); - const { dataTableColumns, primaryKey } = formatBrowseDataTableHeader(instanceTable); + // Deep links can carry filters (?filters={column: "value"}) — relationship cell chips use this + // to land on the related table filtered to the linked record. Applied once per distinct search, + // after the schema arrives (translation needs attribute types). When the URL filters go away + // (navigating to another table, or back to the bare table), the filter inputs are cleared too + // so they don't linger stale — `appliedSearchConditions` already resets on table change, and + // the visible inputs should stay in step with it. + const { filters: urlFilters }: { filters?: Record } = useSearch({ strict: false }); + const urlFiltersKey = JSON.stringify([databaseName, tableName, urlFilters ?? null]); + const appliedUrlFiltersKey = useRef(null); + useEffect(function syncFiltersFromUrl() { + if (!instanceTable || appliedUrlFiltersKey.current === urlFiltersKey) { + return; + } + const isInitialSync = appliedUrlFiltersKey.current === null; + appliedUrlFiltersKey.current = urlFiltersKey; + if (urlFilters) { + resetFiltersForm({ ...urlFilters }); + const conditions = translateFilterValues(urlFilters); + setAppliedSearchConditions(conditions.length ? conditions : null); + showFilters(); + } else if (!isInitialSync) { + // Skip the initial mount (nothing to clear yet); otherwise drop the stale inputs a + // prior URL filter (or another table) left behind. + clearFilters(); + } + }, [ + urlFilters, + urlFiltersKey, + instanceTable, + resetFiltersForm, + translateFilterValues, + setAppliedSearchConditions, + showFilters, + clearFilters, + ]); + + const { dataTableColumns, primaryKey } = formatBrowseDataTableHeader(instanceTable, relationshipInfoMap); const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [isImportDataModalOpen, setIsImportDataModalOpen] = useState(false); const [sort, setSort] = useEffectedState( @@ -199,6 +268,7 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName pageSize, pageIndex, onlyIfCached, + getAttributes: relationshipGetAttributes, }; const searchByValueOptions = getSearchByValueOptions(searchByValueParams); const { data: fullTableData, isFetching: tableDataFetching } = useQuery(searchByValueOptions); @@ -214,6 +284,7 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName pageSize, pageIndex, onlyIfCached, + getAttributes: relationshipGetAttributes, }; const searchByConditionsOptions = getSearchByConditionsOptions(searchByConditionsParams); const { data: filteredTableData, isFetching: tableConditionsDataFetching } = useQuery(searchByConditionsOptions); @@ -269,6 +340,8 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName const allResultsAsCSV = { pageIndex: 0, pageSize: 1_000_000, + // Raw records only: resolved relationship objects don't serialize usefully into CSV cells. + getAttributes: undefined, headers: { Accept: 'text/csv', }, @@ -370,10 +443,17 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName [navigate], ); - const [columnVisibility, setColumnVisibility] = useSessionStorage( + const [storedColumnVisibility, setColumnVisibility] = useSessionStorage( `ColumnDisplayed/${databaseName}/${tableName}` as 'ColumnDisplayed/{database}/{table}', {} satisfies VisibilityState, ); + // A relationship column shows the same key values as the foreign key backing it (and links + // them), so the foreign-key column is collapsed away by default. The user's own choices win: + // re-showing it from the Columns picker stores an explicit `true` that overrides the default. + const columnVisibility = useMemo((): VisibilityState => ({ + ...Object.fromEntries(collapsedForeignKeyNames(relationshipInfoMap).map((name) => [name, false])), + ...storedColumnVisibility, + }), [relationshipInfoMap, storedColumnVisibility]); const openDeleteTable = useSetWatchedValue('ShowDeleteTable', true); const openDeleteDatabase = useSetWatchedValue('ShowDeleteDatabase', true); @@ -543,6 +623,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..3361ae712 --- /dev/null +++ b/src/features/instance/databases/components/RelationshipCell.tsx @@ -0,0 +1,105 @@ +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; + +/** 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 }` + * (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, + record, + primaryKey, + info, +}: { + value: 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 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 + ? { filters: { [info.reverseForeignKey]: String(rowKeyValue) } } + : undefined; + + if (!keyValues.length) { + // 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 overflow = keyValues.length - MAX_CHIPS; + return ( + + {keyValues.slice(0, MAX_CHIPS).map((keyValue, index) => ( + + + {String(keyValue)} + + + ))} + {overflow > 0 && (reverseSearch + ? ( + + +{overflow} more + + ) + : +{overflow} 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..b884d1033 --- /dev/null +++ b/src/features/instance/databases/functions/formatBrowseDataTableHeader.test.ts @@ -0,0 +1,72 @@ +/** @vitest-environment jsdom */ +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', + 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, getRelationshipInfoMap(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 relationship info', () => { + 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); + }); + + 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 21866da26..6bbd2d6c6 100644 --- a/src/features/instance/databases/functions/formatBrowseDataTableHeader.ts +++ b/src/features/instance/databases/functions/formatBrowseDataTableHeader.ts @@ -1,7 +1,21 @@ +import { RelationshipCell } from '@/features/instance/databases/components/RelationshipCell'; +import { RelationshipAttributeInfo } from '@/features/instance/databases/functions/relationshipAttributes'; import { InstanceAttribute, InstanceTable } from '@/integrations/api/api.patch'; -import { ColumnDef } from '@tanstack/react-table'; +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, + relationshipInfoMap: Record = {}, +): { dataTableColumns: Array>>; primaryKey: string; } { @@ -17,16 +31,31 @@ export function formatBrowseDataTableHeader(instanceTable?: InstanceTable): { 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 = relationshipInfoMap[attribute]; 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 ? relationshipCell(relationshipInfo) : renderPlainCell, + meta: relationshipInfo ? { relationshipInfo } : undefined, }; if (is_primary_key) { primaryKeyColumns.push(dataTableColumn); @@ -38,12 +67,47 @@ export function formatBrowseDataTableHeader(instanceTable?: InstanceTable): { 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, }; } +/** + * 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..1de3cf546 --- /dev/null +++ b/src/features/instance/databases/functions/relationshipAttributes.test.ts @@ -0,0 +1,299 @@ +import { InstanceDatabaseTableMap, InstanceTable } from '@/integrations/api/api.patch'; +import { describe, expect, it } from 'vitest'; +import { + buildRelationshipGetAttributes, + collapsedForeignKeyNames, + getRelationshipInfo, + getRelationshipInfoMap, + isSyntheticAttribute, + relationshipForeignKeyName, + 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(); + }); +}); + +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('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([]); + }); +}); + +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); + }); +}); + +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 new file mode 100644 index 000000000..56c317a05 --- /dev/null +++ b/src/features/instance/databases/functions/relationshipAttributes.ts @@ -0,0 +1,272 @@ +import { SchemaRelationship } from '@/features/instance/databases/functions/schemaRelationships'; +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; + /** 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), 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; +} + +/** 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 { + 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; + } + 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; + } + const isToMany = attribute.type === 'array'; + // 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), + isToMany, + resolvable: !isToMany || Boolean(attribute.elements), + reverseForeignKey: isToMany && ownerTable + ? inferReverseForeignKey(ownerTable, relatedTable, databaseTables) + : undefined, + }; +} + +/** + * 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), + ); +} + +/** + * 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. + * + * 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 ?? []) { + const info = getRelationshipInfo(attribute, databaseTables, instanceTable); + if (info) { + 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, + // Only the exact declared key — describe-detected relationships carry none (see above). + foreignKeyAttribute: declared.from, + }; + } + 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); +} + +/** + * 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, + 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( + relationshipInfoMap: Record, +): string[] { + const names = new Set(); + for (const info of Object.values(relationshipInfoMap)) { + if (info.foreignKeyAttribute) { + names.add(info.foreignKeyAttribute); + } + } + return [...names]; +} + +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?.resolvable) { + selects.push({ name: attribute.attribute, select: [info.relatedPrimaryKey] }); + } + } + return selects.length ? [...selects, '*'] : undefined; +} 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/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..73c260c46 100644 --- a/src/integrations/api/instance/database/getSearchByConditions.test.ts +++ b/src/integrations/api/instance/database/getSearchByConditions.test.ts @@ -333,3 +333,106 @@ 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, + resolvable: true, + }; + + 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 }, + ]); + }); + + 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', + }); + }); +}); diff --git a/src/integrations/api/instance/database/getSearchByConditions.ts b/src/integrations/api/instance/database/getSearchByConditions.ts index b021f43a9..5959c12d1 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,40 @@ 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); + 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], + }; +} + 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,