@malloydata/malloy-tests 0.0.428 → 0.0.429

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +10 -10
  2. package/src/core/persist.spec.ts +277 -12
package/package.json CHANGED
@@ -20,15 +20,15 @@
20
20
  "malloyc": "ts-node ../scripts/malloy-to-json"
21
21
  },
22
22
  "dependencies": {
23
- "@malloydata/db-bigquery": "0.0.428",
24
- "@malloydata/db-duckdb": "0.0.428",
25
- "@malloydata/db-postgres": "0.0.428",
26
- "@malloydata/db-snowflake": "0.0.428",
27
- "@malloydata/db-trino": "0.0.428",
28
- "@malloydata/malloy": "0.0.428",
29
- "@malloydata/malloy-tag": "0.0.428",
30
- "@malloydata/render": "0.0.428",
31
- "@malloydata/render-validator": "0.0.428",
23
+ "@malloydata/db-bigquery": "0.0.429",
24
+ "@malloydata/db-duckdb": "0.0.429",
25
+ "@malloydata/db-postgres": "0.0.429",
26
+ "@malloydata/db-snowflake": "0.0.429",
27
+ "@malloydata/db-trino": "0.0.429",
28
+ "@malloydata/malloy": "0.0.429",
29
+ "@malloydata/malloy-tag": "0.0.429",
30
+ "@malloydata/render": "0.0.429",
31
+ "@malloydata/render-validator": "0.0.429",
32
32
  "events": "^3.3.0",
33
33
  "jsdom": "^22.1.0",
34
34
  "luxon": "^3.7.2",
@@ -42,5 +42,5 @@
42
42
  "overrides": {
43
43
  "typescript": "^6.0.3"
44
44
  },
45
- "version": "0.0.428"
45
+ "version": "0.0.429"
46
46
  }
@@ -5,7 +5,12 @@
5
5
 
6
6
  import {runtimeFor, testFileSpace, DuckDBROTestConnection} from '../runtimes';
7
7
  import {wrapTestModel} from '@malloydata/malloy/test';
