From 62cc14c02a7fd3cc9d5cb35eaaeaff63acd067ba Mon Sep 17 00:00:00 2001 From: Hein Date: Wed, 30 Sep 2026 21:37:41 +0200 Subject: [PATCH] feat(resolvespec-js): support extended restheadspec headers Add HeaderSpecOptions, vector_search, X-Preload-Where, X-Expand, custom SQL joins/or, spatial/vector filters, response format, flags and X-Files to buildHeaders, types and README. --- resolvespec-js/README.md | 19 +++ .../src/__tests__/headerspec.test.ts | 95 +++++++++++++ resolvespec-js/src/common/types.ts | 87 +++++++++++- resolvespec-js/src/headerspec/client.ts | 132 ++++++++++++++++-- 4 files changed, 321 insertions(+), 12 deletions(-) diff --git a/resolvespec-js/README.md b/resolvespec-js/README.md index 74ecffb..e07131a 100644 --- a/resolvespec-js/README.md +++ b/resolvespec-js/README.md @@ -106,6 +106,25 @@ await client.delete('public', 'users', '42'); | `X-Fetch-RowNumber` | `fetch_row_number` | string | | `X-CQL-SEL-{col}` | `computedColumns` | expression | | `X-Custom-SQL-W` | `customOperators` | SQL AND-joined | +| `X-Preload-Where` | `preload[].where` | applies to all preloads in `X-Preload`; differing wheres go to `X-Preload-{n}` + `X-Preload-{n}-Where` | +| `X-Expand` | `expand` | `Rel:col1,col2` pipe-separated (LEFT JOIN) | +| `X-Custom-SQL-Join` | `custom_sql_joins` | JOIN clauses, pipe-separated | +| `X-Custom-SQL-Or` | `custom_sql_or` | SQL OR-joined | +| `X-SearchCols` | `search_columns` | comma-separated | +| `X-AdvSQL-{col}` | `advanced_sql` | column -> SQL | +| `X-SpatialFilter-{col}` | `filters` (`st_dwithin`, `st_*`, `bbox`) | JSON `{op,value,logic}` | +| `X-VectorFilter-{col}` | `filters` (`l2_within`, `cosine_within`, `ip_within`) | JSON `{op,value,logic}` | +| `X-Vector-Search-{col}` / `-Vector` / `-As` / `-Dir` | `vector_search` | metric / JSON array / alias / asc\|desc | +| `X-Clean-JSON` | `clean_json` | bool | +| `X-Distinct` | `distinct` | bool | +| `X-SkipCount` / `X-SkipCache` | `skip_count` / `skip_cache` | bool | +| `X-PKRow` | `pk_row` | string | +| `X-SimpleApi` / `X-DetailApi` / `X-Syncfusion` | `response_format` | `simple` \| `detail` \| `syncfusion` | +| `X-Single-Record-As-Object` | `single_record_as_object` | bool (server default true) | +| `X-Transaction-Atomic` | `atomic_transaction` | bool | +| `X-Files` | `xfiles` | JSON, sent as `ZIP_` base64 | + +Extended fields live on `HeaderSpecOptions` (extends `Options`); `vector_search` is on `Options`. ### Utility Functions diff --git a/resolvespec-js/src/__tests__/headerspec.test.ts b/resolvespec-js/src/__tests__/headerspec.test.ts index 9efd47c..021f40d 100644 --- a/resolvespec-js/src/__tests__/headerspec.test.ts +++ b/resolvespec-js/src/__tests__/headerspec.test.ts @@ -2,6 +2,101 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { buildHeaders, encodeHeaderValue, decodeHeaderValue, HeaderSpecClient, getHeaderSpecClient } from '../headerspec/client'; import type { Options, ClientConfig, APIResponse } from '../common/types'; +describe('buildHeaders (extended restheadspec options)', () => { + it('should set X-Preload-Where when all preloads share one where', () => { + const h = buildHeaders({ + preload: [ + { relation: 'Items', columns: ['id'], where: 'active = true' }, + { relation: 'Tags', where: 'active = true' }, + ], + }); + expect(h['X-Preload']).toBe('Items:id|Tags'); + expect(h['X-Preload-Where']).toBe('active = true'); + }); + + it('should use numbered headers for mixed where clauses', () => { + const h = buildHeaders({ + preload: [ + { relation: 'Items', where: 'a = 1' }, + { relation: 'Category' }, + { relation: 'Tags', where: 'b = 2' }, + ], + }); + expect(h['X-Preload']).toBe('Category'); + expect(h['X-Preload-Where']).toBeUndefined(); + expect(h['X-Preload-1']).toBe('Items'); + expect(h['X-Preload-1-Where']).toBe('a = 1'); + expect(h['X-Preload-2']).toBe('Tags'); + expect(h['X-Preload-2-Where']).toBe('b = 2'); + }); + + it('should set expand, joins, or-sql, search cols, advsql', () => { + const h = buildHeaders({ + expand: [{ relation: 'Dept', columns: ['id', 'name'] }, { relation: 'Role' }], + custom_sql_joins: ['LEFT JOIN a ON a.id = b.id', 'INNER JOIN c ON c.id = b.cid'], + custom_sql_or: ['x = 1', 'y = 2'], + search_columns: ['name', 'email'], + advanced_sql: { total: 'a + b' }, + }); + expect(h['X-Expand']).toBe('Dept:id,name|Role'); + expect(h['X-Custom-SQL-Join']).toBe('LEFT JOIN a ON a.id = b.id|INNER JOIN c ON c.id = b.cid'); + expect(h['X-Custom-SQL-Or']).toBe('x = 1 OR y = 2'); + expect(h['X-SearchCols']).toBe('name,email'); + expect(h['X-AdvSQL-total']).toBe('a + b'); + }); + + it('should set boolean flags, pk row and response format', () => { + const h = buildHeaders({ + clean_json: true, + distinct: true, + skip_count: true, + skip_cache: false, + atomic_transaction: true, + single_record_as_object: false, + pk_row: '42', + response_format: 'detail', + }); + expect(h['X-Clean-JSON']).toBe('true'); + expect(h['X-Distinct']).toBe('true'); + expect(h['X-SkipCount']).toBe('true'); + expect(h['X-SkipCache']).toBe('false'); + expect(h['X-Transaction-Atomic']).toBe('true'); + expect(h['X-Single-Record-As-Object']).toBe('false'); + expect(h['X-PKRow']).toBe('42'); + expect(h['X-DetailApi']).toBe('true'); + }); + + it('should set spatial and vector filters as JSON', () => { + const h = buildHeaders({ + filters: [ + { column: 'geom', operator: 'st_dwithin', value: { geom: 'POINT(0 0)', distance: 5 }, logic_operator: 'OR' }, + { column: 'emb', operator: 'cosine_within', value: { vector: [1, 2], distance: 0.3 } }, + ], + }); + expect(JSON.parse(h['X-SpatialFilter-geom'])).toEqual({ + op: 'st_dwithin', value: { geom: 'POINT(0 0)', distance: 5 }, logic: 'or', + }); + expect(JSON.parse(h['X-VectorFilter-emb']).op).toBe('cosine_within'); + }); + + it('should set vector search headers', () => { + const h = buildHeaders({ + vector_search: { column: 'emb', vector: [0.1, 0.2], metric: 'cosine', as: 'dist', direction: 'desc' }, + }); + expect(h['X-Vector-Search-emb']).toBe('cosine'); + expect(h['X-Vector-Search-Vector']).toBe('[0.1,0.2]'); + expect(h['X-Vector-Search-As']).toBe('dist'); + expect(h['X-Vector-Search-Dir']).toBe('desc'); + }); + + it('should encode X-Files as ZIP_ JSON', () => { + const xf = { tablename: 'users', prefix: 'USR', limit: 10 }; + const h = buildHeaders({ xfiles: xf }); + expect(h['X-Files'].startsWith('ZIP_')).toBe(true); + expect(JSON.parse(decodeHeaderValue(h['X-Files']))).toEqual(xf); + }); +}); + describe('buildHeaders', () => { it('should set X-Select-Fields for columns', () => { const h = buildHeaders({ columns: ['id', 'name', 'email'] }); diff --git a/resolvespec-js/src/common/types.ts b/resolvespec-js/src/common/types.ts index fd148d0..c81a108 100644 --- a/resolvespec-js/src/common/types.ts +++ b/resolvespec-js/src/common/types.ts @@ -5,7 +5,11 @@ export type Operator = | 'like' | 'ilike' | 'in' | 'contains' | 'startswith' | 'endswith' | 'between' | 'between_inclusive' - | 'is_null' | 'is_not_null'; + | 'is_null' | 'is_not_null' + // PostGIS spatial (sent via X-SpatialFilter-{col}) + | 'st_dwithin' | 'bbox' + // pgvector similarity (sent via X-VectorFilter-{col}) + | 'l2_within' | 'cosine_within' | 'ip_within'; export type Operation = 'read' | 'create' | 'update' | 'delete'; export type SortDirection = 'asc' | 'desc' | 'ASC' | 'DESC'; @@ -61,6 +65,54 @@ export interface ComputedColumn { expression: string; } +export type VectorMetric = 'l2' | 'cosine' | 'ip'; +export type ResponseFormat = 'simple' | 'detail' | 'syncfusion'; + +/** pgvector KNN search: order by distance between `column` and `vector`. */ +export interface VectorSearchOption { + column: string; + vector: number[]; + metric?: VectorMetric; + /** Distance column alias. Default `_distance` */ + as?: string; + direction?: 'asc' | 'desc'; +} + +/** LEFT JOIN expansion of a relation (X-Expand). */ +export interface ExpandOption { + relation: string; + columns?: string[]; +} + +/** X-Files configuration (Go restheadspec XFiles). Sent as a single JSON header. */ +export interface XFiles { + tablename?: string; + schema?: string; + primarykey?: string; + foreignkey?: string; + relatedkey?: string; + sort?: string[]; + prefix?: string; + editable?: boolean; + recursive?: boolean; + expand?: boolean; + rownumber?: boolean; + skipcount?: boolean; + offset?: number; + limit?: number; + columns?: string[]; + omit_columns?: string[]; + cql_columns?: string[]; + sql_joins?: string[]; + sql_or?: string[]; + sql_and?: string[]; + parenttables?: XFiles[]; + childtables?: XFiles[]; + filter_fields?: { field: string; value: string; operator: string }[]; + cursor_forward?: string; + cursor_backward?: string; +} + export interface Options { preload?: PreloadOption[]; columns?: string[]; @@ -75,6 +127,39 @@ export interface Options { cursor_forward?: string; cursor_backward?: string; fetch_row_number?: string; + vector_search?: VectorSearchOption; +} + +/** Options only available to the header-based (restheadspec) protocol. */ +export interface HeaderSpecOptions extends Options { + /** X-Expand: LEFT JOIN relations */ + expand?: ExpandOption[]; + /** X-Custom-SQL-Join: raw JOIN clauses */ + custom_sql_joins?: string[]; + /** X-Custom-SQL-Or: raw SQL, OR-combined */ + custom_sql_or?: string[]; + /** X-SearchCols: columns for multi-column search */ + search_columns?: string[]; + /** X-AdvSQL-{col}: column -> SQL expression */ + advanced_sql?: Record; + /** X-Clean-JSON */ + clean_json?: boolean; + /** X-Distinct */ + distinct?: boolean; + /** X-SkipCount: skip total count query */ + skip_count?: boolean; + /** X-SkipCache */ + skip_cache?: boolean; + /** X-PKRow: primary key value of a row to fetch */ + pk_row?: string; + /** X-SimpleApi / X-DetailApi / X-Syncfusion */ + response_format?: ResponseFormat; + /** X-Single-Record-As-Object (server default true) */ + single_record_as_object?: boolean; + /** X-Transaction-Atomic */ + atomic_transaction?: boolean; + /** X-Files: single JSON configuration */ + xfiles?: XFiles; } export interface RequestBody { diff --git a/resolvespec-js/src/headerspec/client.ts b/resolvespec-js/src/headerspec/client.ts index e08a55b..e1eb6cb 100644 --- a/resolvespec-js/src/headerspec/client.ts +++ b/resolvespec-js/src/headerspec/client.ts @@ -5,7 +5,7 @@ import type { ClientConfig, CustomOperator, FilterOption, - Options, + HeaderSpecOptions, PreloadOption, SortOption, } from "../common/types"; @@ -59,8 +59,15 @@ function decodeBase64(str: string): string { * - X-Fetch-RowNumber: row number fetch * - X-CQL-SEL-{col}: computed columns * - X-Custom-SQL-W: custom operators (AND) + * - X-Preload-Where: where for X-Preload (extra where groups use X-Preload-{n}[-Where]) + * - X-SpatialFilter-{col} / X-VectorFilter-{col}: JSON {op,value,logic} + * - X-Vector-Search-{col|vector|as|dir}: pgvector KNN + * - X-Expand, X-Custom-SQL-Join, X-Custom-SQL-Or, X-SearchCols, X-AdvSQL-{col} + * - X-Clean-JSON, X-Distinct, X-SkipCount, X-SkipCache, X-PKRow + * - X-SimpleApi / X-DetailApi / X-Syncfusion, X-Single-Record-As-Object + * - X-Transaction-Atomic, X-Files */ -export function buildHeaders(options: Options): Record { +export function buildHeaders(options: HeaderSpecOptions): Record { const headers: Record = {}; // Column selection @@ -79,6 +86,17 @@ export function buildHeaders(options: Options): Record { const op = mapOperatorToHeaderOp(filter.operator); const valueStr = formatFilterValue(filter); + const geoPrefix = geoFilterHeader(filter.operator); + if (geoPrefix) { + const payload: Record = { + op: filter.operator, + value: filter.value, + }; + if (logicOp === "OR") payload.logic = "or"; + headers[`${geoPrefix}${filter.column}`] = JSON.stringify(payload); + continue; + } + if (filter.operator === "eq" && logicOp === "AND") { // Simple field filter shorthand headers[`X-FieldFilter-${filter.column}`] = valueStr; @@ -117,13 +135,94 @@ export function buildHeaders(options: Options): Record { // Preload if (options.preload?.length) { - const parts = options.preload.map((p: PreloadOption) => { - if (p.columns?.length) { - return `${p.relation}:${p.columns.join(",")}`; + // Go applies X-Preload-Where to every preload in the matching X-Preload header, + // so preloads are grouped by where clause. + const groups = new Map(); + for (const p of options.preload) { + const spec = p.columns?.length + ? `${p.relation}:${p.columns.join(",")}` + : p.relation; + const where = p.where ?? ""; + groups.set(where, [...(groups.get(where) ?? []), spec]); + } + let n = 0; + for (const [where, specs] of groups) { + if (!where) { + headers["X-Preload"] = specs.join("|"); + } else if (!groups.has("") && n === 0) { + // X-Preload-Where would also apply to a where-less X-Preload, so only use it alone + headers["X-Preload"] = specs.join("|"); + headers["X-Preload-Where"] = where; + n++; + } else { + n++; + headers[`X-Preload-${n}`] = specs.join("|"); + headers[`X-Preload-${n}-Where`] = where; } - return p.relation; - }); - headers["X-Preload"] = parts.join("|"); + } + } + + // Expand (LEFT JOIN) + if (options.expand?.length) { + headers["X-Expand"] = options.expand + .map((e) => + e.columns?.length ? `${e.relation}:${e.columns.join(",")}` : e.relation, + ) + .join("|"); + } + + if (options.custom_sql_joins?.length) { + headers["X-Custom-SQL-Join"] = options.custom_sql_joins.join("|"); + } + if (options.custom_sql_or?.length) { + headers["X-Custom-SQL-Or"] = options.custom_sql_or.join(" OR "); + } + if (options.search_columns?.length) { + headers["X-SearchCols"] = options.search_columns.join(","); + } + if (options.advanced_sql) { + for (const [col, sql] of Object.entries(options.advanced_sql)) { + headers[`X-AdvSQL-${col}`] = sql; + } + } + + // pgvector KNN search + if (options.vector_search) { + const vs = options.vector_search; + headers[`X-Vector-Search-${vs.column}`] = vs.metric ?? "l2"; + headers["X-Vector-Search-Vector"] = JSON.stringify(vs.vector); + if (vs.as) headers["X-Vector-Search-As"] = vs.as; + if (vs.direction) headers["X-Vector-Search-Dir"] = vs.direction; + } + + // Flags + const flags: [string, boolean | undefined][] = [ + ["X-Clean-JSON", options.clean_json], + ["X-Distinct", options.distinct], + ["X-SkipCount", options.skip_count], + ["X-SkipCache", options.skip_cache], + ["X-Transaction-Atomic", options.atomic_transaction], + ["X-Single-Record-As-Object", options.single_record_as_object], + ]; + for (const [name, val] of flags) { + if (val !== undefined) headers[name] = String(val); + } + + if (options.pk_row) { + headers["X-PKRow"] = options.pk_row; + } + + if (options.response_format) { + const formatHeaders = { + simple: "X-SimpleApi", + detail: "X-DetailApi", + syncfusion: "X-Syncfusion", + } as const; + headers[formatHeaders[options.response_format]] = "true"; + } + + if (options.xfiles) { + headers["X-Files"] = encodeHeaderValue(JSON.stringify(options.xfiles)); } // Fetch row number @@ -149,6 +248,17 @@ export function buildHeaders(options: Options): Record { return headers; } +const VECTOR_OPS = new Set(["l2_within", "cosine_within", "ip_within"]); + +function geoFilterHeader(operator: string): string | null { + const op = operator.toLowerCase(); + if (VECTOR_OPS.has(op) || op.endsWith("_within")) return "X-VectorFilter-"; + if (op.startsWith("st_") || op === "bbox" || op === "&&") { + return "X-SpatialFilter-"; + } + return null; +} + function mapOperatorToHeaderOp(operator: string): string { switch (operator) { case "eq": @@ -280,7 +390,7 @@ export class HeaderSpecClient { schema: string, entity: string, id?: string, - options?: Options, + options?: HeaderSpecOptions, ): Promise> { const url = this.buildUrl(schema, entity, id); const optHeaders = options ? buildHeaders(options) : {}; @@ -294,7 +404,7 @@ export class HeaderSpecClient { schema: string, entity: string, data: any, - options?: Options, + options?: HeaderSpecOptions, ): Promise> { const url = this.buildUrl(schema, entity); const optHeaders = options ? buildHeaders(options) : {}; @@ -310,7 +420,7 @@ export class HeaderSpecClient { entity: string, id: string, data: any, - options?: Options, + options?: HeaderSpecOptions, ): Promise> { const url = this.buildUrl(schema, entity, id); const optHeaders = options ? buildHeaders(options) : {};