Skip to content

Commit bb3491c

Browse files
committed
test: add comprehensive tests for map step compilation, type constraints, and edge cases
- Introduced new integration tests for flow compilation involving map steps - Added runtime validation tests for step dependencies and slug validation - Included type inference validation tests for map method constraints - Covered edge cases such as empty arrays and various runtime options - Added tests for flow with only map steps and multiple independent chains - Ensured correct parameter ordering and handling of dependencies in compiled SQL - Expanded coverage for root and dependent map compilation scenarios - Included tests for flow with only map steps and chaining behaviors - Improved test suite robustness for map step handling in flow compilation
1 parent b77f743 commit bb3491c

File tree

7 files changed

+1036
-3
lines changed

7 files changed

+1036
-3
lines changed

.changeset/easy-bats-nail.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@pgflow/example-flows': patch
3+
'@pgflow/dsl': patch
4+
---
5+
6+
Add `.map()` method to Flow DSL for defining map-type steps
7+
8+
The new `.map()` method enables defining steps that process arrays element-by-element, complementing the existing SQL Core map infrastructure. Key features:
9+
10+
- **Root maps**: Process flow input arrays directly by omitting the `array` property
11+
- **Dependent maps**: Process another step's array output using `array: 'stepSlug'`
12+
- **Type-safe**: Enforces Json-compatible types with full TypeScript inference
13+
- **Different handler signature**: Receives individual items `(item, context)` instead of full input object
14+
- **Always returns arrays**: Return type is `HandlerReturnType[]`
15+
- **SQL generation**: Correctly adds `step_type => 'map'` parameter to `pgflow.add_step()`
16+
17+
Example usage:
18+
```typescript
19+
// Root map - processes array input
20+
new Flow<string[]>({ slug: 'process' })
21+
.map({ slug: 'uppercase' }, (item) => item.toUpperCase())
22+
23+
// Dependent map - processes another step's output
24+
new Flow<{}>({ slug: 'workflow' })
25+
.array({ slug: 'items' }, () => [1, 2, 3])
26+
.map({ slug: 'double', array: 'items' }, (n) => n * 2)
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { Flow } from '../../src/dsl.js';
3+
import { compileFlow } from '../../src/compile-flow.js';
4+
5+
describe('Map flow integration tests', () => {
6+
describe('complete flow examples', () => {
7+
it('should compile a data processing pipeline with maps', () => {
8+
// Simulating a real-world data processing flow
9+
const flow = new Flow<string[]>({ slug: 'data_processing' })
10+
.map({ slug: 'normalize' }, (item) => item.trim().toLowerCase())
11+
.map({ slug: 'validate', array: 'normalize' }, (item) => {
12+
// Validate each normalized item
13+
return item.length > 0 && item.length < 100;
14+
})
15+
.step({ slug: 'summarize', dependsOn: ['validate'] }, (input) => ({
16+
total: input.validate.length,
17+
valid: input.validate.filter(v => v).length,
18+
invalid: input.validate.filter(v => !v).length
19+
}));
20+
21+
const sql = compileFlow(flow);
22+
23+
expect(sql).toHaveLength(4);
24+
expect(sql[0]).toBe("SELECT pgflow.create_flow('data_processing');");
25+
expect(sql[1]).toBe("SELECT pgflow.add_step('data_processing', 'normalize', step_type => 'map');");
26+
expect(sql[2]).toBe("SELECT pgflow.add_step('data_processing', 'validate', ARRAY['normalize'], step_type => 'map');");
27+
expect(sql[3]).toBe("SELECT pgflow.add_step('data_processing', 'summarize', ARRAY['validate']);");
28+
});
29+
30+
it('should compile an ETL flow with array generation and mapping', () => {
31+
const flow = new Flow<{ sourceIds: string[] }>({ slug: 'etl_flow' })
32+
.array({ slug: 'fetch_data' }, async ({ run }) => {
33+
// Simulating fetching data for each source ID
34+
return run.sourceIds.map(id => ({ id, data: `data_${id}` }));
35+
})
36+
.map({ slug: 'transform', array: 'fetch_data' }, (record) => ({
37+
...record,
38+
transformed: record.data.toUpperCase(),
39+
timestamp: Date.now()
40+
}))
41+
.map({ slug: 'enrich', array: 'transform' }, async (record) => ({
42+
...record,
43+
enriched: true,
44+
metadata: { processedAt: new Date().toISOString() }
45+
}))
46+
.step({ slug: 'load', dependsOn: ['enrich'] }, async (input) => {
47+
// Final loading step
48+
return {
49+
recordsProcessed: input.enrich.length,
50+
success: true
51+
};
52+
});
53+
54+
const sql = compileFlow(flow);
55+
56+
expect(sql).toHaveLength(5);
57+
expect(sql[1]).not.toContain("step_type"); // array step
58+
expect(sql[2]).toContain("step_type => 'map'");
59+
expect(sql[3]).toContain("step_type => 'map'");
60+
expect(sql[4]).not.toContain("step_type"); // regular step
61+
});
62+
63+
it('should handle complex nested array processing', () => {
64+
// Flow that processes nested arrays (e.g., matrix operations)
65+
const flow = new Flow<number[][]>({ slug: 'matrix_flow' })
66+
.map({ slug: 'row_sums' }, (row) => row.reduce((a, b) => a + b, 0))
67+
.step({ slug: 'total_sum', dependsOn: ['row_sums'] }, (input) =>
68+
input.row_sums.reduce((a, b) => a + b, 0)
69+
);
70+
71+
const sql = compileFlow(flow);
72+
73+
expect(sql).toHaveLength(3);
74+
expect(sql[1]).toBe("SELECT pgflow.add_step('matrix_flow', 'row_sums', step_type => 'map');");
75+
expect(sql[2]).toBe("SELECT pgflow.add_step('matrix_flow', 'total_sum', ARRAY['row_sums']);");
76+
});
77+
});
78+
79+
describe('runtime validation', () => {
80+
it('should throw when trying to use non-existent step as array dependency', () => {
81+
const flow = new Flow<{}>({ slug: 'test' })
82+
.step({ slug: 'exists' }, () => [1, 2, 3]);
83+
84+
expect(() => {
85+
// @ts-expect-error - TypeScript should catch this at compile time
86+
flow.map({ slug: 'fail', array: 'doesNotExist' }, (item) => item);
87+
}).toThrow('Step "fail" depends on undefined step "doesNotExist"');
88+
});
89+
90+
it('should throw when step slug already exists', () => {
91+
const flow = new Flow<number[]>({ slug: 'test' })
92+
.map({ slug: 'process' }, (n) => n * 2);
93+
94+
expect(() => {
95+
flow.map({ slug: 'process' }, (n) => n * 3);
96+
}).toThrow('Step "process" already exists in flow "test"');
97+
});
98+
99+
it('should validate slug format', () => {
100+
expect(() => {
101+
new Flow<number[]>({ slug: 'test' })
102+
.map({ slug: 'invalid-slug!' }, (n) => n);
103+
}).toThrow(); // validateSlug should reject invalid characters
104+
});
105+
106+
it('should validate runtime options', () => {
107+
// This should not throw - valid options
108+
const validFlow = new Flow<number[]>({ slug: 'test' })
109+
.map({
110+
slug: 'valid',
111+
maxAttempts: 3,
112+
baseDelay: 1000,
113+
timeout: 30000,
114+
startDelay: 5000
115+
}, (n) => n);
116+
117+
expect(compileFlow(validFlow)).toHaveLength(2);
118+
119+
// Invalid options should be caught by validateRuntimeOptions
120+
expect(() => {
121+
new Flow<number[]>({ slug: 'test' })
122+
.map({
123+
slug: 'invalid',
124+
maxAttempts: 0 // Should be >= 1
125+
}, (n) => n);
126+
}).toThrow();
127+
});
128+
});
129+
130+
describe('type inference validation', () => {
131+
it('should correctly infer types through map chains', () => {
132+
const flow = new Flow<{ items: string[] }>({ slug: 'test' })
133+
.step({ slug: 'extract', dependsOn: [] }, ({ run }) => run.items)
134+
.map({ slug: 'lengths', array: 'extract' }, (item) => item.length)
135+
.map({ slug: 'doubles', array: 'lengths' }, (len) => len * 2)
136+
.step({ slug: 'sum', dependsOn: ['doubles'] }, (input) => {
137+
// Type checking - this should compile without errors
138+
const total: number = input.doubles.reduce((a, b) => a + b, 0);
139+
return total;
140+
});
141+
142+
const sql = compileFlow(flow);
143+
expect(sql).toHaveLength(5);
144+
});
145+
});
146+
147+
describe('edge cases', () => {
148+
it('should handle empty array processing', () => {
149+
const flow = new Flow<Json[]>({ slug: 'empty_test' })
150+
.map({ slug: 'process' }, (item) => ({ processed: item }));
151+
152+
const sql = compileFlow(flow);
153+
expect(sql).toHaveLength(2);
154+
expect(sql[1]).toContain("step_type => 'map'");
155+
});
156+
157+
it('should handle all runtime options combinations', () => {
158+
const flow = new Flow<string[]>({ slug: 'options_test' })
159+
.map({ slug: 'no_options' }, (s) => s)
160+
.map({ slug: 'some_options', array: 'no_options', maxAttempts: 5 }, (s) => s)
161+
.map({
162+
slug: 'all_options',
163+
array: 'some_options',
164+
maxAttempts: 3,
165+
baseDelay: 1000,
166+
timeout: 30000,
167+
startDelay: 5000
168+
}, (s) => s);
169+
170+
const sql = compileFlow(flow);
171+
172+
expect(sql[1]).toBe("SELECT pgflow.add_step('options_test', 'no_options', step_type => 'map');");
173+
expect(sql[2]).toBe("SELECT pgflow.add_step('options_test', 'some_options', ARRAY['no_options'], max_attempts => 5, step_type => 'map');");
174+
expect(sql[3]).toContain("max_attempts => 3");
175+
expect(sql[3]).toContain("base_delay => 1000");
176+
expect(sql[3]).toContain("timeout => 30000");
177+
expect(sql[3]).toContain("start_delay => 5000");
178+
expect(sql[3]).toContain("step_type => 'map'");
179+
});
180+
181+
it('should handle map steps with no further dependencies', () => {
182+
// Map step as a leaf node
183+
const flow = new Flow<number[]>({ slug: 'leaf_map' })
184+
.map({ slug: 'final_map' }, (n) => n * n);
185+
186+
const sql = compileFlow(flow);
187+
expect(sql).toHaveLength(2);
188+
expect(sql[1]).toBe("SELECT pgflow.add_step('leaf_map', 'final_map', step_type => 'map');");
189+
});
190+
191+
it('should handle multiple independent map chains', () => {
192+
const flow = new Flow<{ a: number[]; b: string[] }>({ slug: 'parallel' })
193+
.step({ slug: 'extract_a' }, ({ run }) => run.a)
194+
.step({ slug: 'extract_b' }, ({ run }) => run.b)
195+
.map({ slug: 'process_a', array: 'extract_a' }, (n) => n * 2)
196+
.map({ slug: 'process_b', array: 'extract_b' }, (s) => s.toUpperCase())
197+
.step({ slug: 'combine', dependsOn: ['process_a', 'process_b'] }, (input) => ({
198+
numbers: input.process_a,
199+
strings: input.process_b
200+
}));
201+
202+
const sql = compileFlow(flow);
203+
expect(sql).toHaveLength(6);
204+
expect(sql[3]).toContain("step_type => 'map'");
205+
expect(sql[4]).toContain("step_type => 'map'");
206+
});
207+
});
208+
});

0 commit comments

Comments
 (0)