@@ -217,3 +217,199 @@ describe('compileDataset — multi-hop joins (ADR-0071)', () => {
217217 expect ( cube . joins ?. [ 'account__owner' ] ?. name ) . toBe ( 'owner' ) ;
218218 } ) ;
219219} ) ;
220+
221+
222+ /**
223+ * #5115 — a dataset whose JOIN crosses datasources is metadata that can never
224+ * execute: the whole dataset is lowered into ONE statement on the base object's
225+ * datasource (raw SQL routes by object since #5033), so the joined table is
226+ * simply not there. #5033 made that failure loud at QUERY time, in front of
227+ * whoever opened the dashboard; this suite pins the same verdict at COMPILE
228+ * time, while the author (often an AI author) is still holding the metadata.
229+ *
230+ * The gate is deliberately narrow, and these cases pin the boundary as much as
231+ * the rejection: it fires only on a conflict the METADATA PROVES — two explicit,
232+ * different `object.datasource` bindings — because `'default'` is the schema's
233+ * default value rather than a routing decision (mapping rules / the ADR-0057
234+ * lifecycle split / a package's `defaultDatasource` all route objects that
235+ * never say a word about `datasource`). Everything it cannot prove stays
236+ * compilable and is caught by #5033's query-time defence.
237+ */
238+ describe ( 'compileDataset — cross-datasource join gate (#5115)' , ( ) => {
239+ /** opportunity → crm_account → core_user (all to-one). */
240+ const chainResolver = ( obj : string , rel : string ) => {
241+ const graph : Record < string , Record < string , { object : string ; table : string } > > = {
242+ opportunity : { account : { object : 'crm_account' , table : 'crm_account' } } ,
243+ crm_account : { owner : { object : 'core_user' , table : 'core_user' } } ,
244+ } ;
245+ return graph [ obj ] ?. [ rel ] ;
246+ } ;
247+
248+ const datasetWith = ( include : string [ ] , dimensions : Record < string , string > [ ] ) =>
249+ DatasetSchema . parse ( {
250+ name : 'revenue_by_region' ,
251+ label : 'Revenue by region' ,
252+ object : 'opportunity' ,
253+ include,
254+ dimensions : dimensions . map ( ( d ) => ( { ...d , type : 'string' } ) ) ,
255+ measures : [ { name : 'revenue' , label : 'Revenue' , aggregate : 'sum' , field : 'amount' } ] ,
256+ } ) ;
257+
258+ const crossDs = datasetWith ( [ 'account' ] , [ { name : 'region' , field : 'account.region' } ] ) ;
259+
260+ /** Compile `crossDs` with a datasource map; returns the thrown error, if any. */
261+ const compileWith = (
262+ datasources : Record < string , string | undefined > ,
263+ isExternalObject ?: ( o : string ) => boolean ,
264+ ) : Error | undefined => {
265+ try {
266+ compileDataset ( crossDs , chainResolver , {
267+ getObjectDatasource : ( o ) => datasources [ o ] ,
268+ ...( isExternalObject ? { isExternalObject } : { } ) ,
269+ } ) ;
270+ return undefined ;
271+ } catch ( e ) {
272+ return e as Error ;
273+ }
274+ } ;
275+
276+ it ( 'rejects two explicitly-bound objects on different datasources, naming BOTH sides and the fix' , ( ) => {
277+ const err = compileWith ( { opportunity : 'billing_db' , crm_account : 'crm_db' } ) ;
278+
279+ expect ( err ) . toBeDefined ( ) ;
280+ const msg = String ( err ?. message ) ;
281+ // Both objects …
282+ expect ( msg ) . toContain ( 'base object "opportunity"' ) ;
283+ expect ( msg ) . toContain ( 'joined object "crm_account"' ) ;
284+ // … both datasources …
285+ expect ( msg ) . toContain ( 'datasource "billing_db"' ) ;
286+ expect ( msg ) . toContain ( 'datasource "crm_db"' ) ;
287+ // … the offending include path …
288+ expect ( msg ) . toContain ( '"account"' ) ;
289+ // … the rule, and a remedy the author can act on — same wording family as
290+ // the #5033 query-time diagnostic, so the two never read as two bugs.
291+ expect ( msg ) . toMatch ( / J O I N c a n n o t c r o s s d a t a s o u r c e s / ) ;
292+ expect ( msg ) . toMatch ( / b i n d i n g b o t h o b j e c t s t o t h e s a m e d a t a s o u r c e / ) ;
293+ expect ( msg ) . toMatch ( / d r o p p i n g " a c c o u n t " f r o m t h e d a t a s e t ' s ` i n c l u d e ` / ) ;
294+ } ) ;
295+
296+ it ( 'compiles normally when both sides declare the SAME datasource' , ( ) => {
297+ expect ( compileWith ( { opportunity : 'crm_db' , crm_account : 'crm_db' } ) ) . toBeUndefined ( ) ;
298+ // …and the join is still emitted, unchanged.
299+ const { cube } = compileDataset ( crossDs , chainResolver , { getObjectDatasource : ( ) => 'crm_db' } ) ;
300+ expect ( cube . joins ?. account ?. name ) . toBe ( 'crm_account' ) ;
301+ } ) ;
302+
303+ it ( 'compares datasource ids case-insensitively (casing is not two databases)' , ( ) => {
304+ expect ( compileWith ( { opportunity : 'CRM_DB' , crm_account : 'crm_db' } ) ) . toBeUndefined ( ) ;
305+ } ) ;
306+
307+ // ── "cannot answer, do not block" — the tiering, pinned case by case ───────
308+
309+ it ( 'a host with NO datasource probe compiles exactly as before' , ( ) => {
310+ // The headline tiering case: an embedding with no data engine passes no
311+ // options at all, and every dataset it registers must still compile.
312+ expect ( ( ) => compileDataset ( crossDs , chainResolver ) ) . not . toThrow ( ) ;
313+ expect ( ( ) => compileDataset ( crossDs , chainResolver , { } ) ) . not . toThrow ( ) ;
314+ const { cube } = compileDataset ( crossDs , chainResolver , { } ) ;
315+ expect ( cube . joins ?. account ?. name ) . toBe ( 'crm_account' ) ;
316+ } ) ;
317+
318+ it ( 'does not block when the probe cannot place the JOIN TARGET' , ( ) => {
319+ expect ( compileWith ( { opportunity : 'billing_db' , crm_account : undefined } ) ) . toBeUndefined ( ) ;
320+ } ) ;
321+
322+ it ( 'does not block when the probe cannot place the BASE object' , ( ) => {
323+ // "Cannot answer for the base" must not reject every join — the base is the
324+ // side every comparison is made against.
325+ expect ( compileWith ( { opportunity : undefined , crm_account : 'crm_db' } ) ) . toBeUndefined ( ) ;
326+ } ) ;
327+
328+ it ( 'treats the DEFAULT binding as unanswered on either side' , ( ) => {
329+ // `datasource: 'default'` is the schema's default VALUE, not a routing
330+ // decision: `ObjectQL.getDriver` only short-circuits on a name OTHER than
331+ // 'default', then falls through to datasourceMapping rules, the ADR-0057
332+ // lifecycle split and the package's defaultDatasource. An object that says
333+ // 'default' may well be routed elsewhere — and may land on exactly the
334+ // datasource the other side declares, which is why rejecting here would
335+ // blank a working dashboard.
336+ expect ( compileWith ( { opportunity : 'default' , crm_account : 'crm_db' } ) ) . toBeUndefined ( ) ;
337+ expect ( compileWith ( { opportunity : 'billing_db' , crm_account : 'default' } ) ) . toBeUndefined ( ) ;
338+ expect ( compileWith ( { opportunity : 'default' , crm_account : 'DEFAULT' } ) ) . toBeUndefined ( ) ;
339+ } ) ;
340+
341+ it ( 'exempts a FEDERATED join target (ADR-0062 D6 — served by the FK-expand path)' , ( ) => {
342+ // NativeSQLStrategy already declines a cube whose base or joined object is
343+ // external, so such a query runs on the ObjectQL FK-expand path (two reads
344+ // joined in memory), which crosses datasources by construction. Rejecting
345+ // it here would break a path that works today.
346+ expect (
347+ compileWith ( { opportunity : 'billing_db' , crm_account : 'sf_prod' } , ( o ) => o === 'crm_account' ) ,
348+ ) . toBeUndefined ( ) ;
349+ } ) ;
350+
351+ it ( 'exempts a FEDERATED base object' , ( ) => {
352+ expect (
353+ compileWith ( { opportunity : 'sf_prod' , crm_account : 'crm_db' } , ( o ) => o === 'opportunity' ) ,
354+ ) . toBeUndefined ( ) ;
355+ } ) ;
356+
357+ // ── which hop gets named ──────────────────────────────────────────────────
358+
359+ it ( 'names the ONE offending hop when a multi-hop path crosses on its second hop' , ( ) => {
360+ const twoHop = datasetWith (
361+ [ 'account' , 'account.owner' ] ,
362+ [ { name : 'owner_region' , field : 'account.owner.region' } ] ,
363+ ) ;
364+ const datasources : Record < string , string > = {
365+ opportunity : 'billing_db' ,
366+ crm_account : 'billing_db' , // first hop stays home …
367+ core_user : 'identity_db' , // … the second one leaves
368+ } ;
369+ let thrown : Error | undefined ;
370+ try {
371+ compileDataset ( twoHop , chainResolver , { getObjectDatasource : ( o ) => datasources [ o ] } ) ;
372+ } catch ( e ) {
373+ thrown = e as Error ;
374+ }
375+ const msg = String ( thrown ?. message ) ;
376+ expect ( msg ) . toContain ( 'joined object "core_user"' ) ;
377+ expect ( msg ) . toContain ( 'datasource "identity_db"' ) ;
378+ expect ( msg ) . toContain ( 'path "account.owner"' ) ;
379+ // The innocent intermediate hop is not blamed.
380+ expect ( msg ) . not . toContain ( 'joined object "crm_account"' ) ;
381+ } ) ;
382+
383+ it ( 'rejects only the crossing target when several joins are declared' , ( ) => {
384+ const multi = DatasetSchema . parse ( {
385+ name : 'multi' ,
386+ label : 'Multi' ,
387+ object : 'opportunity' ,
388+ include : [ 'owner' , 'account' ] ,
389+ dimensions : [
390+ { name : 'owner_name' , field : 'owner.name' , type : 'string' } ,
391+ { name : 'region' , field : 'account.region' , type : 'string' } ,
392+ ] ,
393+ measures : [ { name : 'cnt' , label : 'Count' , aggregate : 'count' } ] ,
394+ } ) ;
395+ const flatResolver = ( obj : string , rel : string ) => {
396+ if ( obj !== 'opportunity' ) return undefined ;
397+ if ( rel === 'owner' ) return { object : 'core_user' , table : 'core_user' } ;
398+ if ( rel === 'account' ) return { object : 'crm_account' , table : 'crm_account' } ;
399+ return undefined ;
400+ } ;
401+ const datasources : Record < string , string > = {
402+ opportunity : 'billing_db' ,
403+ core_user : 'billing_db' , // same datasource — fine
404+ crm_account : 'crm_db' , // the offender
405+ } ;
406+ let thrown : Error | undefined ;
407+ try {
408+ compileDataset ( multi , flatResolver , { getObjectDatasource : ( o ) => datasources [ o ] } ) ;
409+ } catch ( e ) {
410+ thrown = e as Error ;
411+ }
412+ expect ( String ( thrown ?. message ) ) . toContain ( 'joined object "crm_account"' ) ;
413+ expect ( String ( thrown ?. message ) ) . not . toContain ( 'core_user' ) ;
414+ } ) ;
415+ } ) ;
0 commit comments