dlin-cli 0.1.1__tar.gz → 0.1.2a1__tar.gz

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 (106) hide show
  1. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/Cargo.lock +8 -1
  2. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/Cargo.toml +2 -1
  3. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/PKG-INFO +4 -2
  4. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/README.md +3 -1
  5. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/cli.rs +7 -9
  6. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/graph/builder.rs +360 -10
  7. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/graph/impact.rs +2 -1
  8. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/input.rs +4 -2
  9. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/main.rs +34 -15
  10. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/parser/manifest.rs +14 -1
  11. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/parser/yaml_schema.rs +63 -0
  12. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/html.rs +2 -1
  13. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/json.rs +2 -1
  14. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/tests/integration_test.rs +232 -5
  15. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/.github/workflows/check.yml +0 -0
  16. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/.github/workflows/publish-crate.yml +0 -0
  17. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/.github/workflows/publish-pypi.yml +0 -0
  18. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/.github/workflows/release.yml +0 -0
  19. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/.gitignore +0 -0
  20. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/CHANGELOG.md +0 -0
  21. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/LICENSE +0 -0
  22. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/assets/tui-demo.gif +0 -0
  23. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/dist-workspace.toml +0 -0
  24. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/pyproject.toml +0 -0
  25. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/error.rs +0 -0
  26. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/graph/filter.rs +0 -0
  27. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/graph/mod.rs +0 -0
  28. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/graph/snapshots/dlin__graph__filter__tests__collapse_snapshot.snap +0 -0
  29. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/graph/snapshots/dlin__graph__filter__tests__collapse_snapshot_bfs_pseudoendpoint.snap +0 -0
  30. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/graph/snapshots/dlin__graph__filter__tests__collapse_snapshot_endpoints_fan_out.snap +0 -0
  31. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/graph/snapshots/dlin__graph__filter__tests__collapse_snapshot_endpoints_leaf_model.snap +0 -0
  32. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/graph/snapshots/dlin__graph__filter__tests__collapse_snapshot_multiple_focus_models.snap +0 -0
  33. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/graph/snapshots/dlin__graph__filter__tests__collapse_snapshot_no_source_exposure.snap +0 -0
  34. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/graph/snapshots/dlin__graph__filter__tests__collapse_snapshot_preserve_focus.snap +0 -0
  35. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/graph/snapshots/dlin__graph__filter__tests__snapshot_transitive_node_type_filter.snap +0 -0
  36. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/graph/snapshots/dlin__graph__filter__tests__snapshot_transitive_select_filter.snap +0 -0
  37. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/graph/snapshots/dlin__graph__filter__tests__snapshot_transitive_select_with_node_type.snap +0 -0
  38. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/graph/types.rs +0 -0
  39. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/lib.rs +0 -0
  40. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/parser/cache.rs +0 -0
  41. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/parser/columns.rs +0 -0
  42. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/parser/discovery.rs +0 -0
  43. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/parser/jinja.rs +0 -0
  44. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/parser/mod.rs +0 -0
  45. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/parser/project.rs +0 -0
  46. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/parser/sql.rs +0 -0
  47. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/ascii.rs +0 -0
  48. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/dot.rs +0 -0
  49. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/impact.rs +0 -0
  50. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/layout.rs +0 -0
  51. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/list.rs +0 -0
  52. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/mermaid.rs +0 -0
  53. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/mod.rs +0 -0
  54. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/plain.rs +0 -0
  55. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__dot__tests__group_by_node_type.snap +0 -0
  56. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__dot__tests__snapshot_all_edge_types.snap +0 -0
  57. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__dot__tests__snapshot_direction_tb.snap +0 -0
  58. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__dot__tests__snapshot_direction_tb_grouped.snap +0 -0
  59. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__dot__tests__snapshot_group_by_directory.snap +0 -0
  60. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__dot__tests__snapshot_lineage.snap +0 -0
  61. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__dot__tests__snapshot_transitive_edges.snap +0 -0
  62. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__html__tests__snapshot_html_json.snap +0 -0
  63. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__impact__tests__snapshot_impact_json.snap +0 -0
  64. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__impact__tests__snapshot_impact_json_with_sql.snap +0 -0
  65. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__impact__tests__snapshot_impact_text.snap +0 -0
  66. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__impact__tests__snapshot_impact_text_with_sql.snap +0 -0
  67. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__json__tests__snapshot_json_with_sql.snap +0 -0
  68. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__json__tests__snapshot_lineage.snap +0 -0
  69. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__json__tests__snapshot_node_metadata.snap +0 -0
  70. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__list__tests__snapshot_list_json.snap +0 -0
  71. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__list__tests__snapshot_list_plain.snap +0 -0
  72. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__mermaid__tests__group_by_node_type.snap +0 -0
  73. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__mermaid__tests__mixed_direct_and_transitive_edges.snap +0 -0
  74. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__mermaid__tests__show_columns_escapes_quotes.snap +0 -0
  75. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__mermaid__tests__show_columns_lineage.snap +0 -0
  76. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__mermaid__tests__show_columns_single_model.snap +0 -0
  77. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__mermaid__tests__show_columns_with_collapse.snap +0 -0
  78. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__mermaid__tests__show_columns_with_grouping.snap +0 -0
  79. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__mermaid__tests__snapshot_direction_tb.snap +0 -0
  80. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__mermaid__tests__snapshot_direction_tb_grouped.snap +0 -0
  81. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__mermaid__tests__snapshot_group_by_directory.snap +0 -0
  82. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__mermaid__tests__snapshot_lineage.snap +0 -0
  83. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__mermaid__tests__transitive_edge_rendering.snap +0 -0
  84. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__plain__tests__snapshot_plain.snap +0 -0
  85. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__summary__tests__snapshot_summary_json.snap +0 -0
  86. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/snapshots/dlin__render__summary__tests__snapshot_summary_text.snap +0 -0
  87. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/summary.rs +0 -0
  88. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/src/render/svg.rs +0 -0
  89. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/tests/fixtures/simple_project/.dlin_cache/.gitignore +0 -0
  90. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/tests/fixtures/simple_project/dbt_project.yml +0 -0
  91. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/tests/fixtures/simple_project/macros/order_totals.sql +0 -0
  92. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/tests/fixtures/simple_project/models/marts/combined_orders.sql +0 -0
  93. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/tests/fixtures/simple_project/models/marts/customers.sql +0 -0
  94. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/tests/fixtures/simple_project/models/marts/order_summary.sql +0 -0
  95. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/tests/fixtures/simple_project/models/marts/orders.sql +0 -0
  96. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/tests/fixtures/simple_project/models/marts/schema.yml +0 -0
  97. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/tests/fixtures/simple_project/models/staging/schema.yml +0 -0
  98. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/tests/fixtures/simple_project/models/staging/stg_customers.sql +0 -0
  99. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/tests/fixtures/simple_project/models/staging/stg_online_orders.sql +0 -0
  100. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/tests/fixtures/simple_project/models/staging/stg_orders.sql +0 -0
  101. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/tests/fixtures/simple_project/models/staging/stg_payments.sql +0 -0
  102. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/tests/fixtures/simple_project/models/staging/stg_retail_orders.sql +0 -0
  103. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/tests/fixtures/simple_project/seeds/countries.csv +0 -0
  104. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/tests/fixtures/simple_project/target/manifest.json +0 -0
  105. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/tests/fixtures/simple_project/target/run_results.json +0 -0
  106. {dlin_cli-0.1.1 → dlin_cli-0.1.2a1}/tests/fixtures/simple_project/tests/assert_orders_positive_amount.sql +0 -0
