@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.
- package/package.json +10 -10
- 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.
|
|
24
|
-
"@malloydata/db-duckdb": "0.0.
|
|
25
|
-
"@malloydata/db-postgres": "0.0.
|
|
26
|
-
"@malloydata/db-snowflake": "0.0.
|
|
27
|
-
"@malloydata/db-trino": "0.0.
|
|
28
|
-
"@malloydata/malloy": "0.0.
|
|
29
|
-
"@malloydata/malloy-tag": "0.0.
|
|
30
|
-
"@malloydata/render": "0.0.
|
|
31
|
-
"@malloydata/render-validator": "0.0.
|
|
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.
|
|
45
|
+
"version": "0.0.429"
|
|
46
46
|
}
|
package/src/core/persist.spec.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
expect(
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
expect(
|
|
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(
|
|
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', () => {
|