From e9936d5592c97c96180795596f92eacb638126fc Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:56:09 +0800 Subject: [PATCH 1/2] fix(driver-sql,objectql): honor external.remoteName/remoteSchema in the federation read path (ADR-0015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The query path ignored an external object's remoteName/remoteSchema and resolved the physical table from the object name, so a federated object bound to a differently-named remote table failed with "no such table". ADR-0015's own canonical example (wh_order -> mart.fact_orders) was therefore broken. - SqlDriver.registerExternalObject(): DDL-free metadata for external objects (physical remote table + read-coercion maps) — the read-path counterpart to initObjects(), which is DDL-gated off for external schemaMode. - getBuilder() resolves the physical remote table (+ .withSchema for pg/mysql; sqlite no-op). Coercion re-keyed via coercionKey() so date/datetime filters still resolve after the table switch. Managed path byte-for-byte unchanged. - engine/plugin route objects with external!=null to registerExternalObject in both boot schema-sync and the on-demand syncObjectSchema path. - IDataDriver.registerExternalObject?() declared optional (non-SQL drivers skip). - Tests: sql-driver-external-remote-name.test.ts + a ddl-gate case. ADR-0015 §18 addendum. Scope: remoteName (all dialects) + remoteSchema (pg/mysql). columnMap, native-analytics SQL over external objects, and auto-connecting declared datasources are tracked follow-ups (ADR-0015 §18). Co-Authored-By: Claude Opus 4.8 --- .../0015-external-datasource-federation.md | 84 +++++++++ packages/objectql/src/engine.ts | 13 +- packages/objectql/src/plugin.ts | 27 +++ .../src/sql-driver-ddl-gate.test.ts | 15 ++ .../sql-driver-external-remote-name.test.ts | 165 ++++++++++++++++++ packages/plugins/driver-sql/src/sql-driver.ts | 128 +++++++++++++- packages/spec/src/contracts/data-driver.ts | 12 ++ 7 files changed, 438 insertions(+), 6 deletions(-) create mode 100644 packages/plugins/driver-sql/src/sql-driver-external-remote-name.test.ts diff --git a/docs/adr/0015-external-datasource-federation.md b/docs/adr/0015-external-datasource-federation.md index 22a85ca40f..edb3a6788e 100644 --- a/docs/adr/0015-external-datasource-federation.md +++ b/docs/adr/0015-external-datasource-federation.md @@ -865,3 +865,87 @@ The ADR is considered "delivered" when: - `packages/plugins/driver-sql/src/sql-driver.ts` (introspectSchema, createTable, alterTable) - `packages/services/service-ai/src/tools/query-data.tool.ts` - `packages/services/service-ai/src/schema-retriever.ts` + +--- + +## 18. Addendum (2026-06): Federation read path honours `remoteName` / `remoteSchema` + +**Status:** accepted — implements §3.1 / §8 of this ADR that shipped only partially. + +### Problem + +The spec, introspection (`os datasource introspect`), boot validation (§5.2), and +the write gate (§5.3) all honoured an object's `external.remoteName` / +`external.remoteSchema`. **The query-execution path did not.** Reads resolved the +physical table from the *object name* alone, ignoring the binding — so the +canonical example in §8 (object `wh_order` → `external: { remoteSchema: 'mart', +remoteName: 'fact_orders' }`) could not actually be queried. + +Reproduced with the real engine + a real better-sqlite3 driver: an object +`ext_customer` bound to remote table `remote_customers` via `remoteName` failed +with `no such table: ext_customer`; only naming the object `remote_customers` +returned rows. This is a correctness gap, not a feature request — the declared +contract validated green and then queried the wrong (non-existent) table. + +### Root cause + +- `SqlDriver.getBuilder(object)` did `this.knex(object)` — the object name *was* + the physical table. +- Per-object read-coercion metadata (`json`/`boolean`/`numeric`/`date`/`datetime`) + is populated only inside `initObjects()`, whose first statement is + `assertSchemaMutable()` (the DDL gate, §5.1) → it throws for any + `schemaMode !== 'managed'` driver. The boot schema-sync swallowed that throw + per-object, leaving external objects with **zero** coercion metadata and no + table-name mapping. + +### Fix + +- **`SqlDriver.registerExternalObject(schema)`** — a DDL-free counterpart to + `initObjects()`. It records the physical remote table + (`physicalTableByObject` / `physicalSchemaByObject`) and populates the same + coercion maps, keyed by **object name** (matching `formatInput`/`formatOutput`/ + `coerceFilterValue`), without running any DDL. +- **`getBuilder()`** now resolves `physicalTableByObject[object] ?? object`, and + applies `.withSchema()` when a remote schema is recorded. Managed objects miss + both maps, so the path is unchanged (one `undefined` lookup). +- **Coercion re-keying** — `applyFilters`/`applyFilterCondition` map the builder's + physical table back to the object name (`coercionKey`) so date/datetime filter + coercion still resolves after the table switch. +- **Engine/plugin routing** — `ObjectQLPlugin.syncRegisteredSchemas` (boot) and + `ObjectQL.syncObjectSchema` (on-demand) route objects with `external != null` + to `driver.registerExternalObject()` instead of the DDL `syncSchema`. The + on-demand path lets an app register a *late* external driver (one added via an + `onEnable` hook) and then make its objects queryable. +- **Driver contract** — `IDataDriver.registerExternalObject?()` is declared + optional, so non-SQL drivers degrade gracefully (the engine skips external + objects they can't serve). + +### Scope + +- `remoteName` is honoured on **all** dialects. +- `remoteSchema` is applied via `knex.withSchema()` on Postgres / MySQL; on + **SQLite it is a no-op** (no schema namespace) and logs a one-time warning. +- External reads use **best-effort coercion**: with no DDL/`columnInfo`, coercion + is driven purely by the declared field types. Keep external object fields to + well-understood scalar types. + +### Explicitly out of scope (separate follow-ups) + +1. **`external.columnMap`** (remote column name ≠ local field key). The driver's + `select` / `where` / `orderBy` do not currently apply column-name translation, + and `columnMap` is the inverse of per-field `field.columnName` — reconciling + the two into one source of truth is its own change. +2. **Native-analytics SQL over external objects** — the analytics service compiles + its own `FROM ""` outside the driver and needs the same remote-table + awareness. +3. **Auto-connecting declared datasources** as queryable ObjectQL drivers in the + standalone runtime. Today a declared non-default datasource appears in the + metadata registry (Setup → Datasources) but is only made queryable by + registering a live driver under its name (e.g. via an app `onEnable` hook). + +### Tests + +`packages/plugins/driver-sql/src/sql-driver-external-remote-name.test.ts` (read / +filter / coercion against a differently-named remote table, no DDL leakage) and an +added case in `sql-driver-ddl-gate.test.ts` (`registerExternalObject` is DDL-free). +File-based better-sqlite3 tests require Node ≥ 25 (ABI 141) in this repo. diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 74d2deee2f..a38538f104 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -2815,7 +2815,18 @@ export class ObjectQL implements IDataEngine { const obj = this._registry.getObject(objectName) as any; if (!obj) return; const driver = this.getDriverForObject(objectName); - if (!driver || typeof (driver as any).syncSchema !== 'function') return; + if (!driver) return; + // Federated (external) object (ADR-0015): register read metadata WITHOUT DDL + // (its remote schema is owned externally). This is what an app's onEnable + // calls after registering a late external driver so coercion maps + the + // physical-table mapping exist for queries. See SqlDriver.registerExternalObject. + if (obj.external != null) { + if (typeof (driver as any).registerExternalObject === 'function') { + await (driver as any).registerExternalObject(obj); + } + return; + } + if (typeof (driver as any).syncSchema !== 'function') return; const tableName = StorageNameMapping.resolveTableName(obj); await (driver as any).syncSchema(tableName, obj); } diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 730e47970a..c147250789 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -698,6 +698,33 @@ export class ObjectQLPlugin implements Plugin { continue; } + // Federated (external) objects (ADR-0015): their schema is owned by the + // remote database, so DDL (syncSchema/initObjects) is forbidden and would + // throw. Register read metadata (physical remote table + coercion maps) + // without DDL so the query path resolves to the remote table, then skip + // the DDL grouping below. + if (obj.external != null) { + if (typeof driver.registerExternalObject === 'function') { + try { + await driver.registerExternalObject(obj); + synced++; + } catch (e: unknown) { + ctx.logger.warn('Failed to register external object metadata', { + object: obj.name, + driver: driver.name, + error: e instanceof Error ? e.message : String(e), + }); + } + } else { + ctx.logger.debug('Driver does not support registerExternalObject, skipping external object', { + object: obj.name, + driver: driver.name, + }); + skipped++; + } + continue; + } + if (typeof driver.syncSchema !== 'function') { ctx.logger.debug('Driver does not support syncSchema, skipping', { object: obj.name, diff --git a/packages/plugins/driver-sql/src/sql-driver-ddl-gate.test.ts b/packages/plugins/driver-sql/src/sql-driver-ddl-gate.test.ts index 380e57c590..880732918e 100644 --- a/packages/plugins/driver-sql/src/sql-driver-ddl-gate.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-ddl-gate.test.ts @@ -68,6 +68,21 @@ describe('SqlDriver DDL gate (ADR-0015)', () => { ); }); + it('registerExternalObject is DDL-free: does not throw on external mode and creates no table', async () => { + driver = makeDriver('external'); + expect(() => + driver.registerExternalObject!({ + name: 'ext_widget', + external: { remoteName: 'widgets' }, + fields: { sku: { type: 'text' } }, + }), + ).not.toThrow(); + const k = (driver as any).knex; + // No DDL ran — neither the object name nor the remote name was created. + expect(await k.schema.hasTable('ext_widget')).toBe(false); + expect(await k.schema.hasTable('widgets')).toBe(false); + }); + it('also blocks DDL in validate-only mode', async () => { driver = makeDriver('validate-only'); await expect( diff --git a/packages/plugins/driver-sql/src/sql-driver-external-remote-name.test.ts b/packages/plugins/driver-sql/src/sql-driver-external-remote-name.test.ts new file mode 100644 index 0000000000..643ec0477e --- /dev/null +++ b/packages/plugins/driver-sql/src/sql-driver-external-remote-name.test.ts @@ -0,0 +1,165 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Federation read-path tests (ADR-0015 addendum). + * + * Before this fix the query path resolved an external object to a table named + * after the OBJECT, ignoring `external.remoteName` — so `find('ext_customer')` + * threw `no such table: ext_customer` even though the object bound to a real + * remote table `remote_customers`. ADR-0015's own canonical example + * (`wh_order` → `mart.fact_orders`) was therefore broken. + * + * These tests stand up one sqlite file as the "remote" database (populated with + * a managed driver), then open a second `schemaMode: 'external'` driver over the + * same file and assert that an object whose name differs from the remote table + * is fully queryable — with read coercion (boolean/json/date) working even + * though no DDL ran for the external object. + */ + +import { describe, it, expect, afterAll } from 'vitest'; +import { rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SqlDriver } from '../src/index.js'; +import { ExternalSchemaModeViolationError } from '@objectstack/spec/shared'; + +const FIELDS = { + name: { type: 'text' }, + flag: { type: 'boolean' }, + meta: { type: 'json' }, + when: { type: 'date' }, + amount: { type: 'number' }, + seen_at: { type: 'datetime' }, +} as const; + +let file: string; + +afterAll(() => { + if (file) { + try { rmSync(file, { force: true }); } catch { /* ignore */ } + } +}); + +async function seedRemote(path: string) { + const fixture = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: path }, + useNullAsDefault: true, + }); + (fixture as any).name = 'fixture'; + await fixture.connect?.(); + // Physical table name deliberately differs from the object name used later. + await (fixture as any).initObjects([{ name: 'remote_customers', fields: FIELDS }]); + await (fixture as any).create('remote_customers', { + id: 'c1', name: 'Acme', flag: true, meta: { tier: 'gold' }, + when: '2026-01-01', amount: 100, seen_at: new Date('2026-01-02T10:00:00.000Z'), + }); + await (fixture as any).create('remote_customers', { + id: 'c2', name: 'Globex', flag: false, meta: { tier: 'silver' }, + when: '2026-02-15', amount: 250, seen_at: new Date('2026-02-16T08:00:00.000Z'), + }); + await fixture.disconnect?.(); +} + +function externalDriver(path: string): SqlDriver { + const ext = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: path }, + useNullAsDefault: true, + schemaMode: 'external', + } as any); + (ext as any).name = 'extds'; + return ext; +} + +describe('SqlDriver external read path — remoteName resolution (ADR-0015)', () => { + it('queries a remote table whose name differs from the object name, with coercion', async () => { + file = join(tmpdir(), `os-ext-read-${process.pid}-${Date.now()}.db`); + await seedRemote(file); + + const ext = externalDriver(file); + await ext.connect?.(); + try { + // DDL-free registration — must NOT throw (unlike initObjects/syncSchema). + expect(() => + ext.registerExternalObject!({ name: 'ext_customer', external: { remoteName: 'remote_customers' }, fields: FIELDS as any }), + ).not.toThrow(); + + // The bug: this used to throw `no such table: ext_customer`. + const rows = await ext.find('ext_customer', {} as any); + expect(rows).toHaveLength(2); + + const acme = rows.find((r: any) => r.name === 'Acme'); + expect(acme).toBeTruthy(); + // Coercion populated despite no DDL having run for the external object: + expect(acme.flag).toBe(true); // boolean (stored 1/0) + expect(acme.meta).toEqual({ tier: 'gold' }); // json (stored as text) + expect(acme.when).toBe('2026-01-01'); // date → YYYY-MM-DD + expect(typeof acme.amount).toBe('number'); // numeric scalar + + // count + findOne route to the remote table too. + expect(await ext.count('ext_customer', {} as any)).toBe(2); + const one = await ext.findOne('ext_customer', { where: { name: 'Globex' } } as any); + expect(one?.name).toBe('Globex'); + + // Filtered reads hit the remote table. + const filtered = await ext.find('ext_customer', { where: { name: 'Acme' } } as any); + expect(filtered).toHaveLength(1); + expect(filtered[0].name).toBe('Acme'); + + // Date filter — guards the coercion re-keying (§3): coercion maps are keyed + // by the OBJECT name even though the builder now targets the remote table. + const byDate = await ext.find('ext_customer', { where: { when: '2026-02-15' } } as any); + expect(byDate).toHaveLength(1); + expect(byDate[0].name).toBe('Globex'); + + // Datetime filter — the SQLite epoch-affinity case the §3 trap would break + // if coercion were keyed by the physical (remote) name instead of object. + const byDatetime = await ext.find('ext_customer', { where: { seen_at: '2026-01-02T10:00:00.000Z' } } as any); + expect(byDatetime.map((r: any) => r.name)).toContain('Acme'); + + // No object-named table was ever created in the remote db (no DDL leaked). + const k = (ext as any).knex; + expect(await k.schema.hasTable('ext_customer')).toBe(false); + expect(await k.schema.hasTable('remote_customers')).toBe(true); + } finally { + await ext.disconnect?.(); + } + }); + + it('still throws on initObjects/syncSchema (DDL) for external objects', async () => { + const ext = externalDriver(file); + await ext.connect?.(); + try { + await expect( + ext.initObjects([{ name: 'ext_customer', fields: FIELDS as any }]), + ).rejects.toBeInstanceOf(ExternalSchemaModeViolationError); + } finally { + await ext.disconnect?.(); + } + }); + + it('an object whose name equals the remote table also works (no remap)', async () => { + const ext = externalDriver(file); + await ext.connect?.(); + try { + ext.registerExternalObject!({ name: 'remote_customers', external: {}, fields: FIELDS as any }); + const rows = await ext.find('remote_customers', {} as any); + expect(rows.length).toBe(2); + } finally { + await ext.disconnect?.(); + } + }); + + it('treats remoteSchema as a no-op on sqlite (bare table)', async () => { + const ext = externalDriver(file); + await ext.connect?.(); + try { + ext.registerExternalObject!({ name: 'ext_cust2', external: { remoteName: 'remote_customers', remoteSchema: 'mart' }, fields: FIELDS as any }); + const rows = await ext.find('ext_cust2', {} as any); + expect(rows.length).toBe(2); + } finally { + await ext.disconnect?.(); + } + }); +}); diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 7617f2d26e..04cdc20828 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -197,6 +197,16 @@ export class SqlDriver implements IDataDriver { protected numericFields: Record = {}; protected dateFields: Record> = {}; protected datetimeFields: Record> = {}; + /** + * Federation read path (ADR-0015). For external objects whose physical + * remote table differs from the object name, these map between the two so + * {@link getBuilder} targets the remote table while the coercion maps above + * stay keyed by OBJECT name (matching formatInput/formatOutput). Empty for + * managed objects, so the managed query path is unchanged. + */ + protected physicalTableByObject: Record = {}; + protected physicalSchemaByObject: Record = {}; + protected objectByPhysicalTable: Record = {}; protected tablesWithTimestamps: Set = new Set(); /** * Autonumber field configs per table, captured during initObjects. @@ -818,8 +828,11 @@ export class SqlDriver implements IDataDriver { row: Record, options?: DriverOptions, ): Promise { - const tableName = StorageNameMapping.resolveTableName({ name: object } as any); - const cfgs = this.autoNumberFields[tableName] || this.autoNumberFields[object]; + // Scan/seed the physical (remote) table for an external object; managed + // objects fall through to the storage-mapped name. Config lookup stays + // keyed by object name (matching initObjects/registerExternalObject). + const tableName = this.physicalTableByObject[object] ?? StorageNameMapping.resolveTableName({ name: object } as any); + const cfgs = this.autoNumberFields[object] || this.autoNumberFields[tableName]; if (!cfgs || cfgs.length === 0) return; const parentTrx = options?.transaction as Knex.Transaction | undefined; const timezone = (options as any)?.timezone as string | undefined; @@ -1252,6 +1265,86 @@ export class SqlDriver implements IDataDriver { /** * Batch-initialise tables from an array of object definitions. */ + /** + * DDL-free metadata registration for a federated (external) object — the + * read-path counterpart to {@link initObjects} (ADR-0015 federation). + * + * `initObjects` is gated by `assertSchemaMutable` and therefore throws for + * any non-`managed` driver, which left external objects with NO read-coercion + * metadata and the query path resolving to a table named after the object + * instead of its remote table. This populates the same coercion maps (keyed + * by OBJECT name, matching formatInput/formatOutput/coerceFilterValue) and + * records the physical remote table (`external.remoteName`, optionally + * `external.remoteSchema`) so {@link getBuilder} targets it — WITHOUT running + * any DDL (createTable/alterTable/columnInfo). Keep the field-classification + * below in sync with initObjects() if the field-type -> storage mapping changes. + */ + registerExternalObject(schema: { + name: string; + fields?: Record; + tenancy?: any; + external?: { remoteName?: string; remoteSchema?: string }; + }): void { + const key = schema.name; + const remoteName = schema.external?.remoteName || schema.name; + const remoteSchema = schema.external?.remoteSchema; + this.physicalTableByObject[key] = remoteName; + this.objectByPhysicalTable[remoteName] = key; + if (remoteSchema) { + if (this.isSqlite) { + this.logger.warn( + `[sql-driver] external object "${key}" declares remoteSchema="${remoteSchema}" but SQLite has no schema namespace; ignoring (treating "${remoteName}" as a bare table).`, + ); + } else { + this.physicalSchemaByObject[key] = remoteSchema; + } + } + + const jsonCols: string[] = []; + const booleanCols: string[] = []; + const numericCols: string[] = []; + const dateCols: string[] = []; + const datetimeCols: string[] = []; + const autoNumberCols: Array<{ name: string; format: string; tokens: AutonumberToken[]; tenantField: string | null }> = []; + + const tenancyDecl = (schema as any)?.tenancy; + let tenantField: string | null = null; + if (tenancyDecl && tenancyDecl.enabled !== false && tenancyDecl.tenantField) { + const declared = String(tenancyDecl.tenantField); + if (schema.fields && Object.prototype.hasOwnProperty.call(schema.fields, declared)) { + tenantField = declared; + } + } + if (!tenantField) { + const hasOrgField = !!(schema.fields && Object.prototype.hasOwnProperty.call(schema.fields, 'organization_id')); + tenantField = hasOrgField ? 'organization_id' : null; + } + if (schema.fields) { + for (const [name, field] of Object.entries(schema.fields)) { + const type = field.type || 'string'; + if (this.isJsonField(type, field)) jsonCols.push(name); + if (type === 'boolean' || type === 'toggle') booleanCols.push(name); + if (NUMERIC_SCALAR_TYPES.has(type) && !field.multiple) numericCols.push(name); + if (type === 'date') dateCols.push(name); + if (type === 'datetime') datetimeCols.push(name); + if (type === 'auto_number' || type === 'autonumber') { + const rawFmt = (typeof field.autonumberFormat === 'string' && field.autonumberFormat) + ? field.autonumberFormat + : (typeof field.format === 'string' && field.format ? field.format : ''); + const fmt = rawFmt || '{0000}'; + autoNumberCols.push({ name, format: fmt, tokens: parseAutonumberFormat(fmt), tenantField }); + } + } + } + this.jsonFields[key] = jsonCols; + this.booleanFields[key] = booleanCols; + this.numericFields[key] = numericCols; + this.autoNumberFields[key] = autoNumberCols; + this.tenantFieldByTable[key] = tenantField; + if (dateCols.length) this.dateFields[key] = new Set(dateCols); + if (datetimeCols.length) this.datetimeFields[key] = new Set(datetimeCols); + } + async initObjects(objects: Array<{ name: string; fields?: Record }>): Promise { // DDL gate (ADR-0015 §5.1): createTable/alterTable below mutate schema. // Also covers `syncSchema`, which delegates here. @@ -1563,7 +1656,17 @@ export class SqlDriver implements IDataDriver { } protected getBuilder(object: string, options?: DriverOptions) { - let builder = this.knex(object); + // Federation (ADR-0015): an external object resolves to its remote table + // (`external.remoteName`, optionally schema-qualified). Managed objects miss + // both maps, so this is `this.knex(object)` — unchanged. `.withSchema()` is + // applied on the builder (not via `knex.withSchema().from()`) so the builder + // type is identical to the managed path for every downstream caller. + const physical = this.physicalTableByObject[object] ?? object; + let builder = this.knex(physical); + const remoteSchema = this.physicalSchemaByObject[object]; + if (remoteSchema) { + builder = builder.withSchema(remoteSchema); + } if (options?.transaction) { builder = builder.transacting(options.transaction as Knex.Transaction); } @@ -1698,6 +1801,21 @@ export class SqlDriver implements IDataDriver { return null; } + /** + * Coercion-map key for a builder. Coercion maps (date/datetime) are keyed by + * OBJECT name, but after the federation change {@link getBuilder} targets the + * physical remote table, so a builder reports the remote name. Map it back to + * the object name for external objects; identity for managed ones (no reverse + * entry). Note datetime coercion is a SQLite-only concern (see + * coerceFilterValue), and SQLite external tables are bare-named, so this is + * exact where it matters. + */ + protected coercionKey(builder: any): string | null { + const physical = this.tableNameForBuilder(builder); + if (physical == null) return null; + return this.objectByPhysicalTable[physical] ?? physical; + } + /** * Collapse a `Field.date` value to a timezone-naive `YYYY-MM-DD` * calendar-day string (ADR-0053 Phase 1). A `Date` collapses to its UTC @@ -1808,7 +1926,7 @@ export class SqlDriver implements IDataDriver { protected applyFilters(builder: Knex.QueryBuilder, filters: any) { if (!filters) return; - const table = this.tableNameForBuilder(builder); + const table = this.coercionKey(builder); if (!Array.isArray(filters) && typeof filters === 'object') { const hasMongoOperators = Object.keys(filters).some( @@ -1905,7 +2023,7 @@ export class SqlDriver implements IDataDriver { protected applyFilterCondition(builder: Knex.QueryBuilder, condition: any, logicalOp: 'and' | 'or' = 'and', tableHint?: string | null) { if (!condition || typeof condition !== 'object') return; - const table = tableHint ?? this.tableNameForBuilder(builder); + const table = tableHint ?? this.coercionKey(builder); for (const [key, value] of Object.entries(condition)) { if (key === '$and' && Array.isArray(value)) { diff --git a/packages/spec/src/contracts/data-driver.ts b/packages/spec/src/contracts/data-driver.ts index a548260e06..7b1d5c685f 100644 --- a/packages/spec/src/contracts/data-driver.ts +++ b/packages/spec/src/contracts/data-driver.ts @@ -163,6 +163,18 @@ export interface IDataDriver { */ syncSchemasBatch?(schemas: Array<{ object: string; schema: unknown }>, options?: DriverOptions): Promise; + /** + * Register a federated (external) object's read metadata WITHOUT running DDL + * (ADR-0015). For datasources with `schemaMode !== 'managed'`, the schema is + * owned by the remote database, so `syncSchema()`/`initObjects()` (which run + * DDL) are forbidden. Drivers that support federation implement this to record + * the physical remote table (`external.remoteName` / `remoteSchema`) and the + * per-object read-coercion metadata so queries resolve to the remote table. + * Optional: drivers that don't support federation simply omit it (the engine + * skips external objects for them). + */ + registerExternalObject?(schema: unknown): void | Promise; + /** Drop the underlying table or collection (destructive) */ dropTable(object: string, options?: DriverOptions): Promise; From c67687eb99cf1b9bd04f652c9acc36891b406023 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 23:24:47 +0800 Subject: [PATCH 2/2] chore(changeset): ADR-0015 federation read-path fix (patch) Co-Authored-By: Claude Opus 4.8 --- .changeset/adr-0015-federation-read-path.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/adr-0015-federation-read-path.md diff --git a/.changeset/adr-0015-federation-read-path.md b/.changeset/adr-0015-federation-read-path.md new file mode 100644 index 0000000000..bfd02e6e43 --- /dev/null +++ b/.changeset/adr-0015-federation-read-path.md @@ -0,0 +1,16 @@ +--- +"@objectstack/driver-sql": patch +"@objectstack/objectql": patch +"@objectstack/spec": patch +--- + +fix(ADR-0015): honor `external.remoteName` / `external.remoteSchema` on the federation read path. + +The query path previously resolved an external object's physical table from the +object name, ignoring its `external` binding — so a federated object bound to a +differently-named remote table failed with `no such table`, and ADR-0015's own +`wh_order` → `mart.fact_orders` example was unqueryable. The SQL driver now +resolves the remote table (`remoteName`, plus `remoteSchema` via `.withSchema()` +on pg/mysql) and registers external objects' read-coercion metadata without DDL +(`SqlDriver.registerExternalObject`, routed from the engine/plugin schema-sync). +The managed path is unchanged. See ADR-0015 §18.