Skip to content

Commit 92db3e5

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(driver-sql): honor external.columnMap on federated objects (ADR-0015) (#2149)
When a federated (external) object declares external.columnMap ({ remoteColumn -> localField }), the SQL driver now translates the query pipeline to the physical remote columns: - registerExternalObject inverts columnMap to localField -> remoteColumn (+ keeps the inverse for read remap). - WHERE (object/array/mongo forms) and ORDER BY resolve the local field to its remote column via remoteColumn(); value coercion stays keyed by the local field. - formatOutput renames remote-column keys back to local field names on read (no-op when no columnMap), so coercion + callers see local names. - write payloads are key-remapped after formatInput. Managed objects and external objects without a columnMap are byte-for-byte unchanged (the resolver falls back to each site's existing behavior). Adds sql-driver-external-columnmap.test.ts (read/where/orderBy/date-coercion + managed control). ADR-0015 §18 updated. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 331a8b8 commit 92db3e5

4 files changed

Lines changed: 228 additions & 17 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
feat(driver-sql): honor `external.columnMap` on federated (external) objects (ADR-0015).
6+
7+
When a federated object declares `external.columnMap` ({ remoteColumn -> localField }),
8+
the SQL driver now translates queries to the physical remote columns: WHERE and
9+
ORDER BY map local fields to remote columns (value coercion stays keyed by the local
10+
field), `formatOutput` renames remote-column keys back to local field names on read,
11+
and write payloads are key-remapped. Managed objects and external objects without a
12+
columnMap are unchanged (the resolver falls back to the existing per-site behavior).

docs/adr/0015-external-datasource-federation.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -929,13 +929,21 @@ contract validated green and then queried the wrong (non-existent) table.
929929
is driven purely by the declared field types. Keep external object fields to
930930
well-understood scalar types.
931931

932+
### `external.columnMap` (added in a follow-up)
933+
934+
`external.columnMap` ({ remoteColumn → localField }) is now honored on the read +
935+
write paths. `registerExternalObject` inverts it to localField → remoteColumn;
936+
`SqlDriver` translates WHERE + ORDER BY to the physical remote column (coercion
937+
stays keyed by the local field), `formatOutput` renames remote-column keys back to
938+
local field names, and write payloads are key-remapped after `formatInput`.
939+
Managed objects and external objects without a columnMap are unchanged (the
940+
resolver falls back to the existing per-site behavior). `field.columnName`
941+
reconciliation (its inverse) is left untouched — `columnMap` is the
942+
external-binding mechanism.
943+
932944
### Explicitly out of scope (separate follow-ups)
933945

934-
1. **`external.columnMap`** (remote column name ≠ local field key). The driver's
935-
`select` / `where` / `orderBy` do not currently apply column-name translation,
936-
and `columnMap` is the inverse of per-field `field.columnName` — reconciling
937-
the two into one source of truth is its own change.
938-
2. **Native-analytics SQL over external objects** — the analytics service compiles
946+
1. **Native-analytics SQL over external objects** — the analytics service compiles
939947
its own `FROM "<table>"` outside the driver and needs the same remote-table
940948
awareness.
941949
3. **Auto-connecting declared datasources** as queryable ObjectQL drivers in the
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Federation columnMap tests (ADR-0015 §18).
5+
*
6+
* An external object can bind to a remote table whose COLUMN names differ from
7+
* the local field names via `external.columnMap` ({ remoteColumn -> localField }).
8+
* Reads must come back keyed by local field names (with coercion), and WHERE /
9+
* ORDER BY must translate local fields to the physical remote columns.
10+
*/
11+
12+
import { describe, it, expect, afterAll } from 'vitest';
13+
import { rmSync } from 'node:fs';
14+
import { tmpdir } from 'node:os';
15+
import { join } from 'node:path';
16+
import { SqlDriver } from '../src/index.js';
17+
18+
// Remote table with deliberately non-matching column names.
19+
const REMOTE = {
20+
name: 'legacy_cust',
21+
fields: {
22+
cust_id: { type: 'text' },
23+
full_name: { type: 'text' },
24+
region_code: { type: 'text' },
25+
ltv: { type: 'number' },
26+
signup_dt: { type: 'date' },
27+
},
28+
};
29+
30+
// Local federated object: clean field names, bound to the remote columns.
31+
const COLUMN_MAP = { full_name: 'name', region_code: 'region', ltv: 'value', signup_dt: 'signed_up', cust_id: 'id' };
32+
const LOCAL_FIELDS = {
33+
name: { type: 'text' },
34+
region: { type: 'text' },
35+
value: { type: 'number' },
36+
signed_up: { type: 'date' },
37+
};
38+
39+
let file: string;
40+
afterAll(() => { if (file) { try { rmSync(file, { force: true }); } catch { /* ignore */ } } });
41+
42+
async function seedRemote(path: string) {
43+
const fx = new SqlDriver({ client: 'better-sqlite3', connection: { filename: path }, useNullAsDefault: true }) as any;
44+
fx.name = 'fx';
45+
await fx.connect();
46+
await fx.initObjects([REMOTE]);
47+
await fx.create('legacy_cust', { cust_id: 'c1', full_name: 'Aurora', region_code: 'NA', ltv: 480, signup_dt: '2026-01-10' });
48+
await fx.create('legacy_cust', { cust_id: 'c2', full_name: 'Borealis', region_code: 'EU', ltv: 312, signup_dt: '2026-02-15' });
49+
await fx.create('legacy_cust', { cust_id: 'c3', full_name: 'Cyan', region_code: 'EU', ltv: 95, signup_dt: '2026-03-01' });
50+
await fx.disconnect();
51+
}
52+
53+
function ext(path: string): any {
54+
const d = new SqlDriver({ client: 'better-sqlite3', connection: { filename: path }, useNullAsDefault: true, schemaMode: 'external' } as any) as any;
55+
d.name = 'extds';
56+
return d;
57+
}
58+
59+
describe('SqlDriver external columnMap (ADR-0015 §18)', () => {
60+
it('reads come back keyed by local field names from differently-named remote columns', async () => {
61+
file = join(tmpdir(), `os-cm-${process.pid}-${Date.now()}.db`);
62+
await seedRemote(file);
63+
const d = ext(file);
64+
await d.connect();
65+
try {
66+
d.registerExternalObject({ name: 'cm_customer', external: { remoteName: 'legacy_cust', columnMap: COLUMN_MAP }, fields: LOCAL_FIELDS });
67+
68+
const rows = await d.find('cm_customer', {});
69+
expect(rows).toHaveLength(3);
70+
const aurora = rows.find((r: any) => r.name === 'Aurora');
71+
expect(aurora).toBeTruthy();
72+
// Local field names present; remote column names absent.
73+
expect(aurora.id).toBe('c1');
74+
expect(aurora.region).toBe('NA');
75+
expect(typeof aurora.value).toBe('number');
76+
expect(aurora.value).toBe(480);
77+
expect(aurora.signed_up).toBe('2026-01-10');
78+
expect(aurora.full_name).toBeUndefined();
79+
expect(aurora.region_code).toBeUndefined();
80+
expect(aurora.ltv).toBeUndefined();
81+
} finally { await d.disconnect(); }
82+
});
83+
84+
it('WHERE (object + array form) translates local field -> remote column', async () => {
85+
const d = ext(file);
86+
await d.connect();
87+
try {
88+
d.registerExternalObject({ name: 'cm_customer', external: { remoteName: 'legacy_cust', columnMap: COLUMN_MAP }, fields: LOCAL_FIELDS });
89+
// object form: region -> region_code
90+
const eu = await d.find('cm_customer', { where: { region: 'EU' } });
91+
expect(eu.map((r: any) => r.name).sort()).toEqual(['Borealis', 'Cyan']);
92+
// array criterion: value -> ltv
93+
const big = await d.find('cm_customer', { where: [['value', '>', 200]] });
94+
expect(big.map((r: any) => r.name).sort()).toEqual(['Aurora', 'Borealis']);
95+
// mongo operator: value $gte
96+
const gte = await d.find('cm_customer', { where: { value: { $gte: 312 } } });
97+
expect(gte.map((r: any) => r.name).sort()).toEqual(['Aurora', 'Borealis']);
98+
} finally { await d.disconnect(); }
99+
});
100+
101+
it('ORDER BY + date coercion work through the columnMap', async () => {
102+
const d = ext(file);
103+
await d.connect();
104+
try {
105+
d.registerExternalObject({ name: 'cm_customer', external: { remoteName: 'legacy_cust', columnMap: COLUMN_MAP }, fields: LOCAL_FIELDS });
106+
// orderBy value (-> ltv) desc
107+
const ordered = await d.find('cm_customer', { orderBy: [{ field: 'value', order: 'desc' }] });
108+
expect(ordered.map((r: any) => r.value)).toEqual([480, 312, 95]);
109+
// date filter: signed_up (-> signup_dt) coercion keyed by local field
110+
const onDate = await d.find('cm_customer', { where: { signed_up: '2026-02-15' } });
111+
expect(onDate.map((r: any) => r.name)).toEqual(['Borealis']);
112+
} finally { await d.disconnect(); }
113+
});
114+
115+
it('managed objects (no columnMap) are unaffected — own column names', async () => {
116+
const d = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as any;
117+
d.name = 'default';
118+
await d.connect();
119+
try {
120+
await d.initObjects([{ name: 'plain', fields: { title: { type: 'text' }, qty: { type: 'number' } } }]);
121+
await d.create('plain', { id: 'p1', title: 'Widget', qty: 5 });
122+
const rows = await d.find('plain', { where: { title: 'Widget' }, orderBy: [{ field: 'qty', order: 'asc' }] });
123+
expect(rows).toHaveLength(1);
124+
expect(rows[0].title).toBe('Widget');
125+
expect(rows[0].qty).toBe(5);
126+
} finally { await d.disconnect(); }
127+
});
128+
});

packages/plugins/driver-sql/src/sql-driver.ts

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,10 @@ export class SqlDriver implements IDataDriver {
207207
protected physicalTableByObject: Record<string, string> = {};
208208
protected physicalSchemaByObject: Record<string, string> = {};
209209
protected objectByPhysicalTable: Record<string, string> = {};
210+
/** External columnMap (ADR-0015): logical field -> physical remote column (for WHERE/ORDER BY/writes). */
211+
protected fieldColumnByObject: Record<string, Record<string, string>> = {};
212+
/** External columnMap inverse: physical remote column -> logical field (for read output remap). */
213+
protected columnFieldByObject: Record<string, Record<string, string>> = {};
210214
protected tablesWithTimestamps: Set<string> = new Set();
211215
/**
212216
* Autonumber field configs per table, captured during initObjects.
@@ -453,7 +457,7 @@ export class SqlDriver implements IDataDriver {
453457
if (query.orderBy && Array.isArray(query.orderBy)) {
454458
for (const item of query.orderBy) {
455459
if (item.field) {
456-
b.orderBy(this.mapSortField(item.field), item.order || 'asc');
460+
b.orderBy(this.remoteColumn(object, item.field, this.mapSortField(item.field)), item.order || 'asc');
457461
}
458462
}
459463
}
@@ -567,7 +571,7 @@ export class SqlDriver implements IDataDriver {
567571
await this.fillAutoNumberFields(object, toInsert, options);
568572

569573
const builder = this.getBuilder(object, options);
570-
const formatted = this.formatInput(object, toInsert);
574+
const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toInsert));
571575

572576
const result = await builder.insert(formatted).returning('*');
573577
return this.formatOutput(object, result[0]);
@@ -881,7 +885,7 @@ export class SqlDriver implements IDataDriver {
881885
this.auditMissingTenant(object, 'update', options);
882886
const builder = this.getBuilder(object, options).where('id', id);
883887
this.applyTenantScope(builder, object, options);
884-
const formatted = this.formatInput(object, data);
888+
const formatted = this.applyWriteColumnMap(object, this.formatInput(object, data));
885889

886890
if (this.tablesWithTimestamps.has(object)) {
887891
if (this.isSqlite) {
@@ -914,7 +918,7 @@ export class SqlDriver implements IDataDriver {
914918
this.injectTenantOnInsert(object, toUpsert, options);
915919
await this.fillAutoNumberFields(object, toUpsert, options);
916920

917-
const formatted = this.formatInput(object, toUpsert);
921+
const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toUpsert));
918922
const mergeKeys = conflictKeys && conflictKeys.length > 0 ? conflictKeys : ['id'];
919923

920924
const builder = this.getBuilder(object, options);
@@ -1283,7 +1287,7 @@ export class SqlDriver implements IDataDriver {
12831287
name: string;
12841288
fields?: Record<string, any>;
12851289
tenancy?: any;
1286-
external?: { remoteName?: string; remoteSchema?: string };
1290+
external?: { remoteName?: string; remoteSchema?: string; columnMap?: Record<string, string> };
12871291
}): void {
12881292
const key = schema.name;
12891293
const remoteName = schema.external?.remoteName || schema.name;
@@ -1300,6 +1304,23 @@ export class SqlDriver implements IDataDriver {
13001304
}
13011305
}
13021306

1307+
// External columnMap (ADR-0015) is declared as { remoteColumn -> localField }.
1308+
// Keep it for read-output remap, and invert to { localField -> remoteColumn }
1309+
// for WHERE/ORDER BY/write translation. Absent => managed-identical behavior.
1310+
const columnMap = schema.external?.columnMap;
1311+
if (columnMap && typeof columnMap === 'object' && Object.keys(columnMap).length > 0) {
1312+
const fieldToCol: Record<string, string> = {};
1313+
const colToField: Record<string, string> = {};
1314+
for (const [remoteCol, localField] of Object.entries(columnMap)) {
1315+
if (typeof localField === 'string' && localField) {
1316+
fieldToCol[localField] = remoteCol;
1317+
colToField[remoteCol] = localField;
1318+
}
1319+
}
1320+
this.fieldColumnByObject[key] = fieldToCol;
1321+
this.columnFieldByObject[key] = colToField;
1322+
}
1323+
13031324
const jsonCols: string[] = [];
13041325
const booleanCols: string[] = [];
13051326
const numericCols: string[] = [];
@@ -1944,7 +1965,7 @@ export class SqlDriver implements IDataDriver {
19441965

19451966
for (const [key, value] of Object.entries(filters)) {
19461967
if (['limit', 'offset', 'fields', 'orderBy'].includes(key)) continue;
1947-
builder.where(key, this.coerceFilterValue(table, key, value) as any);
1968+
builder.where(this.remoteColumn(table, key, key), this.coerceFilterValue(table, key, value) as any);
19481969
}
19491970
return;
19501971
}
@@ -1965,8 +1986,9 @@ export class SqlDriver implements IDataDriver {
19651986
const isCriterion = typeof fieldRaw === 'string' && typeof op === 'string';
19661987

19671988
if (isCriterion) {
1968-
const field = this.mapSortField(fieldRaw);
1969-
const coerced = this.coerceFilterValue(table, field, value);
1989+
const localField = this.mapSortField(fieldRaw);
1990+
const field = this.remoteColumn(table, fieldRaw, localField);
1991+
const coerced = this.coerceFilterValue(table, localField, value);
19701992
const apply = (b: any) => {
19711993
const method = nextJoin === 'or' ? 'orWhere' : 'where';
19721994
const methodIn = nextJoin === 'or' ? 'orWhereIn' : 'whereIn';
@@ -2044,10 +2066,11 @@ export class SqlDriver implements IDataDriver {
20442066
}
20452067
});
20462068
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
2047-
const field = this.mapSortField(key);
2069+
const localField = this.mapSortField(key);
2070+
const field = this.remoteColumn(table, key, localField);
20482071
for (const [op, opValue] of Object.entries(value as Record<string, any>)) {
20492072
const method = logicalOp === 'or' ? 'orWhere' : 'where';
2050-
const coerced = this.coerceFilterValue(table, field, opValue);
2073+
const coerced = this.coerceFilterValue(table, localField, opValue);
20512074
switch (op) {
20522075
case '$eq':
20532076
(builder as any)[method](field, coerced);
@@ -2085,9 +2108,10 @@ export class SqlDriver implements IDataDriver {
20852108
}
20862109
}
20872110
} else {
2088-
const field = this.mapSortField(key);
2111+
const localField = this.mapSortField(key);
2112+
const field = this.remoteColumn(table, key, localField);
20892113
const method = logicalOp === 'or' ? 'orWhere' : 'where';
2090-
(builder as any)[method](field, this.coerceFilterValue(table, field, value) as any);
2114+
(builder as any)[method](field, this.coerceFilterValue(table, localField, value) as any);
20912115
}
20922116
}
20932117
}
@@ -2100,6 +2124,30 @@ export class SqlDriver implements IDataDriver {
21002124
return field;
21012125
}
21022126

2127+
/**
2128+
* Physical column for a logical field on an external object that declares an
2129+
* `external.columnMap` (ADR-0015). Returns `fallback` (the caller's existing
2130+
* per-site resolution) when the object has no columnMap, so managed objects
2131+
* and external objects without a columnMap are byte-for-byte unchanged.
2132+
*/
2133+
protected remoteColumn(object: string | null | undefined, field: string, fallback: string): string {
2134+
const m = object ? this.fieldColumnByObject[object] : undefined;
2135+
return (m && m[field]) || fallback;
2136+
}
2137+
2138+
/**
2139+
* Remap a write payload's logical field keys to physical remote columns for an
2140+
* external object with a columnMap. No-op otherwise. Applied AFTER formatInput
2141+
* (whose value coercion is keyed by logical field name).
2142+
*/
2143+
protected applyWriteColumnMap(object: string, data: any): any {
2144+
const m = this.fieldColumnByObject[object];
2145+
if (!m || !data || typeof data !== 'object') return data;
2146+
const out: any = {};
2147+
for (const [k, v] of Object.entries(data)) out[m[k] ?? k] = v;
2148+
return out;
2149+
}
2150+
21032151
protected mapAggregateFunc(func: string): string {
21042152
switch (func) {
21052153
case 'count':
@@ -2412,6 +2460,21 @@ export class SqlDriver implements IDataDriver {
24122460
protected formatOutput(object: string, data: any): any {
24132461
if (!data) return data;
24142462

2463+
// External columnMap (ADR-0015): rename physical remote-column keys to local
2464+
// field names BEFORE coercion (which is keyed by local field). No-op for
2465+
// managed objects and external objects without a columnMap.
2466+
const colToField = this.columnFieldByObject[object];
2467+
if (colToField && typeof data === 'object') {
2468+
for (const [remoteCol, localField] of Object.entries(colToField)) {
2469+
if (remoteCol !== localField && Object.prototype.hasOwnProperty.call(data, remoteCol)) {
2470+
// Explicit columnMap wins: the remote column is the source of truth for
2471+
// this local field, even if a same-named native column also exists.
2472+
data[localField] = data[remoteCol];
2473+
delete data[remoteCol];
2474+
}
2475+
}
2476+
}
2477+
24152478
if (this.isSqlite) {
24162479
const jsonFields = this.jsonFields[object];
24172480
if (jsonFields && jsonFields.length > 0) {

0 commit comments

Comments
 (0)