@@ -231,7 +231,7 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
231
231
 
232
232
  [[package]]
233
233
  name = "dlin"
234
- version = "0.1.1"
234
+ version = "0.1.2-alpha.1"
235
235
  dependencies = [
236
236
  "anyhow",
237
237
  "clap",
@@ -241,6 +241,7 @@ dependencies = [
241
241
  "insta",
242
242
  "libc",
243
243
  "minijinja",
244
+ "path-slash",
244
245
  "petgraph",
245
246
  "rayon",
246
247
  "regex",
@@ -576,6 +577,12 @@ dependencies = [
576
577
  "windows-link",
577
578
  ]
578
579
 
580
+ [[package]]
581
+ name = "path-slash"
582
+ version = "0.2.1"
583
+ source = "registry+https://github.com/rust-lang/crates.io-index"
584
+ checksum = "1e91099d4268b0e11973f036e885d652fb0b21fedcf69738c627f94db6a44f42"
585
+
579
586
  [[package]]
580
587
  name = "petgraph"
581
588
  version = "0.6.5"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "dlin"
3
- version = "0.1.1"
3
+ version = "0.1.2-alpha.1"
4
4
  edition = "2024"
5
5
  description = "A fast CLI tool for dbt model lineage analysis"
6
6
  license = "MIT"
@@ -29,6 +29,7 @@ indexmap = "2"
29
29
  minijinja = "2"
30
30
  rayon = "1"
31
31
  globset = "0.4"
32
+ path-slash = "0.2.1"
32
33
 
33
34
  [target.'cfg(unix)'.dependencies]
34
35
  libc = "0.2"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dlin-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2a1
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -24,6 +24,7 @@ Project-URL: Repository, https://github.com/eitsupi/dlin
24
24
 
25
25
  [![Crates.io](https://img.shields.io/crates/v/dlin)](https://crates.io/crates/dlin)
26
26
  [![PyPI](https://img.shields.io/pypi/v/dlin-cli)](https://pypi.org/project/dlin-cli/)
27
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/eitsupi/dlin)
27
28
 
28
29
  dbt lineage analysis CLI that parses SQL files directly. No `dbt compile`, no Python, no `manifest.json`.
29
30
 
@@ -291,7 +292,7 @@ dlin graph --node-type model,source # filter by node type
291
292
 
292
293
  dlin aims to work without `dbt compile`. By default it parses SQL files directly, but it can also leverage a pre-compiled `manifest.json` for additional accuracy when one is available.
293
294
 
294
- **SQL parsing (default)**: extracts `ref()` and `source()` from SQL via regex + Jinja template evaluation. No Python or dbt needed.
295
+ **SQL parsing (default)**: extracts `ref()` and `source()` from SQL via regex + Jinja template evaluation. No Python or dbt needed. Generic tests (`not_null`, `unique`, `relationships`, etc.) are inferred from YAML schema declarations.
295
296
 
296
297
  **Manifest mode** (`--source manifest`): reads a pre-compiled `manifest.json` for full accuracy with complex Jinja logic.
297
298
 
@@ -300,6 +301,7 @@ dlin aims to work without `dbt compile`. By default it parses SQL files directly
300
301
  - `var()` resolves from `dbt_project.yml` only (`--vars` CLI overrides not supported)
301
302
  - Runtime context (`target.type`, `env_var()`) is not evaluated
302
303
  - Conditional Jinja branches use default values; non-default paths may be missed
304
+ - Generic test IDs are dlin-specific (e.g. `test.not_null.orders.order_id`) and do not match dbt's naming; use manifest mode when exact test IDs matter
303
305
 
304
306
  When these limitations matter, use `--source manifest`.
305
307
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![Crates.io](https://img.shields.io/crates/v/dlin)](https://crates.io/crates/dlin)
4
4
  [![PyPI](https://img.shields.io/pypi/v/dlin-cli)](https://pypi.org/project/dlin-cli/)
5
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/eitsupi/dlin)
5
6
 
6
7
  dbt lineage analysis CLI that parses SQL files directly. No `dbt compile`, no Python, no `manifest.json`.
7
8
 
@@ -269,7 +270,7 @@ dlin graph --node-type model,source # filter by node type
269
270
 
270
271
  dlin aims to work without `dbt compile`. By default it parses SQL files directly, but it can also leverage a pre-compiled `manifest.json` for additional accuracy when one is available.
271
272
 
272
- **SQL parsing (default)**: extracts `ref()` and `source()` from SQL via regex + Jinja template evaluation. No Python or dbt needed.
273
+ **SQL parsing (default)**: extracts `ref()` and `source()` from SQL via regex + Jinja template evaluation. No Python or dbt needed. Generic tests (`not_null`, `unique`, `relationships`, etc.) are inferred from YAML schema declarations.
273
274
 
274
275
  **Manifest mode** (`--source manifest`): reads a pre-compiled `manifest.json` for full accuracy with complex Jinja logic.
275
276
 
@@ -278,6 +279,7 @@ dlin aims to work without `dbt compile`. By default it parses SQL files directly
278
279
  - `var()` resolves from `dbt_project.yml` only (`--vars` CLI overrides not supported)
279
280
  - Runtime context (`target.type`, `env_var()`) is not evaluated
280
281
  - Conditional Jinja branches use default values; non-default paths may be missed
282
+ - Generic test IDs are dlin-specific (e.g. `test.not_null.orders.order_id`) and do not match dbt's naming; use manifest mode when exact test IDs matter
281
283
 
282
284
  When these limitations matter, use `--source manifest`.
283
285
 
@@ -15,8 +15,8 @@ or HTML.
15
15
  Data sources:
16
16
  sql (default) Parse SQL files via regex + minijinja. No Python or dbt required.
17
17
  Detects ref() and source() calls in SQL, plus exposures from YAML.
18
- YAML-defined generic tests (not_null, unique, etc.) are NOT detected
19
- — use manifest mode for full test coverage.
18
+ Generic tests (not_null, unique, etc.) are inferred from YAML declarations
19
+ with dlin-specific IDs — use manifest mode for exact dependency resolution.
20
20
  manifest Read a pre-compiled manifest.json for full accuracy. Requires
21
21
  `dbt compile` (or `dbt run`/`dbt build`) to have been run first.
22
22
  Use `dlin check-manifest` to verify freshness before querying.
@@ -262,9 +262,8 @@ pub struct GraphArgs {
262
262
  /// Filter output by node type (comma-separated). Default: all types.
263
263
  /// Available types: model, source, seed, snapshot, test, exposure.
264
264
  ///
265
- /// WARNING: In sql mode, only singular tests (SQL files in tests/) are detected.
266
- /// YAML-defined generic tests (not_null, unique, relationships, etc.) are NOT detected.
267
- /// Use --source manifest for full test coverage.
265
+ /// NOTE: In sql mode, generic tests are inferred from YAML declarations
266
+ /// with dlin-specific IDs. Use --source manifest for exact dependency resolution.
268
267
  #[arg(long = "node-type", value_delimiter = ',')]
269
268
  pub node_types: Option<Vec<String>>,
270
269
 
@@ -633,9 +632,8 @@ pub struct ListArgs {
633
632
  /// Filter output by node type (comma-separated). Default: all types.
634
633
  /// Available types: model, source, seed, snapshot, test, exposure.
635
634
  ///
636
- /// WARNING: In sql mode, only singular tests (SQL files in tests/) are detected.
637
- /// YAML-defined generic tests (not_null, unique, relationships, etc.) are NOT detected.
638
- /// Use --source manifest for full test coverage.
635
+ /// NOTE: In sql mode, generic tests are inferred from YAML declarations
636
+ /// with dlin-specific IDs. Use --source manifest for exact dependency resolution.
639
637
  #[arg(long = "node-type", value_delimiter = ',')]
640
638
  pub node_types: Option<Vec<String>>,
641
639
 
@@ -688,7 +686,7 @@ impl OutputFormat {
688
686
 
689
687
  #[derive(Debug, Clone, PartialEq, Eq, clap::ValueEnum)]
690
688
  pub enum SourceType {
691
- /// Parse SQL files directly — no dbt/Python required. Exposures are detected from YAML, but YAML-defined generic tests (not_null, unique, relationships, etc.) are NOT detected — only singular test SQL files are found
689
+ /// Parse SQL files directly — no dbt/Python required. Exposures and generic tests are detected from YAML with dlin-specific IDs; use manifest mode for exact test dependency resolution
692
690
  Sql,
693
691
  /// Use dbt manifest.json — full accuracy, requires prior `dbt compile`
694
692
  Manifest,
@@ -14,7 +14,7 @@ use crate::parser::jinja::JinjaExtraction;
14
14
  use crate::parser::sql::{
15
15
  RefCall, SourceCall, extract_all_with_vars, extract_refs_and_sources_with_vars,
16
16
  };
17
- use crate::parser::yaml_schema::{ExposureDefinition, parse_schema_file};
17
+ use crate::parser::yaml_schema::{ExposureDefinition, SchemaFile, parse_schema_file};
18
18
 
19
19
  /// Read all macro SQL files, filter out unparseable ones, and return a
20
20
  /// pre-built prefix string for prepending to model templates.
@@ -171,16 +171,32 @@ struct YamlModelMeta {
171
171
  columns: Vec<String>,
172
172
  }
173
173
 
174
- /// Parse YAML schema files: create source nodes, collect model metadata and exposures
174
+ /// Result of parsing YAML schema files.
175
+ /// The third element pairs each `SchemaFile` with the relative path of the
176
+ /// YAML file it was parsed from (used to populate `file_path` on test nodes).
177
+ type YamlResult = (
178
+ HashMap<String, YamlModelMeta>,
179
+ Vec<ExposureDefinition>,
180
+ Vec<(SchemaFile, PathBuf)>,
181
+ );
182
+
183
+ /// Parse YAML schema files: create source nodes, collect model metadata, exposures,
184
+ /// and parsed schemas (for generic test extraction).
175
185
  fn process_yaml_files(
176
186
  gb: &mut GraphBuilder,
177
187
  files: &DiscoveredFiles,
178
188
  project_dir: &Path,
179
- ) -> Result<(HashMap<String, YamlModelMeta>, Vec<ExposureDefinition>)> {
189
+ ) -> Result<YamlResult> {
180
190
  let mut model_meta: HashMap<String, YamlModelMeta> = HashMap::new();
181
191
  let mut exposures: Vec<ExposureDefinition> = Vec::new();
192
+ let mut schemas: Vec<(SchemaFile, PathBuf)> = Vec::new();
182
193
 
183
- for yaml_path in &files.yaml_files {
194
+ // Sort YAML paths so that duplicate-test-ID suffixes (_2, _3, …) are
195
+ // deterministic across filesystems/OSes.
196
+ let mut sorted_yaml_files = files.yaml_files.clone();
197
+ sorted_yaml_files.sort();
198
+
199
+ for yaml_path in &sorted_yaml_files {
184
200
  let content = read_file(yaml_path)?;
185
201
  let schema = match parse_schema_file(&content, Some(yaml_path.as_path())) {
186
202
  Ok(s) => s,
@@ -207,10 +223,15 @@ fn process_yaml_files(
207
223
  model_meta.insert(model_def.name.clone(), meta);
208
224
  }
209
225
 
210
- exposures.extend(schema.exposures.into_iter());
226
+ exposures.extend(schema.exposures.iter().cloned());
227
+ let relative_path = yaml_path
228
+ .strip_prefix(project_dir)
229
+ .unwrap_or(yaml_path)
230
+ .to_path_buf();
231
+ schemas.push((schema, relative_path));
211
232
  }
212
233
 
213
- Ok((model_meta, exposures))
234
+ Ok((model_meta, exposures, schemas))
214
235
  }
215
236
 
216
237
  /// Cached extraction result for a model SQL file (refs and sources).
@@ -442,10 +463,19 @@ fn process_sql_edges(
442
463
  (&owned.0, &owned.1)
443
464
  };
444
465
 
466
+ // Use EdgeType::Test when the target node is a test, so all test
467
+ // relationships render with consistent edge labels/styles.
468
+ let is_test = *file_type == "test";
469
+
445
470
  for ref_call in refs {
446
471
  let dep_idx = gb.get_or_create_phantom_ref(&ref_call.name, sql_path);
472
+ let edge_type = if is_test {
473
+ EdgeType::Test
474
+ } else {
475
+ EdgeType::Ref
476
+ };
447
477
  gb.graph
448
- .add_edge(dep_idx, current_idx, EdgeData::direct(EdgeType::Ref));
478
+ .add_edge(dep_idx, current_idx, EdgeData::direct(edge_type));
449
479
  }
450
480
 
451
481
  for source_call in sources {
@@ -454,8 +484,13 @@ fn process_sql_edges(
454
484
  &source_call.table_name,
455
485
  sql_path,
456
486
  );
487
+ let edge_type = if is_test {
488
+ EdgeType::Test
489
+ } else {
490
+ EdgeType::Source
491
+ };
457
492
  gb.graph
458
- .add_edge(source_idx, current_idx, EdgeData::direct(EdgeType::Source));
493
+ .add_edge(source_idx, current_idx, EdgeData::direct(edge_type));
459
494
  }
460
495
  }
461
496
 
@@ -499,6 +534,132 @@ fn process_exposures(gb: &mut GraphBuilder, exposures: &[ExposureDefinition]) {
499
534
  }
500
535
  }
501
536
 
537
+ /// Deduplicate a candidate unique_id by appending `_2`, `_3`, … if it already
538
+ /// exists in the node map. Returns `(unique_id, suffix)` where `suffix` is
539
+ /// `None` when no deduplication was needed, or `Some("_2")` etc. when it was.
540
+ /// Callers can append the suffix to labels so they stay distinct too.
541
+ fn dedup_unique_id(
542
+ candidate: &str,
543
+ node_map: &HashMap<String, NodeIndex>,
544
+ ) -> (String, Option<String>) {
545
+ if !node_map.contains_key(candidate) {
546
+ return (candidate.to_string(), None);
547
+ }
548
+ let mut n = 2u32;
549
+ loop {
550
+ let suffix = format!("_{}", n);
551
+ let suffixed = format!("{}{}", candidate, suffix);
552
+ if !node_map.contains_key(&suffixed) {
553
+ return (suffixed, Some(suffix));
554
+ }
555
+ n += 1;
556
+ }
557
+ }
558
+
559
+ /// Add a generic test node to the graph and connect it to the parent.
560
+ fn add_generic_test_node(
561
+ gb: &mut GraphBuilder,
562
+ parent_idx: NodeIndex,
563
+ unique_id: String,
564
+ label: String,
565
+ file_path: Option<PathBuf>,
566
+ ) {
567
+ let idx = gb.add_node(NodeData {
568
+ unique_id,
569
+ label,
570
+ node_type: NodeType::Test,
571
+ file_path,
572
+ description: None,
573
+ materialization: None,
574
+ tags: vec![],
575
+ columns: vec![],
576
+ exposure: None,
577
+ });
578
+ gb.graph
579
+ .add_edge(parent_idx, idx, EdgeData::direct(EdgeType::Test));
580
+ }
581
+
582
+ /// Create test nodes for YAML-declared generic tests (not_null, unique, etc.)
583
+ /// and connect them to their parent model/source nodes.
584
+ fn process_generic_tests(gb: &mut GraphBuilder, schemas: &[(SchemaFile, PathBuf)]) {
585
+ for (schema, yaml_path) in schemas {
586
+ let file_path = Some(yaml_path.clone());
587
+
588
+ // Model-level generic tests
589
+ for model_def in &schema.models {
590
+ let parent_id = format!("model.{}", model_def.name);
591
+ let parent_idx = match gb.node_map.get(&parent_id) {
592
+ Some(&idx) => idx,
593
+ None => continue,
594
+ };
595
+
596
+ // Model-level tests (not attached to a column)
597
+ for test_def in &model_def.tests {
598
+ let test_name = match test_def.test_name() {
599
+ Some(name) => name,
600
+ None => continue,
601
+ };
602
+ let candidate = format!("test.{}.{}", test_name, model_def.name);
603
+ let (unique_id, suffix) = dedup_unique_id(&candidate, &gb.node_map);
604
+ let mut label = format!("{}_{}", test_name, model_def.name);
605
+ if let Some(s) = suffix {
606
+ label.push_str(&s);
607
+ }
608
+ add_generic_test_node(gb, parent_idx, unique_id, label, file_path.clone());
609
+ }
610
+
611
+ // Column-level tests
612
+ for col in &model_def.columns {
613
+ for test_def in &col.tests {
614
+ let test_name = match test_def.test_name() {
615
+ Some(name) => name,
616
+ None => continue,
617
+ };
618
+ let candidate = format!("test.{}.{}.{}", test_name, model_def.name, col.name);
619
+ let (unique_id, suffix) = dedup_unique_id(&candidate, &gb.node_map);
620
+ let mut label = format!("{}_{}_{}", test_name, model_def.name, col.name);
621
+ if let Some(s) = suffix {
622
+ label.push_str(&s);
623
+ }
624
+ add_generic_test_node(gb, parent_idx, unique_id, label, file_path.clone());
625
+ }
626
+ }
627
+ }
628
+
629
+ // Source-level generic tests (column-level only)
630
+ for source_def in &schema.sources {
631
+ for table in &source_def.tables {
632
+ let parent_id = format!("source.{}.{}", source_def.name, table.name);
633
+ let parent_idx = match gb.node_map.get(&parent_id) {
634
+ Some(&idx) => idx,
635
+ None => continue,
636
+ };
637
+ for col in &table.columns {
638
+ for test_def in &col.tests {
639
+ let test_name = match test_def.test_name() {
640
+ Some(name) => name,
641
+ None => continue,
642
+ };
643
+ let candidate = format!(
644
+ "test.{}.{}.{}.{}",
645
+ test_name, source_def.name, table.name, col.name
646
+ );
647
+ let (unique_id, suffix) = dedup_unique_id(&candidate, &gb.node_map);
648
+ let mut label = format!(
649
+ "{}_{}_{}_{}",
650
+ test_name, source_def.name, table.name, col.name
651
+ );
652
+ if let Some(s) = suffix {
653
+ label.push_str(&s);
654
+ }
655
+ add_generic_test_node(gb, parent_idx, unique_id, label, file_path.clone());
656
+ }
657
+ }
658
+ }
659
+ }
660
+ }
661
+ }
662
+
502
663
  /// Build the lineage graph from discovered files.
503
664
  /// If `cache_dir` is provided, it is used as the cache directory;
504
665
  /// otherwise the cache is stored under `<project_dir>/.dlin_cache/`.
@@ -523,7 +684,7 @@ pub fn build_graph(
523
684
  cache::ExtractionCache::load(project_dir, &macro_prefix, vars, cache_dir)
524
685
  };
525
686
 
526
- let (model_meta, exposures) = process_yaml_files(&mut gb, files, project_dir)?;
687
+ let (model_meta, exposures, schemas) = process_yaml_files(&mut gb, files, project_dir)?;
527
688
  let extraction_cache = process_model_files(
528
689
  &mut gb,
529
690
  files,
@@ -556,6 +717,7 @@ pub fn build_graph(
556
717
  vars,
557
718
  )?;
558
719
  process_exposures(&mut gb, &exposures);
720
+ process_generic_tests(&mut gb, &schemas);
559
721
 
560
722
  disk_cache.save();
561
723
 
@@ -827,8 +989,13 @@ models:
827
989
  let graph = build_graph(&project_dir, &files, None, true, false, &HashMap::new()).unwrap();
828
990
  // model + test = 2 nodes
829
991
  assert_eq!(graph.node_count(), 2);
830
- // ref edge: stg_orders → assert_positive
992
+ // test edge: stg_orders → assert_positive
831
993
  assert_eq!(graph.edge_count(), 1);
994
+
995
+ // Singular SQL tests should use EdgeType::Test
996
+ use petgraph::visit::IntoEdgeReferences;
997
+ let edge = graph.edge_references().next().unwrap();
998
+ assert_eq!(edge.weight().edge_type, EdgeType::Test);
832
999
  }
833
1000
 
834
1001
  #[test]
@@ -1261,4 +1428,187 @@ models:
1261
1428
  assert!(graph.contains_edge(electronics, combined));
1262
1429
  assert!(graph.contains_edge(clothing, combined));
1263
1430
  }
1431
+
1432
+ #[test]
1433
+ fn test_generic_tests_from_yaml() {
1434
+ let tmp = tempfile::tempdir().unwrap();
1435
+ let project_dir = tmp.path().to_path_buf();
1436
+ let models_dir = project_dir.join("models");
1437
+ fs::create_dir_all(&models_dir).unwrap();
1438
+
1439
+ fs::write(project_dir.join("dbt_project.yml"), "name: test_proj\n").unwrap();
1440
+ fs::write(models_dir.join("orders.sql"), "SELECT 1 AS order_id").unwrap();
1441
+
1442
+ // Schema with generic tests on columns
1443
+ fs::write(
1444
+ models_dir.join("schema.yml"),
1445
+ r#"
1446
+ sources:
1447
+ - name: raw
1448
+ tables:
1449
+ - name: events
1450
+ columns:
1451
+ - name: event_id
1452
+ data_tests:
1453
+ - not_null
1454
+ models:
1455
+ - name: orders
1456
+ data_tests:
1457
+ - dbt_utils.expression_is_true:
1458
+ expression: "a = b"
1459
+ - dbt_utils.expression_is_true:
1460
+ expression: "c = d"
1461
+ columns:
1462
+ - name: order_id
1463
+ data_tests:
1464
+ - not_null
1465
+ - unique
1466
+ "#,
1467
+ )
1468
+ .unwrap();
1469
+
1470
+ let files = DiscoveredFiles {
1471
+ model_sql_files: vec![project_dir.join("models/orders.sql")],
1472
+ yaml_files: vec![project_dir.join("models/schema.yml")],
1473
+ ..Default::default()
1474
+ };
1475
+
1476
+ let graph = build_graph(&project_dir, &files, None, true, false, &HashMap::new()).unwrap();
1477
+
1478
+ // 1 model + 1 source + 2 column tests + 1 source test + 2 model-level tests = 7
1479
+ assert_eq!(graph.node_count(), 7);
1480
+
1481
+ let test_nodes: Vec<_> = graph
1482
+ .node_indices()
1483
+ .filter(|&i| graph[i].node_type == NodeType::Test)
1484
+ .collect();
1485
+ assert_eq!(test_nodes.len(), 5);
1486
+
1487
+ // Verify test unique_ids
1488
+ let mut test_ids: Vec<&str> = test_nodes
1489
+ .iter()
1490
+ .map(|&i| graph[i].unique_id.as_str())
1491
+ .collect();
1492
+ test_ids.sort();
1493
+ assert!(test_ids.contains(&"test.not_null.orders.order_id"));
1494
+ assert!(test_ids.contains(&"test.unique.orders.order_id"));
1495
+ assert!(test_ids.contains(&"test.not_null.raw.events.event_id"));
1496
+ // Model-level tests: first gets base ID, second gets _2 suffix
1497
+ assert!(test_ids.contains(&"test.dbt_utils.expression_is_true.orders"));
1498
+ assert!(test_ids.contains(&"test.dbt_utils.expression_is_true.orders_2"));
1499
+
1500
+ // All test edges from model (2 column + 2 model-level = 4)
1501
+ let model_idx = graph
1502
+ .node_indices()
1503
+ .find(|&i| graph[i].unique_id == "model.orders")
1504
+ .unwrap();
1505
+ let source_idx = graph
1506
+ .node_indices()
1507
+ .find(|&i| graph[i].unique_id == "source.raw.events")
1508
+ .unwrap();
1509
+
1510
+ let model_test_edges = graph
1511
+ .edges_directed(model_idx, petgraph::Direction::Outgoing)
1512
+ .filter(|e| e.weight().edge_type == EdgeType::Test)
1513
+ .count();
1514
+ assert_eq!(model_test_edges, 4);
1515
+
1516
+ let source_test_edges = graph
1517
+ .edges_directed(source_idx, petgraph::Direction::Outgoing)
1518
+ .filter(|e| e.weight().edge_type == EdgeType::Test)
1519
+ .count();
1520
+ assert_eq!(source_test_edges, 1);
1521
+
1522
+ // All generic test nodes should have file_path pointing to the YAML file
1523
+ for &ti in &test_nodes {
1524
+ assert_eq!(
1525
+ graph[ti].file_path.as_deref(),
1526
+ Some(std::path::Path::new("models/schema.yml")),
1527
+ "test node '{}' should have file_path",
1528
+ graph[ti].unique_id,
1529
+ );
1530
+ }
1531
+
1532
+ // Deduped test labels must also be distinct (suffix applied to label)
1533
+ let mut test_labels: Vec<&str> = test_nodes
1534
+ .iter()
1535
+ .map(|&i| graph[i].label.as_str())
1536
+ .collect();
1537
+ test_labels.sort();
1538
+ let deduped_len = test_labels.len();
1539
+ test_labels.dedup();
1540
+ assert_eq!(
1541
+ test_labels.len(),
1542
+ deduped_len,
1543
+ "All test labels should be unique"
1544
+ );
1545
+ // Verify the deduped model-level test labels
1546
+ assert!(test_labels.contains(&"dbt_utils.expression_is_true_orders"));
1547
+ assert!(test_labels.contains(&"dbt_utils.expression_is_true_orders_2"));
1548
+ }
1549
+
1550
+ #[test]
1551
+ fn test_generic_test_ids_deterministic_across_yaml_order() {
1552
+ // Duplicate test names across two YAML files should produce the same
1553
+ // suffixed IDs regardless of the order the files are passed in.
1554
+ let tmp = tempfile::tempdir().unwrap();
1555
+ let project_dir = tmp.path().to_path_buf();
1556
+ let models_dir = project_dir.join("models");
1557
+ let sub_dir = models_dir.join("sub");
1558
+ fs::create_dir_all(&sub_dir).unwrap();
1559
+
1560
+ fs::write(models_dir.join("orders.sql"), "SELECT 1 AS order_id").unwrap();
1561
+
1562
+ // Two YAML files that both declare a not_null test on orders.order_id
1563
+ let yaml_a = models_dir.join("a_schema.yml");
1564
+ let yaml_b = sub_dir.join("b_schema.yml");
1565
+ let yaml_content = r#"
1566
+ models:
1567
+ - name: orders
1568
+ columns:
1569
+ - name: order_id
1570
+ data_tests:
1571
+ - not_null
1572
+ "#;
1573
+ fs::write(&yaml_a, yaml_content).unwrap();
1574
+ fs::write(&yaml_b, yaml_content).unwrap();
1575
+
1576
+ // Build with files in forward order
1577
+ let files_fwd = DiscoveredFiles {
1578
+ model_sql_files: vec![project_dir.join("models/orders.sql")],
1579
+ yaml_files: vec![yaml_a.clone(), yaml_b.clone()],
1580
+ ..Default::default()
1581
+ };
1582
+ let graph_fwd =
1583
+ build_graph(&project_dir, &files_fwd, None, true, false, &HashMap::new()).unwrap();
1584
+
1585
+ // Build with files in reverse order
1586
+ let files_rev = DiscoveredFiles {
1587
+ model_sql_files: vec![project_dir.join("models/orders.sql")],
1588
+ yaml_files: vec![yaml_b, yaml_a],
1589
+ ..Default::default()
1590
+ };
1591
+ let graph_rev =
1592
+ build_graph(&project_dir, &files_rev, None, true, false, &HashMap::new()).unwrap();
1593
+
1594
+ // Both should produce the same set of test unique_ids
1595
+ let mut ids_fwd: Vec<String> = graph_fwd
1596
+ .node_indices()
1597
+ .filter(|&i| graph_fwd[i].node_type == NodeType::Test)
1598
+ .map(|i| graph_fwd[i].unique_id.clone())
1599
+ .collect();
1600
+ ids_fwd.sort();
1601
+
1602
+ let mut ids_rev: Vec<String> = graph_rev
1603
+ .node_indices()
1604
+ .filter(|&i| graph_rev[i].node_type == NodeType::Test)
1605
+ .map(|i| graph_rev[i].unique_id.clone())
1606
+ .collect();
1607
+ ids_rev.sort();
1608
+
1609
+ assert_eq!(ids_fwd, ids_rev);
1610
+ assert_eq!(ids_fwd.len(), 2);
1611
+ assert!(ids_fwd.contains(&"test.not_null.orders.order_id".to_string()));
1612
+ assert!(ids_fwd.contains(&"test.not_null.orders.order_id_2".to_string()));
1613
+ }
1264
1614
  }
@@ -1,5 +1,6 @@
1
1
  use std::collections::{HashMap, HashSet, VecDeque};
2
2
 
3
+ use path_slash::PathExt as _;
3
4
  use petgraph::Direction;
4
5
  use petgraph::stable_graph::NodeIndex;
5
6
  use petgraph::visit::EdgeRef;
@@ -132,7 +133,7 @@ pub fn compute_impact(graph: &LineageGraph, source_idx: NodeIndex) -> ImpactRepo
132
133
  file_path: node
133
134
  .file_path
134
135
  .as_ref()
135
- .map(|p| p.to_string_lossy().into_owned()),
136
+ .map(|p| p.to_slash_lossy().into_owned()),
136
137
  severity,
137
138
  distance: next_distance,
138
139
  sql_content: None,
@@ -1,6 +1,8 @@
1
1
  use std::io::{self, BufRead, IsTerminal};
2
2
  use std::path::{Path, PathBuf};
3
3
 
4
+ use path_slash::PathExt as _;
5
+
4
6
  use crate::graph::types::LineageGraph;
5
7
  use crate::parser::project::ResolvedPaths;
6
8
  use crate::parser::yaml_schema;
@@ -232,13 +234,13 @@ fn resolve_sql_to_label(
232
234
  let project_dir = normalize_path(project_dir);
233
235
  let relative = abs_path.strip_prefix(&project_dir).ok()?;
234
236
  // Normalize to forward slashes once (loop-invariant) for Windows compatibility
235
- let rel_str = relative.to_string_lossy().replace('\\', "/");
237
+ let rel_str = relative.to_slash_lossy();
236
238
 
237
239
  graph.node_indices().find_map(|idx| {
238
240
  let node = &graph[idx];
239
241
  match &node.file_path {
240
242
  Some(node_path) => {
241
- let node_str = node_path.to_string_lossy().replace('\\', "/");
243
+ let node_str = node_path.to_slash_lossy();
242
244
  if node_str == rel_str {
243
245
  Some(node.label.clone())
244
246
  } else {