trellis-datamodel 0.3.3__py3-none-any.whl → 0.4.0__py3-none-any.whl
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.
- trellis_datamodel/adapters/base.py +5 -1
- trellis_datamodel/adapters/dbt_core.py +79 -8
- trellis_datamodel/config.py +43 -1
- trellis_datamodel/routes/__init__.py +2 -0
- trellis_datamodel/routes/lineage.py +60 -0
- trellis_datamodel/routes/manifest.py +6 -0
- trellis_datamodel/server.py +2 -1
- trellis_datamodel/services/__init__.py +2 -0
- trellis_datamodel/services/lineage.py +427 -0
- trellis_datamodel/static/_app/immutable/assets/0.DRr1NRor.css +1 -0
- trellis_datamodel/static/_app/immutable/chunks/{CXDUumOQ.js → COKQndWa.js} +1 -1
- trellis_datamodel/static/_app/immutable/entry/{app.abCkWeAJ.js → app.BA1wC-Z2.js} +2 -2
- trellis_datamodel/static/_app/immutable/entry/start.Cq8bDFFs.js +1 -0
- trellis_datamodel/static/_app/immutable/nodes/{1.J_r941Qf.js → 1.CITYWtIe.js} +1 -1
- trellis_datamodel/static/_app/immutable/nodes/2.DBgiABuH.js +27 -0
- trellis_datamodel/static/_app/version.json +1 -1
- trellis_datamodel/static/index.html +6 -6
- trellis_datamodel/tests/test_dbt_schema.py +260 -4
- trellis_datamodel/tests/test_yaml_handler.py +228 -0
- trellis_datamodel/utils/yaml_handler.py +131 -20
- {trellis_datamodel-0.3.3.dist-info → trellis_datamodel-0.4.0.dist-info}/METADATA +8 -4
- {trellis_datamodel-0.3.3.dist-info → trellis_datamodel-0.4.0.dist-info}/RECORD +28 -25
- trellis_datamodel/static/_app/immutable/assets/0.ByDwyx3a.css +0 -1
- trellis_datamodel/static/_app/immutable/entry/start.B7CjH6Z7.js +0 -1
- trellis_datamodel/static/_app/immutable/nodes/2.WqbMkq6o.js +0 -27
- /trellis_datamodel/static/_app/immutable/nodes/{0.bFI_DI3G.js → 0.CXLfbIn-.js} +0 -0
- {trellis_datamodel-0.3.3.dist-info → trellis_datamodel-0.4.0.dist-info}/WHEEL +0 -0
- {trellis_datamodel-0.3.3.dist-info → trellis_datamodel-0.4.0.dist-info}/entry_points.txt +0 -0
- {trellis_datamodel-0.3.3.dist-info → trellis_datamodel-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {trellis_datamodel-0.3.3.dist-info → trellis_datamodel-0.4.0.dist-info}/licenses/NOTICE +0 -0
- {trellis_datamodel-0.3.3.dist-info → trellis_datamodel-0.4.0.dist-info}/top_level.txt +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"
|
|
1
|
+
{"version":"1767080961734"}
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
8
8
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
9
9
|
|
|
10
|
-
<link rel="modulepreload" href="/_app/immutable/entry/start.
|
|
11
|
-
<link rel="modulepreload" href="/_app/immutable/chunks/
|
|
10
|
+
<link rel="modulepreload" href="/_app/immutable/entry/start.Cq8bDFFs.js">
|
|
11
|
+
<link rel="modulepreload" href="/_app/immutable/chunks/COKQndWa.js">
|
|
12
12
|
<link rel="modulepreload" href="/_app/immutable/chunks/zXDdy2c_.js">
|
|
13
13
|
<link rel="modulepreload" href="/_app/immutable/chunks/8ZaN1sxc.js">
|
|
14
|
-
<link rel="modulepreload" href="/_app/immutable/entry/app.
|
|
14
|
+
<link rel="modulepreload" href="/_app/immutable/entry/app.BA1wC-Z2.js">
|
|
15
15
|
<link rel="modulepreload" href="/_app/immutable/chunks/QRltG_J6.js">
|
|
16
16
|
<link rel="modulepreload" href="/_app/immutable/chunks/DDNfEvut.js">
|
|
17
17
|
<link rel="modulepreload" href="/_app/immutable/chunks/BfBfOTnK.js">
|
|
@@ -21,15 +21,15 @@
|
|
|
21
21
|
<div style="display: contents">
|
|
22
22
|
<script>
|
|
23
23
|
{
|
|
24
|
-
|
|
24
|
+
__sveltekit_vj8yr8 = {
|
|
25
25
|
base: ""
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
const element = document.currentScript.parentElement;
|
|
29
29
|
|
|
30
30
|
Promise.all([
|
|
31
|
-
import("/_app/immutable/entry/start.
|
|
32
|
-
import("/_app/immutable/entry/app.
|
|
31
|
+
import("/_app/immutable/entry/start.Cq8bDFFs.js"),
|
|
32
|
+
import("/_app/immutable/entry/app.BA1wC-Z2.js")
|
|
33
33
|
]).then(([kit, app]) => {
|
|
34
34
|
kit.start(app, element);
|
|
35
35
|
});
|
|
@@ -244,6 +244,260 @@ class TestSyncDbtTests:
|
|
|
244
244
|
}
|
|
245
245
|
]
|
|
246
246
|
|
|
247
|
+
def test_syncs_many_to_one_relationship(self, test_client, temp_dir, temp_data_model_path):
|
|
248
|
+
"""
|
|
249
|
+
Ensure many_to_one relationships write FK test to source entity (the "many" side).
|
|
250
|
+
"""
|
|
251
|
+
data_model = {
|
|
252
|
+
"version": 0.1,
|
|
253
|
+
"entities": [
|
|
254
|
+
{
|
|
255
|
+
"id": "customer_entity",
|
|
256
|
+
"label": "Customers",
|
|
257
|
+
"dbt_model": "model.project.customers",
|
|
258
|
+
"position": {"x": 0, "y": 0},
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
"id": "order_entity",
|
|
262
|
+
"label": "Orders",
|
|
263
|
+
"dbt_model": "model.project.orders",
|
|
264
|
+
"drafted_fields": [{"name": "customer_id", "datatype": "int"}],
|
|
265
|
+
"position": {"x": 100, "y": 0},
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
"relationships": [
|
|
269
|
+
{
|
|
270
|
+
"source": "order_entity", # Source is "many" side, so FK should be here
|
|
271
|
+
"target": "customer_entity", # Target is "one" side (PK)
|
|
272
|
+
"type": "many_to_one",
|
|
273
|
+
"source_field": "customer_id", # FK field
|
|
274
|
+
"target_field": "id", # PK field
|
|
275
|
+
}
|
|
276
|
+
],
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
# Persist data model
|
|
280
|
+
with open(temp_data_model_path, "w") as f:
|
|
281
|
+
yaml.dump(data_model, f)
|
|
282
|
+
|
|
283
|
+
response = test_client.post("/api/sync-dbt-tests")
|
|
284
|
+
assert response.status_code == 200
|
|
285
|
+
|
|
286
|
+
# orders.yml should contain a relationship test (FK is on source/orders)
|
|
287
|
+
orders_yml = os.path.join(temp_dir, "models", "3_core", "orders.yml")
|
|
288
|
+
assert os.path.exists(orders_yml)
|
|
289
|
+
with open(orders_yml, "r") as f:
|
|
290
|
+
schema = yaml.safe_load(f)
|
|
291
|
+
|
|
292
|
+
rel_tests = schema["models"][0]["columns"][0]["data_tests"]
|
|
293
|
+
assert rel_tests == [
|
|
294
|
+
{
|
|
295
|
+
"relationships": {
|
|
296
|
+
"arguments": {"to": "ref('customers')", "field": "id"},
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
# customers.yml should NOT have a relationship test (PK is on target/customers)
|
|
302
|
+
customers_yml = os.path.join(temp_dir, "models", "3_core", "customers.yml")
|
|
303
|
+
if os.path.exists(customers_yml):
|
|
304
|
+
with open(customers_yml, "r") as f:
|
|
305
|
+
customer_schema = yaml.safe_load(f)
|
|
306
|
+
# If customers.yml exists, it shouldn't have relationship tests for this relationship
|
|
307
|
+
if "models" in customer_schema and len(customer_schema["models"]) > 0:
|
|
308
|
+
if "columns" in customer_schema["models"][0]:
|
|
309
|
+
for col in customer_schema["models"][0]["columns"]:
|
|
310
|
+
if col.get("name") == "id":
|
|
311
|
+
# PK field shouldn't have relationship test pointing back
|
|
312
|
+
tests = col.get("data_tests", [])
|
|
313
|
+
for test in tests:
|
|
314
|
+
if "relationships" in test:
|
|
315
|
+
rel = test["relationships"]
|
|
316
|
+
ref = rel.get("arguments", {}).get("to", "") or rel.get("to", "")
|
|
317
|
+
assert "ref('orders')" not in ref
|
|
318
|
+
|
|
319
|
+
def test_removes_stale_relationship_tests_when_type_changes(
|
|
320
|
+
self, test_client, temp_dir, temp_data_model_path
|
|
321
|
+
):
|
|
322
|
+
"""
|
|
323
|
+
When relationship type changes (e.g., one_to_many -> many_to_one),
|
|
324
|
+
stale relationship tests should be removed from old FK location and
|
|
325
|
+
added to new FK location.
|
|
326
|
+
"""
|
|
327
|
+
# Start with one_to_many relationship (FK on target/orders)
|
|
328
|
+
data_model = {
|
|
329
|
+
"version": 0.1,
|
|
330
|
+
"entities": [
|
|
331
|
+
{
|
|
332
|
+
"id": "customers",
|
|
333
|
+
"label": "Customers",
|
|
334
|
+
"dbt_model": "model.project.customers",
|
|
335
|
+
"position": {"x": 0, "y": 0},
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
"id": "orders",
|
|
339
|
+
"label": "Orders",
|
|
340
|
+
"dbt_model": "model.project.orders",
|
|
341
|
+
"drafted_fields": [{"name": "customer_id", "datatype": "int"}],
|
|
342
|
+
"position": {"x": 100, "y": 0},
|
|
343
|
+
},
|
|
344
|
+
],
|
|
345
|
+
"relationships": [
|
|
346
|
+
{
|
|
347
|
+
"source": "customers",
|
|
348
|
+
"target": "orders",
|
|
349
|
+
"type": "one_to_many", # FK on target (orders)
|
|
350
|
+
"source_field": "id",
|
|
351
|
+
"target_field": "customer_id",
|
|
352
|
+
}
|
|
353
|
+
],
|
|
354
|
+
}
|
|
355
|
+
with open(temp_data_model_path, "w") as f:
|
|
356
|
+
yaml.dump(data_model, f)
|
|
357
|
+
|
|
358
|
+
# First sync: FK should be on orders
|
|
359
|
+
response = test_client.post("/api/sync-dbt-tests")
|
|
360
|
+
assert response.status_code == 200
|
|
361
|
+
|
|
362
|
+
orders_yml = os.path.join(temp_dir, "models", "3_core", "orders.yml")
|
|
363
|
+
assert os.path.exists(orders_yml)
|
|
364
|
+
with open(orders_yml, "r") as f:
|
|
365
|
+
schema = yaml.safe_load(f)
|
|
366
|
+
|
|
367
|
+
# Verify FK test is on orders.customer_id
|
|
368
|
+
rel_tests = schema["models"][0]["columns"][0]["data_tests"]
|
|
369
|
+
assert len(rel_tests) == 1
|
|
370
|
+
assert "relationships" in rel_tests[0]
|
|
371
|
+
assert rel_tests[0]["relationships"]["arguments"]["to"] == "ref('customers')"
|
|
372
|
+
|
|
373
|
+
# Now change relationship type to many_to_one (FK should move to source/customers)
|
|
374
|
+
data_model["relationships"][0]["type"] = "many_to_one"
|
|
375
|
+
# Swap fields: FK now on source (customers), PK on target (orders)
|
|
376
|
+
data_model["relationships"][0]["source_field"] = "customer_id"
|
|
377
|
+
data_model["relationships"][0]["target_field"] = "id"
|
|
378
|
+
# Swap source/target to reflect new direction
|
|
379
|
+
data_model["relationships"][0]["source"] = "orders"
|
|
380
|
+
data_model["relationships"][0]["target"] = "customers"
|
|
381
|
+
|
|
382
|
+
with open(temp_data_model_path, "w") as f:
|
|
383
|
+
yaml.dump(data_model, f)
|
|
384
|
+
|
|
385
|
+
# Second sync: FK should move to orders, old FK on customers should be removed
|
|
386
|
+
response = test_client.post("/api/sync-dbt-tests")
|
|
387
|
+
assert response.status_code == 200
|
|
388
|
+
|
|
389
|
+
# Verify orders.yml still has the test (FK is still on orders, just different semantics)
|
|
390
|
+
with open(orders_yml, "r") as f:
|
|
391
|
+
schema = yaml.safe_load(f)
|
|
392
|
+
|
|
393
|
+
rel_tests = schema["models"][0]["columns"][0]["data_tests"]
|
|
394
|
+
assert len(rel_tests) == 1
|
|
395
|
+
assert "relationships" in rel_tests[0]
|
|
396
|
+
assert rel_tests[0]["relationships"]["arguments"]["to"] == "ref('customers')"
|
|
397
|
+
|
|
398
|
+
# Verify customers.yml does NOT have a relationship test
|
|
399
|
+
# (even if it exists, it shouldn't have a test pointing back to orders)
|
|
400
|
+
customers_yml = os.path.join(temp_dir, "models", "3_core", "customers.yml")
|
|
401
|
+
if os.path.exists(customers_yml):
|
|
402
|
+
with open(customers_yml, "r") as f:
|
|
403
|
+
customer_schema = yaml.safe_load(f)
|
|
404
|
+
if "models" in customer_schema and len(customer_schema["models"]) > 0:
|
|
405
|
+
if "columns" in customer_schema["models"][0]:
|
|
406
|
+
for col in customer_schema["models"][0]["columns"]:
|
|
407
|
+
if col.get("name") == "id":
|
|
408
|
+
tests = col.get("data_tests", [])
|
|
409
|
+
for test in tests:
|
|
410
|
+
if "relationships" in test:
|
|
411
|
+
rel = test["relationships"]
|
|
412
|
+
ref = rel.get("arguments", {}).get("to", "") or rel.get("to", "")
|
|
413
|
+
assert "ref('orders')" not in ref
|
|
414
|
+
|
|
415
|
+
def test_removes_stale_tests_when_swapping_one_to_many_to_many_to_one(
|
|
416
|
+
self, test_client, temp_dir, temp_data_model_path
|
|
417
|
+
):
|
|
418
|
+
"""
|
|
419
|
+
Test the specific bug scenario: swapping one_to_many to many_to_one
|
|
420
|
+
should move FK from target to source and remove stale test from target.
|
|
421
|
+
"""
|
|
422
|
+
# Create initial state: one_to_many with FK on target
|
|
423
|
+
data_model = {
|
|
424
|
+
"version": 0.1,
|
|
425
|
+
"entities": [
|
|
426
|
+
{
|
|
427
|
+
"id": "department",
|
|
428
|
+
"label": "Department",
|
|
429
|
+
"dbt_model": "model.project.department",
|
|
430
|
+
"drafted_fields": [{"name": "department_id", "datatype": "text"}],
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
"id": "cool_stuff",
|
|
434
|
+
"label": "Cool Stuff",
|
|
435
|
+
"dbt_model": "model.project.cool_stuff",
|
|
436
|
+
"drafted_fields": [{"name": "department_id", "datatype": "text"}],
|
|
437
|
+
},
|
|
438
|
+
],
|
|
439
|
+
"relationships": [
|
|
440
|
+
{
|
|
441
|
+
"source": "department",
|
|
442
|
+
"target": "cool_stuff",
|
|
443
|
+
"type": "one_to_many", # FK on target (cool_stuff)
|
|
444
|
+
"source_field": "department_id",
|
|
445
|
+
"target_field": "department_id",
|
|
446
|
+
}
|
|
447
|
+
],
|
|
448
|
+
}
|
|
449
|
+
with open(temp_data_model_path, "w") as f:
|
|
450
|
+
yaml.dump(data_model, f)
|
|
451
|
+
|
|
452
|
+
# Initial sync: FK should be on cool_stuff
|
|
453
|
+
response = test_client.post("/api/sync-dbt-tests")
|
|
454
|
+
assert response.status_code == 200
|
|
455
|
+
|
|
456
|
+
cool_stuff_yml = os.path.join(temp_dir, "models", "3_core", "cool_stuff.yml")
|
|
457
|
+
assert os.path.exists(cool_stuff_yml)
|
|
458
|
+
with open(cool_stuff_yml, "r") as f:
|
|
459
|
+
schema = yaml.safe_load(f)
|
|
460
|
+
|
|
461
|
+
# Verify FK test exists on cool_stuff.department_id
|
|
462
|
+
rel_tests = schema["models"][0]["columns"][0]["data_tests"]
|
|
463
|
+
assert len(rel_tests) == 1
|
|
464
|
+
assert "relationships" in rel_tests[0]
|
|
465
|
+
|
|
466
|
+
# Now swap to many_to_one (FK should move to department)
|
|
467
|
+
data_model["relationships"][0]["type"] = "many_to_one"
|
|
468
|
+
# Note: source/target stay the same, only type changes (as per fixed swap logic)
|
|
469
|
+
with open(temp_data_model_path, "w") as f:
|
|
470
|
+
yaml.dump(data_model, f)
|
|
471
|
+
|
|
472
|
+
# Second sync: FK should move to department, stale test removed from cool_stuff
|
|
473
|
+
response = test_client.post("/api/sync-dbt-tests")
|
|
474
|
+
assert response.status_code == 200
|
|
475
|
+
|
|
476
|
+
# Verify cool_stuff.yml no longer has the relationship test
|
|
477
|
+
with open(cool_stuff_yml, "r") as f:
|
|
478
|
+
schema = yaml.safe_load(f)
|
|
479
|
+
|
|
480
|
+
# cool_stuff.department_id should NOT have relationship test anymore
|
|
481
|
+
if "columns" in schema["models"][0]:
|
|
482
|
+
for col in schema["models"][0]["columns"]:
|
|
483
|
+
if col.get("name") == "department_id":
|
|
484
|
+
tests = col.get("data_tests", [])
|
|
485
|
+
# Should have no relationship tests (or no tests at all)
|
|
486
|
+
for test in tests:
|
|
487
|
+
assert "relationships" not in test
|
|
488
|
+
|
|
489
|
+
# Verify department.yml now has the relationship test
|
|
490
|
+
department_yml = os.path.join(temp_dir, "models", "3_core", "department.yml")
|
|
491
|
+
assert os.path.exists(department_yml)
|
|
492
|
+
with open(department_yml, "r") as f:
|
|
493
|
+
dept_schema = yaml.safe_load(f)
|
|
494
|
+
|
|
495
|
+
# department.department_id should have relationship test pointing to cool_stuff
|
|
496
|
+
rel_tests = dept_schema["models"][0]["columns"][0]["data_tests"]
|
|
497
|
+
assert len(rel_tests) == 1
|
|
498
|
+
assert "relationships" in rel_tests[0]
|
|
499
|
+
assert rel_tests[0]["relationships"]["arguments"]["to"] == "ref('cool_stuff')"
|
|
500
|
+
|
|
247
501
|
|
|
248
502
|
class TestGetModelSchema:
|
|
249
503
|
"""Tests for GET /api/models/{model_name}/schema endpoint."""
|
|
@@ -653,7 +907,11 @@ class TestInferRelationships:
|
|
|
653
907
|
"label": "Customers",
|
|
654
908
|
"additional_models": ["model.project.customers_alt"],
|
|
655
909
|
},
|
|
656
|
-
{
|
|
910
|
+
{
|
|
911
|
+
"id": "orders",
|
|
912
|
+
"label": "Orders",
|
|
913
|
+
"dbt_model": "model.project.orders",
|
|
914
|
+
},
|
|
657
915
|
],
|
|
658
916
|
}
|
|
659
917
|
with open(temp_data_model_path, "w") as f:
|
|
@@ -847,9 +1105,7 @@ class TestModelSchemaVersionHandling:
|
|
|
847
1105
|
self._write_versioned_manifest(temp_dir)
|
|
848
1106
|
self._write_versioned_schema(temp_dir)
|
|
849
1107
|
|
|
850
|
-
response = test_client.get(
|
|
851
|
-
"/api/models/player/schema", params={"version": 2}
|
|
852
|
-
)
|
|
1108
|
+
response = test_client.get("/api/models/player/schema", params={"version": 2})
|
|
853
1109
|
assert response.status_code == 200
|
|
854
1110
|
|
|
855
1111
|
schema = response.json()
|
|
@@ -226,3 +226,231 @@ class TestYamlHandlerRelationshipTests:
|
|
|
226
226
|
assert len(col["data_tests"]) == 2
|
|
227
227
|
rel_test = next(t for t in col["data_tests"] if "relationships" in t)
|
|
228
228
|
assert rel_test["relationships"]["arguments"]["to"] == "ref('new_model')"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class TestYamlIndentation:
|
|
232
|
+
"""Test YAML indentation formatting (regression test for issue #19)."""
|
|
233
|
+
|
|
234
|
+
def test_models_list_indentation(self, temp_dir):
|
|
235
|
+
"""Test that models list items are properly indented under the models key."""
|
|
236
|
+
handler = YamlHandler()
|
|
237
|
+
file_path = os.path.join(temp_dir, "test_indentation.yml")
|
|
238
|
+
|
|
239
|
+
# Create data with a plain Python list (simulates real usage in dbt_core.py)
|
|
240
|
+
data = {"version": 2, "models": [{"name": "test_model", "description": "Test"}]}
|
|
241
|
+
handler.save_file(file_path, data)
|
|
242
|
+
|
|
243
|
+
# Read the raw file text
|
|
244
|
+
with open(file_path, "r") as f:
|
|
245
|
+
content = f.read()
|
|
246
|
+
|
|
247
|
+
# Assert proper indentation: models list items should be indented 2 spaces
|
|
248
|
+
# Expected format:
|
|
249
|
+
# models:
|
|
250
|
+
# - name: test_model
|
|
251
|
+
assert (
|
|
252
|
+
"models:\n - name:" in content
|
|
253
|
+
), f"Expected proper indentation, got:\n{content}"
|
|
254
|
+
|
|
255
|
+
def test_ensure_model_normalizes_plain_list(self, temp_dir):
|
|
256
|
+
"""Test that ensure_model normalizes plain Python lists to CommentedSeq."""
|
|
257
|
+
handler = YamlHandler()
|
|
258
|
+
file_path = os.path.join(temp_dir, "test_normalize.yml")
|
|
259
|
+
|
|
260
|
+
# Start with a plain Python list
|
|
261
|
+
data = {"version": 2, "models": []}
|
|
262
|
+
|
|
263
|
+
# Call ensure_model which should normalize the list
|
|
264
|
+
handler.ensure_model(data, "new_model")
|
|
265
|
+
|
|
266
|
+
# Verify it's now a CommentedSeq
|
|
267
|
+
assert isinstance(data["models"], CommentedSeq)
|
|
268
|
+
|
|
269
|
+
# Save and verify indentation
|
|
270
|
+
handler.save_file(file_path, data)
|
|
271
|
+
|
|
272
|
+
with open(file_path, "r") as f:
|
|
273
|
+
content = f.read()
|
|
274
|
+
|
|
275
|
+
assert (
|
|
276
|
+
"models:\n - name:" in content
|
|
277
|
+
), f"Expected proper indentation after normalization, got:\n{content}"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class TestTagHandling:
|
|
281
|
+
"""Test tag handling improvements (issue #20)."""
|
|
282
|
+
|
|
283
|
+
def test_update_model_tags_skips_empty_tags(self):
|
|
284
|
+
"""Test that empty tags arrays are not written to YAML (Point 1)."""
|
|
285
|
+
handler = YamlHandler()
|
|
286
|
+
model = CommentedMap({"name": "test"})
|
|
287
|
+
|
|
288
|
+
# Update with empty tags - should not create tags key
|
|
289
|
+
handler.update_model_tags(model, [])
|
|
290
|
+
|
|
291
|
+
assert "tags" not in model
|
|
292
|
+
assert "config" not in model
|
|
293
|
+
|
|
294
|
+
def test_update_model_tags_removes_existing_empty_tags(self):
|
|
295
|
+
"""Test that existing tags are removed when updated to empty (Point 1)."""
|
|
296
|
+
handler = YamlHandler()
|
|
297
|
+
model = CommentedMap({"name": "test", "tags": ["old_tag"]})
|
|
298
|
+
|
|
299
|
+
# Update with empty tags - should remove existing tags
|
|
300
|
+
handler.update_model_tags(model, [])
|
|
301
|
+
|
|
302
|
+
assert "tags" not in model
|
|
303
|
+
|
|
304
|
+
def test_update_model_tags_removes_config_tags_when_empty(self):
|
|
305
|
+
"""Test that config.tags are removed when updated to empty (Point 1)."""
|
|
306
|
+
handler = YamlHandler()
|
|
307
|
+
model = CommentedMap(
|
|
308
|
+
{
|
|
309
|
+
"name": "test",
|
|
310
|
+
"config": CommentedMap({"tags": ["old_tag"], "materialized": "table"}),
|
|
311
|
+
}
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Update with empty tags - should remove config.tags but keep config
|
|
315
|
+
handler.update_model_tags(model, [])
|
|
316
|
+
|
|
317
|
+
assert "tags" not in model["config"]
|
|
318
|
+
assert model["config"]["materialized"] == "table"
|
|
319
|
+
|
|
320
|
+
def test_update_version_tags_skips_empty_tags(self):
|
|
321
|
+
"""Test that empty tags arrays are not written for versions (Point 1)."""
|
|
322
|
+
handler = YamlHandler()
|
|
323
|
+
version = CommentedMap({"v": 2})
|
|
324
|
+
|
|
325
|
+
# Update with empty tags - should not create config
|
|
326
|
+
handler.update_version_tags(version, [])
|
|
327
|
+
|
|
328
|
+
assert "config" not in version
|
|
329
|
+
|
|
330
|
+
def test_update_version_tags_removes_existing_empty_tags(self):
|
|
331
|
+
"""Test that existing version tags are removed when updated to empty (Point 1)."""
|
|
332
|
+
handler = YamlHandler()
|
|
333
|
+
version = CommentedMap({"v": 2, "config": CommentedMap({"tags": ["old_tag"]})})
|
|
334
|
+
|
|
335
|
+
# Update with empty tags - should remove config.tags
|
|
336
|
+
handler.update_version_tags(version, [])
|
|
337
|
+
|
|
338
|
+
assert "tags" not in version["config"]
|
|
339
|
+
|
|
340
|
+
def test_config_placement_after_description(self, temp_dir):
|
|
341
|
+
"""Test that config block is placed after description (Point 2)."""
|
|
342
|
+
handler = YamlHandler()
|
|
343
|
+
file_path = os.path.join(temp_dir, "test_config_placement.yml")
|
|
344
|
+
|
|
345
|
+
# Create model with name and description, then add tags
|
|
346
|
+
model = CommentedMap()
|
|
347
|
+
model["name"] = "test_model"
|
|
348
|
+
model["description"] = "A test model"
|
|
349
|
+
|
|
350
|
+
handler.update_model_tags(model, ["core"])
|
|
351
|
+
|
|
352
|
+
# Save and check order
|
|
353
|
+
data = {"version": 2, "models": [model]}
|
|
354
|
+
handler.save_file(file_path, data)
|
|
355
|
+
|
|
356
|
+
with open(file_path, "r") as f:
|
|
357
|
+
content = f.read()
|
|
358
|
+
|
|
359
|
+
# Config should appear after description
|
|
360
|
+
desc_pos = content.find("description:")
|
|
361
|
+
config_pos = content.find("config:")
|
|
362
|
+
|
|
363
|
+
assert desc_pos > 0, "Description should be present"
|
|
364
|
+
assert config_pos > 0, "Config should be present"
|
|
365
|
+
assert config_pos > desc_pos, "Config should appear after description"
|
|
366
|
+
|
|
367
|
+
def test_config_placement_before_columns(self, temp_dir):
|
|
368
|
+
"""Test that config block is placed before columns (Point 2)."""
|
|
369
|
+
handler = YamlHandler()
|
|
370
|
+
file_path = os.path.join(temp_dir, "test_config_before_columns.yml")
|
|
371
|
+
|
|
372
|
+
# Create model with name, description, and columns
|
|
373
|
+
model = CommentedMap()
|
|
374
|
+
model["name"] = "test_model"
|
|
375
|
+
model["description"] = "A test model"
|
|
376
|
+
model["columns"] = CommentedSeq([CommentedMap({"name": "id"})])
|
|
377
|
+
|
|
378
|
+
# Add tags - should insert config before columns
|
|
379
|
+
handler.update_model_tags(model, ["core"])
|
|
380
|
+
|
|
381
|
+
# Save and check order
|
|
382
|
+
data = {"version": 2, "models": [model]}
|
|
383
|
+
handler.save_file(file_path, data)
|
|
384
|
+
|
|
385
|
+
with open(file_path, "r") as f:
|
|
386
|
+
content = f.read()
|
|
387
|
+
|
|
388
|
+
# Config should appear before columns
|
|
389
|
+
config_pos = content.find("config:")
|
|
390
|
+
columns_pos = content.find("columns:")
|
|
391
|
+
|
|
392
|
+
assert config_pos > 0, "Config should be present"
|
|
393
|
+
assert columns_pos > 0, "Columns should be present"
|
|
394
|
+
assert config_pos < columns_pos, "Config should appear before columns"
|
|
395
|
+
|
|
396
|
+
def test_config_placement_after_name_when_no_description(self, temp_dir):
|
|
397
|
+
"""Test that config is placed after name when description is missing (Point 2)."""
|
|
398
|
+
handler = YamlHandler()
|
|
399
|
+
file_path = os.path.join(temp_dir, "test_config_after_name.yml")
|
|
400
|
+
|
|
401
|
+
# Create model with only name
|
|
402
|
+
model = CommentedMap()
|
|
403
|
+
model["name"] = "test_model"
|
|
404
|
+
|
|
405
|
+
handler.update_model_tags(model, ["core"])
|
|
406
|
+
|
|
407
|
+
# Save and check order
|
|
408
|
+
data = {"version": 2, "models": [model]}
|
|
409
|
+
handler.save_file(file_path, data)
|
|
410
|
+
|
|
411
|
+
with open(file_path, "r") as f:
|
|
412
|
+
content = f.read()
|
|
413
|
+
|
|
414
|
+
# Config should appear after name
|
|
415
|
+
name_pos = content.find("name:")
|
|
416
|
+
config_pos = content.find("config:")
|
|
417
|
+
|
|
418
|
+
assert name_pos > 0, "Name should be present"
|
|
419
|
+
assert config_pos > 0, "Config should be present"
|
|
420
|
+
assert config_pos > name_pos, "Config should appear after name"
|
|
421
|
+
|
|
422
|
+
def test_version_config_placement(self, temp_dir):
|
|
423
|
+
"""Test that config block in versions is placed correctly (Point 2)."""
|
|
424
|
+
handler = YamlHandler()
|
|
425
|
+
file_path = os.path.join(temp_dir, "test_version_config.yml")
|
|
426
|
+
|
|
427
|
+
# Create version with description
|
|
428
|
+
version = CommentedMap()
|
|
429
|
+
version["v"] = 2
|
|
430
|
+
version["description"] = "Version 2"
|
|
431
|
+
|
|
432
|
+
handler.update_version_tags(version, ["core"])
|
|
433
|
+
|
|
434
|
+
# Save and check order
|
|
435
|
+
model = CommentedMap()
|
|
436
|
+
model["name"] = "test_model"
|
|
437
|
+
model["versions"] = CommentedSeq([version])
|
|
438
|
+
data = {"version": 2, "models": [model]}
|
|
439
|
+
handler.save_file(file_path, data)
|
|
440
|
+
|
|
441
|
+
with open(file_path, "r") as f:
|
|
442
|
+
content = f.read()
|
|
443
|
+
|
|
444
|
+
# In the version block, config should appear after description
|
|
445
|
+
lines = content.split("\n")
|
|
446
|
+
v_line = next(i for i, line in enumerate(lines) if "v:" in line)
|
|
447
|
+
desc_line = next(
|
|
448
|
+
i for i, line in enumerate(lines) if i > v_line and "description:" in line
|
|
449
|
+
)
|
|
450
|
+
config_line = next(
|
|
451
|
+
i for i, line in enumerate(lines) if i > v_line and "config:" in line
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
assert (
|
|
455
|
+
config_line > desc_line
|
|
456
|
+
), "Config should appear after description in version block"
|