Skip to content

Commit 29d0d0e

Browse files
authored
feat: add template dependency ordering via @depends-on comments (#61)
#### In This PR Templates can now declare explicit dependencies on other templates using `@depends-on` comments: ```sql -- @depends-on: helper_functions.sql, base_types.sql CREATE FUNCTION complex_calc() ... ``` During `apply` and `build`, templates are topologically sorted so dependencies run first. Circular dependencies are detected and reported as warnings (execution continues with best-effort ordering). #### The Approach Originally this branch attempted automatic SQL parsing - extracting `CREATE TABLE/VIEW/FUNCTION` declarations and detecting `FROM`, `JOIN`, `REFERENCES` clauses. After review feedback, this was simplified to explicit `@depends-on` comments. The tradeoffs: - **Explicit > implicit**: Users know exactly what's happening - **No false positives**: SQL parsing can't reliably detect all dependency types (CTEs, dynamic SQL, partial names) - **Simpler implementation**: ~180 lines of focused code vs regex soup - **Opt-in granularity**: Only declare dependencies where order matters #### What Changed - `dependencyParser.ts` - extracts `@depends-on` comments (case-insensitive, deduped) - `dependencyGraph.ts` - builds graph, topological sort, cycle detection - `Orchestrator.ts` - integrates sorting into `apply()` and `build()` - `--no-deps` flag on both commands to disable if needed - Does NOT apply to `watch` (file-at-a-time execution) #### Refs Closes TT-XXX
1 parent 7387771 commit 29d0d0e

File tree

11 files changed

+627
-3
lines changed

11 files changed

+627
-3
lines changed

.changeset/smart-dependencies.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@t1mmen/srtd": minor
3+
---
4+
5+
Add dependency ordering for SQL templates via @depends-on comments
6+
7+
Templates can now declare dependencies on other templates using comments:
8+
9+
```sql
10+
-- @depends-on: users_table.sql, roles.sql
11+
CREATE VIEW active_users AS ...
12+
```
13+
14+
Features:
15+
- Templates sorted so dependencies apply/build first
16+
- Circular dependencies detected and reported
17+
- Case-insensitive filename matching
18+
- Multiple @depends-on comments supported
19+
20+
Use `--no-deps` flag to disable if needed:
21+
```bash
22+
srtd apply --no-deps
23+
srtd build --no-deps
24+
```

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,18 @@ my_experiment.wip.sql → Applies locally, never builds to migration
133133
When it's ready: `srtd promote my_experiment.wip.sql`
134134

135135

136+
## Template Dependencies
137+
138+
Declare dependencies between templates with `@depends-on` comments:
139+
140+
```sql
141+
-- @depends-on: helper_functions.sql
142+
CREATE FUNCTION complex_calc() ...
143+
```
144+
145+
During `apply` and `build`, templates are sorted so dependencies run first. Circular dependencies are detected and reported. Use `--no-deps` to disable.
146+
147+
136148
## Existing Projects
137149

138150
Already have functions in your database? Create templates for them, then:

src/__tests__/apply.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ describe('Apply Command', () => {
8989
expect(mockOrchestrator.apply).toHaveBeenCalledWith({
9090
force: undefined,
9191
silent: true,
92+
respectDependencies: true,
9293
});
9394
expect(spies.exitSpy).toHaveBeenCalledWith(0);
9495
});
@@ -109,6 +110,7 @@ describe('Apply Command', () => {
109110
expect(mockOrchestrator.apply).toHaveBeenCalledWith({
110111
force: true,
111112
silent: true,
113+
respectDependencies: true,
112114
});
113115
});
114116

src/__tests__/build.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ describe('Build Command', () => {
105105
force: undefined,
106106
bundle: undefined,
107107
silent: true,
108+
respectDependencies: true,
108109
});
109110
expect(spies.exitSpy).toHaveBeenCalledWith(0);
110111
});
@@ -126,6 +127,7 @@ describe('Build Command', () => {
126127
force: true,
127128
bundle: undefined,
128129
silent: true,
130+
respectDependencies: true,
129131
});
130132
});
131133

@@ -146,6 +148,7 @@ describe('Build Command', () => {
146148
force: undefined,
147149
bundle: true,
148150
silent: true,
151+
respectDependencies: true,
149152
});
150153
});
151154