8
- import type {BuildNode, BuildManifest, BuildPlan} from '@malloydata/malloy';
8
+ import type {
9
+ BuildNode,
10
+ BuildManifest,
11
+ BuildPlan,
12
+ BuildTarget,
13
+ } from '@malloydata/malloy';
9
14
  import {
10
15
  ConnectionRuntime,
11
16
  SingleConnectionRuntime,
@@ -494,11 +499,18 @@ describe('source persistence', () => {
494
499
  const nodeD = graph.nodes[0][0];
495
500
  expect(nodeD.sourceID).toMatch(/source_d/);
496
501
 
497
- const allDeps = getAllSourceIDs(nodeD.dependsOn);
498
- expect(allDeps).toHaveLength(3);
499
- expect(hasDependency(nodeD.dependsOn, 'source_a')).toBe(true);
500
- expect(hasDependency(nodeD.dependsOn, 'source_b')).toBe(true);
501
- expect(hasDependency(nodeD.dependsOn, 'source_c')).toBe(true);
502
+ // Both readers of source_a record the edge to it. A source reached a
503
+ // second time returns what it returned the first time, so the second
504
+ // reader is not left claiming it depends on nothing.
505
+ const [depB, depC] = nodeD.dependsOn;
506
+ expect(nodeD.dependsOn).toHaveLength(2);
507
+ expect(depB.sourceID).toMatch(/source_b/);
508
+ expect(depC.sourceID).toMatch(/source_c/);
509
+ expect(depB.dependsOn[0].sourceID).toMatch(/source_a/);
510
+ expect(depC.dependsOn[0].sourceID).toMatch(/source_a/);
511
+ // ...and it is the same node object both times, not a copy.
512
+ expect(depB.dependsOn[0]).toBe(depC.dependsOn[0]);
513
+ expect(getAllSourceIDs(nodeD.dependsOn)).toHaveLength(4);
502
514
  });
503
515
 
504
516
  it('diamond dependency pattern with query_source chains', async () => {
@@ -530,11 +542,18 @@ describe('source persistence', () => {
530
542
  const nodeD = graph.nodes[0][0];
531
543
  expect(nodeD.sourceID).toMatch(/source_d/);
532
544
 
533
- const allDeps = getAllSourceIDs(nodeD.dependsOn);
534
- expect(allDeps).toHaveLength(3);
535
- expect(hasDependency(nodeD.dependsOn, 'source_a')).toBe(true);
536
- expect(hasDependency(nodeD.dependsOn, 'source_b')).toBe(true);
537
- expect(hasDependency(nodeD.dependsOn, 'source_c')).toBe(true);
545
+ // Both readers of source_a record the edge to it. A source reached a
546
+ // second time returns what it returned the first time, so the second
547
+ // reader is not left claiming it depends on nothing.
548
+ const [depB, depC] = nodeD.dependsOn;
549
+ expect(nodeD.dependsOn).toHaveLength(2);
550
+ expect(depB.sourceID).toMatch(/source_b/);
551
+ expect(depC.sourceID).toMatch(/source_c/);
552
+ expect(depB.dependsOn[0].sourceID).toMatch(/source_a/);
553
+ expect(depC.dependsOn[0].sourceID).toMatch(/source_a/);
554
+ // ...and it is the same node object both times, not a copy.
555
+ expect(depB.dependsOn[0]).toBe(depC.dependsOn[0]);
556
+ expect(getAllSourceIDs(nodeD.dependsOn)).toHaveLength(4);
538
557
  });
539
558
  });
540
559
 
@@ -606,8 +625,20 @@ describe('source persistence', () => {
606
625
  const nodeC = plan.graphs[0].nodes[0][0];
607
626
  expect(nodeC.sourceID).toMatch(/source_c/);
608
627
 
628
+ // source_c reaches source_a two ways: through source_b, and directly,
629
+ // because source_c's SQL inlines source_b's, which inlines source_a's.
630
+ // Both edges are true, and the direct one is redundant for ordering —
631
+ // source_b already sits between them.
632
+ const directDeps = nodeC.dependsOn.map(d => d.sourceID);
633
+ expect(directDeps.filter(id => id.includes('source_b'))).toHaveLength(
634
+ 1
635
+ );
636
+ expect(directDeps.filter(id => id.includes('source_a'))).toHaveLength(
637
+ 1
638
+ );
639
+
609
640
  const allDeps = getAllSourceIDs(nodeC.dependsOn);
610
- expect(allDeps).toHaveLength(2);
641
+ expect(allDeps).toHaveLength(3);
611
642
  expect(hasDependency(nodeC.dependsOn, 'source_a')).toBe(true);
612
643
  expect(hasDependency(nodeC.dependsOn, 'source_b')).toBe(true);
613
644
  });
@@ -1415,6 +1446,240 @@ describe('source persistence', () => {
1415
1446
  expect(source_a.sourceID).toContain('source_a');
1416
1447
  expect(source_a.dependsOn).toHaveLength(0);
1417
1448
  });
1449
+
1450
+ it('an inline extend at the query site still routes to the table', async () => {
1451
+ // `run: persisted extend { ... } -> { ... }` is the ordinary way to use a
1452
+ // persisted source, and the inline extend is not a named source. It finds
1453
+ // its dependency through `extends` rather than through an id of its own,
1454
+ // and it substitutes because substitution keys on `persistent` plus the
1455
+ // SQL hash — an extend changes neither.
1456
+ testFileSpace.setFile(
1457
+ new URL('test://model1.malloy'),
1458
+ `${PERSIST_ANNOTATION}
1459
+ ${FLIGHTS_SOURCE}
1460
+
1461
+ #@ persist
1462
+ source: complex_thing is flights -> {
1463
+ group_by: carrier
1464
+ aggregate: n is count()
1465
+ }
1466
+
1467
+ run: complex_thing extend {
1468
+ dimension: loud is upper(carrier)
1469
+ } -> {
1470
+ group_by: loud
1471
+ aggregate: total is n.sum()
1472
+ }
1473
+ `
1474
+ );
1475
+
1476
+ const model = await tstRuntime
1477
+ .loadModel(new URL('test://model1.malloy'))
1478
+ .getModel();
1479
+
1480
+ // The query's dependency on the persisted source is found.
1481
+ const {connections} = await tstRuntime.getBuildTargets(model);
1482
+ expect(connections).toHaveLength(1);
1483
+ expect(connections[0].targets).toHaveLength(1);
1484
+ const target = connections[0].targets[0];
1485
+ expect(target.sources.map(s => s.name)).toEqual(['complex_thing']);
1486
+
1487
+ // And a query through the inline extend reads the built table.
1488
+ const manifest: BuildManifest = {
1489
+ entries: {[target.buildId]: {tableName: 'cached.complex_thing'}},
1490
+ };
1491
+ const sql = await runtimeWithManifest(manifest)
1492
+ .loadQueryByIndex(new URL('test://model1.malloy'), 0)
1493
+ .getSQL();
1494
+ expect(sql).toContain('cached.complex_thing');
1495
+ expect(sql).not.toContain('COUNT(');
1496
+ });
1497
+
1498
+ it('a named extend is the same table, and routes to it', async () => {
1499
+ // The named counterpart of the test above. `moreComplex` inherits the
1500
+ // whole annotation — `name=` included — and does not change the
1501
+ // materialization SQL, so it is a second name for one table rather than
1502
+ // a second table.
1503
+ testFileSpace.setFile(
1504
+ new URL('test://model1.malloy'),
1505
+ `${PERSIST_ANNOTATION}
1506
+ ${FLIGHTS_SOURCE}
1507
+
1508
+ #@ persist name=complex_thing
1509
+ source: complex_base is flights -> {
1510
+ group_by: carrier
1511
+ aggregate: n is count()
1512
+ }
1513
+
1514
+ source: more_complex is complex_base extend {
1515
+ dimension: loud is upper(carrier)
1516
+ }
1517
+
1518
+ run: more_complex -> { group_by: loud; aggregate: total is n.sum() }
1519
+ `
1520
+ );
1521
+
1522
+ const model = await tstRuntime
1523
+ .loadModel(new URL('test://model1.malloy'))
1524
+ .getModel();
1525
+ const {connections} = await tstRuntime.getBuildTargets(model);
1526
+
1527
+ expect(connections[0].targets).toHaveLength(1);
1528
+ const target = connections[0].targets[0];
1529
+ expect(target.sources.map(s => s.name).sort()).toEqual([
1530
+ 'complex_base',
1531
+ 'more_complex',
1532
+ ]);
1533
+ // Both inherited the same requested name, so there is nothing to resolve.
1534
+ const asked = target.sources.map(s =>
1535
+ s.annotations.parseAsTag('@').tag.text('name')
1536
+ );
1537
+ expect(new Set(asked)).toEqual(new Set(['complex_thing']));
1538
+
1539
+ const manifest: BuildManifest = {
1540
+ entries: {[target.buildId]: {tableName: 'cached.complex_thing'}},
1541
+ };
1542
+ const sql = await runtimeWithManifest(manifest)
1543
+ .loadQueryByIndex(new URL('test://model1.malloy'), 0)
1544
+ .getSQL();
1545
+ expect(sql).toContain('cached.complex_thing');
1546
+ expect(sql).not.toContain('COUNT(');
1547
+ });
1548
+
1549
+ it('a diamond orders both readers after the source they share', async () => {
1550
+ // Two readers of one source, both read by a fourth. Every reader has to
1551
+ // record the shared dependency: one that records no edge can be started
1552
+ // before the table it reads exists.
1553
+ testFileSpace.setFile(
1554
+ new URL('test://model1.malloy'),
1555
+ `${PERSIST_ANNOTATION}
1556
+ ${FLIGHTS_SOURCE}
1557
+
1558
+ #@ persist
1559
+ source: shared is flights -> {
1560
+ group_by: carrier
1561
+ aggregate: flight_count is count()
1562
+ }
1563
+
1564
+ #@ persist
1565
+ source: lo_side is shared extend { dimension: side is 'lo' } -> { select: * }
1566
+
1567
+ #@ persist
1568
+ source: hi_side is shared extend { dimension: side is 'hi' } -> { select: * }
1569
+
1570
+ #@ persist
1571
+ source: both is ${tstDB}.sql("""
1572
+ SELECT * FROM %{ lo_side } UNION ALL SELECT * FROM %{ hi_side }
1573
+ """)
1574
+ `
1575
+ );
1576
+
1577
+ const model = await tstRuntime
1578
+ .loadModel(new URL('test://model1.malloy'))
1579
+ .getModel();
1580
+ const {connections} = await tstRuntime.getBuildTargets(model);
1581
+
1582
+ expect(connections).toHaveLength(1);
1583
+ const targets = connections[0].targets;
1584
+ const nameOf = (t: BuildTarget) =>
1585
+ t.sources
1586
+ .map(s => s.name)
1587
+ .sort()
1588
+ .join('+');
1589
+
1590
+ // Dependencies before dependents, whichever order the walk found them.
1591
+ const at = (n: string) => targets.findIndex(t => nameOf(t) === n);
1592
+ expect(at('shared')).toBeLessThan(at('lo_side'));
1593
+ expect(at('shared')).toBeLessThan(at('hi_side'));
1594
+ expect(at('lo_side')).toBeLessThan(at('both'));
1595
+ expect(at('hi_side')).toBeLessThan(at('both'));
1596
+
1597
+ // Both readers record the shared dependency — not just the first one.
1598
+ const shared = targets[at('shared')];
1599
+ expect(targets[at('lo_side')].dependsOn).toEqual([shared]);
1600
+ expect(targets[at('hi_side')].dependsOn).toEqual([shared]);
1601
+ expect(targets[at('both')].dependsOn.map(nameOf).sort()).toEqual([
1602
+ 'hi_side',
1603
+ 'lo_side',
1604
+ ]);
1605
+ });
1606
+
1607
+ it('reaches dependencies through a non-persistent wrapper', async () => {
1608
+ // The importing model has to be able to take the same route the walk
1609
+ // took. `joined` is correctly not a table — it adds a join and nothing
1610
+ // else, so it materializes identically to source_a — but it is the only
1611
+ // way to reach either dependency. Copy only the persistent sources and
1612
+ // the route is severed: the plan comes back holding `report` alone, and
1613
+ // two sources the user marked `#@ persist` are never built.
1614
+ testFileSpace.setFile(
1615
+ new URL('test://model1.malloy'),
1616
+ `${PERSIST_ANNOTATION}
1617
+ ${FLIGHTS_SOURCE}
1618
+
1619
+ #@ persist
1620
+ source: source_a is flights -> {
1621
+ group_by: carrier
1622
+ aggregate: flight_count is count()
1623
+ }
1624
+
1625
+ #@ persist
1626
+ source: source_b is flights -> {
1627
+ group_by: carrier
1628
+ aggregate: dep_count is count()
1629
+ }
1630
+
1631
+ #@ -persist
1632
+ source: joined is source_a extend {
1633
+ join_one: source_b on carrier = source_b.carrier
1634
+ }
1635
+
1636
+ #@ persist
1637
+ source: report is joined -> {
1638
+ group_by: carrier
1639
+ group_by: n is flight_count
1640
+ group_by: d is source_b.dep_count
1641
+ }
1642
+ `
1643
+ );
1644
+ testFileSpace.setFile(
1645
+ new URL('test://model2.malloy'),
1646
+ `${PERSIST_ANNOTATION}
1647
+ import "test://model1.malloy"
1648
+ run: report -> { select: * }
1649
+ `
1650
+ );
1651
+
1652
+ const direct = await tstRuntime
1653
+ .loadModel(new URL('test://model1.malloy'))
1654
+ .getModel();
1655
+ const imported = await tstRuntime
1656
+ .loadModel(new URL('test://model2.malloy'))
1657
+ .getModel();
1658
+
1659
+ const namesIn = (plan: BuildPlan) =>
1660
+ Object.keys(plan.sources)
1661
+ .map(id => id.split('@')[0])
1662
+ .sort();
1663
+
1664
+ // Importing the report must not change what has to be built.
1665
+ expect(namesIn(imported.getBuildPlan())).toEqual([
1666
+ 'report',
1667
+ 'source_a',
1668
+ 'source_b',
1669
+ ]);
1670
+ expect(namesIn(imported.getBuildPlan())).toEqual(
1671
+ namesIn(direct.getBuildPlan())
1672
+ );
1673
+
1674
+ // The wrapper is a route, not a table, so it is not in the plan at all —
1675
+ // `report` depends on the two tables it reaches through it.
1676
+ const node = imported.getBuildPlan().graphs[0].nodes[0][0];
1677
+ expect(node.sourceID).toContain('report');
1678
+ expect(node.dependsOn.map(d => d.sourceID.split('@')[0]).sort()).toEqual([
1679
+ 'source_a',
1680
+ 'source_b',
1681
+ ]);
1682
+ });
1418
1683
  });
1419
1684
 
1420
1685
  describe('cross-model manifest substitution', () => {