src/commands/apply.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ import { getErrorMessage } from '../utils/getErrorMessage.js';
1919
export const applyCommand = new Command('apply')
2020
.description('Apply built migrations to the database')
2121
.option('-f, --force', 'Force apply of all templates, irrespective of changes')
22-
.action(async (options: { force?: boolean }) => {
22+
.option('--no-deps', 'Disable automatic dependency ordering')
23+
.action(async (options: { force?: boolean; deps?: boolean }) => {
2324
let exitCode = 0;
2425

2526
try {
@@ -45,6 +46,7 @@ export const applyCommand = new Command('apply')
4546
const result: ProcessedTemplateResult = await orchestrator.apply({
4647
force: options.force,
4748
silent: true,
49+
respectDependencies: options.deps,
4850
});
4951

5052
// Transform results to unified TemplateResult format

src/commands/build.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ interface BuildOptions {
2121
force?: boolean;
2222
apply?: boolean;
2323
bundle?: boolean;
24+
deps?: boolean;
2425
}
2526

2627
export const buildCommand = new Command('build')
2728
.description('Build migrations from templates')
2829
.option('-f, --force', 'Force building of all templates, irrespective of changes')
2930
.option('-a, --apply', 'Apply the built templates')
3031
.option('-b, --bundle', 'Bundle all templates into a single migration')
32+
.option('--no-deps', 'Disable automatic dependency ordering')
3133
.action(async (options: BuildOptions) => {
3234
let exitCode = 0;
3335

@@ -56,6 +58,7 @@ export const buildCommand = new Command('build')
5658
force: options.force,
5759
bundle: options.bundle,
5860
silent: true,
61+
respectDependencies: options.deps,
5962
});
6063

6164
let result = buildResult;

src/services/Orchestrator.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import EventEmitter from 'node:events';
88
import path from 'node:path';
99
import type { CLIConfig, ProcessedTemplateResult, TemplateStatus } from '../types.js';
10+
import { buildDependencyGraph, detectCycles, topologicalSort } from '../utils/dependencyGraph.js';
1011
import { isWipTemplate } from '../utils/isWipTemplate.js';
1112
import type { ValidationWarning } from '../utils/schemas.js';
1213
import { DatabaseService } from './DatabaseService.js';
@@ -33,13 +34,17 @@ export interface ApplyOptions {
3334
force?: boolean;
3435
templatePaths?: string[];
3536
silent?: boolean;
37+
/** Sort templates by dependencies before processing (default: true) */
38+
respectDependencies?: boolean;
3639
}
3740

3841
export interface BuildOptions {
3942
force?: boolean;
4043
bundle?: boolean;
4144
templatePaths?: string[];
4245
silent?: boolean;
46+
/** Sort templates by dependencies before processing (default: true) */
47+
respectDependencies?: boolean;
4348
}
4449

4550
export interface WatchOptions {
@@ -428,9 +433,14 @@ export class Orchestrator extends EventEmitter implements Disposable {
428433
* Command handler: Apply templates to database
429434
*/
430435
async apply(options: ApplyOptions = {}): Promise<ProcessedTemplateResult> {
431-
const templates = options.templatePaths || (await this.fileSystemService.findTemplates());
436+
let templates = options.templatePaths || (await this.fileSystemService.findTemplates());
432437
const result: ProcessedTemplateResult = { errors: [], applied: [], built: [], skipped: [] };
433438

439+
// Sort templates by dependencies (default: true)
440+
if (options.respectDependencies !== false) {
441+
templates = await this.sortByDependencies(templates);
442+
}
443+
434444
this.log('\\n');
435445
const action = options.force ? 'Force applying' : 'Applying';
436446
this.log(`${action} changed templates to local database...`, 'success');
@@ -474,7 +484,12 @@ export class Orchestrator extends EventEmitter implements Disposable {
474484
* Command handler: Build migration files from templates
475485
*/
476486
async build(options: BuildOptions = {}): Promise<ProcessedTemplateResult> {
477-
const templates = options.templatePaths || (await this.fileSystemService.findTemplates());
487+
let templates = options.templatePaths || (await this.fileSystemService.findTemplates());
488+
489+
// Sort templates by dependencies (default: true)
490+
if (options.respectDependencies !== false) {
491+
templates = await this.sortByDependencies(templates);
492+
}
478493

479494
this.log('\\n');
480495

@@ -821,6 +836,50 @@ export class Orchestrator extends EventEmitter implements Disposable {
821836
return this.stateService.getRecentActivity(limit);
822837
}
823838

839+
/**
840+
* Sort templates by their SQL dependencies
841+
* Reads all templates, builds a dependency graph, and returns topologically sorted paths
842+
*/
843+
private async sortByDependencies(templatePaths: string[]): Promise<string[]> {
844+
if (templatePaths.length <= 1) {
845+
return templatePaths;
846+
}
847+
848+
// Read all templates in parallel to analyze dependencies
849+
const templates = await Promise.all(
850+
templatePaths.map(async templatePath => {
851+
try {
852+
const file = await this.fileSystemService.readTemplate(templatePath);
853+
return { path: templatePath, content: file.content };
854+
} catch (error) {
855+
// Log warning but include with empty content so it still appears in sorted output
856+
const fileName = path.basename(templatePath);
857+
const errorMsg = error instanceof Error ? error.message : String(error);
858+
this.log(`Warning: Could not read ${fileName}: ${errorMsg}`, 'warn');
859+
return { path: templatePath, content: '' };
860+
}
861+
})
862+
);
863+
864+
// Build dependency graph and detect cycles
865+
const graph = buildDependencyGraph(templates);
866+
const cycles = detectCycles(graph);
867+
868+
// Warn about circular dependencies
869+
if (cycles.length > 0) {
870+
this.log('Warning: Circular dependencies detected:', 'warn');
871+
for (const cycle of cycles) {
872+
const firstNode = cycle[0];
873+
if (!firstNode) continue;
874+
const cycleStr = [...cycle, firstNode].map(p => path.basename(p)).join(' → ');
875+
this.log(` ${cycleStr}`, 'warn');
876+
}
877+
}
878+
879+
// Return topologically sorted templates
880+
return topologicalSort(graph);
881+
}
882+
824883
/**
825884
* Logging utility
826885
*/

0 commit comments

Comments
 (0)