Repository: datnguye/dbterd (language: python)

Issue:
Optionally enforce relationship types
**Is your feature request related to a problem? Please describe.**
Currently the relationship type is always assumed as `one-to-many`

**Describe the solution you'd like**
Support to detect the relationship type [one-to-many, many-to-one, one-to-one, many-to-many],  based on the meta config of the test e.g.
```
  - name: DIM_ORDER_LINES
      - name: OL_CUSTKEY
        tests:
          - relationship:
              to: ref('DIM_CUSTOMERS')
              field: C_CUSTKEY
              meta:
                relationship_type: one-to-many
```

DBML Rel types:
```
<: one-to-many. E.g: users.id < posts.user_id
>: many-to-one. E.g: posts.user_id > users.id
-: one-to-one. E.g: users.id - user_infos.user_id
<>: many-to-many. E.g: authors.id <> books.id
```

**Describe alternatives you've considered**
Manual edit after output generated

**Additional context**
Enrich the ERD



Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index 73ae4c2..1aeed97 100644
--- a/README.md
+++ b/README.md
@@ -50,7 +50,7 @@ dbterd run -ad samples/local -rt model -rt source
 
 ## Quick DEMO
 
-Check [Quick Demo](https://dbterd.datnguyen.de/latest/nav/guide/generate-dbml.html) out! And, following is the sample result using `dbdocs`:
+Check [Quick Demo](https://dbterd.datnguyen.de/latest/nav/guide/targets/generate-dbml.html) out! And, following is the sample result using `dbdocs`:
 
 ![screencapture-dbdocs-io-datnguye-poc-2023-02-25-10_29_32.png](https://raw.githubusercontent.com/datnguye/dbterd/main/assets/images/screencapture-dbdocs-io-datnguye-poc-2023-02-25-10_29_32.png)
 
diff --git a/dbterd/adapters/algos/meta.py b/dbterd/adapters/algos/meta.py
index eef6122..3765d53 100644
--- a/dbterd/adapters/algos/meta.py
+++ b/dbterd/adapters/algos/meta.py
@@ -30,3 +30,4 @@ class Ref:
     name: str
     table_map: Tuple[str, str]
     column_map: Tuple[str, str]
+    type: str = "n1"
diff --git a/dbterd/constants.py b/dbterd/constants.py
index 5c97001..7b45318 100644
--- a/dbterd/constants.py
+++ b/dbterd/constants.py
@@ -1,2 +1,3 @@
 DEFAULT_ALGO_RULE = "(name:relationship|c_from:column_name|c_to:field)"
 TEST_META_IGNORE_IN_ERD = "ignore_in_erd"
+TEST_META_RELATIONSHIP_TYPE = "relationship_type"
diff --git a/docs/index.md b/docs/index.md
index 23a319e..c496e51 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -53,11 +53,10 @@ dbterd run -ad samples/local -o target -rt model -rt source
 
 ## Quick DEMO
 
-Check [Quick Demo](https://dbterd.datnguyen.de/latest/nav/guide/generate-dbml.html) out! And, following is the sample result using `dbdocs`:
+Check [Quick Demo](https://dbterd.datnguyen.de/latest/nav/guide/targets/generate-dbml.html) out! And, following is the sample result using `dbdocs`:
 
 ![screencapture-dbdocs-io-datnguye-poc-2023-02-25-10_29_32.png](https://raw.githubusercontent.com/datnguye/dbterd/main/assets/images/screencapture-dbdocs-io-datnguye-poc-2023-02-25-10_29_32.png)
 
-
 ## Contributing ✨
 
 If you've ever wanted to contribute to this tool, and a great cause, now is your chance!
diff --git a/docs/nav/guide/cli-references.md b/docs/nav/guide/cli-references.md
index a38348e..f52509e 100644
--- a/docs/nav/guide/cli-references.md
+++ b/docs/nav/guide/cli-references.md
@@ -117,7 +117,7 @@ Rules:
 === "CLI (by wildcard)"
 
     ```bash
-    dbterd run --select "*xyz"
+    dbterd run --select "wildcard:*xyz"
     ```
 
 #### `AND` and `OR` logic
diff --git a/docs/nav/guide/generate-dbml.md b/docs/nav/guide/targets/generate-dbml.md
similarity index 100%
rename from docs/nav/guide/generate-dbml.md
rename to docs/nav/guide/targets/generate-dbml.md
diff --git a/docs/nav/guide/generate-markdown-mermaid-erd.md b/docs/nav/guide/targets/generate-markdown-mermaid-erd.md
similarity index 100%
rename from docs/nav/guide/generate-markdown-mermaid-erd.md
rename to docs/nav/guide/targets/generate-markdown-mermaid-erd.md
diff --git a/docs/nav/guide/generate-plantuml.md b/docs/nav/guide/targets/generate-plantuml.md
similarity index 100%
rename from docs/nav/guide/generate-plantuml.md
rename to docs/nav/guide/targets/generate-plantuml.md
diff --git a/docs/nav/metadata/relationship_type.md b/docs/nav/metadata/relationship_type.md
new file mode 100644
index 0000000..cf7a6a0
--- /dev/null
+++ b/docs/nav/metadata/relationship_type.md
@@ -0,0 +1,33 @@
+## Decide to enforce Relationship Type
+
+Add `relationship_type` attribute into your test's meta:
+
+```yml
+version: 2
+
+models:
+  - name: your_model
+    columns:
+      - name: your_column
+        tests:
+          - relationships_test:
+              to: ref('your_other_model')
+              field: your_other_column
+              meta:
+                relationship_type: many-to-one
+```
+
+Default value: `many-to-one` if the meta config is not specified
+
+List of accepted values:
+
+| Relationship Type | Programatic Symbol |
+|--------|--------|
+| one-to-many | 1n |
+| zero-to-many | 0n |
+| many-to-many | nn |
+| one-to-one | 11 |
+| many-to-one | n1 |
+| Not specified/Invalid value | n1 |
+
+> NOTE: Known as we could configure multiple relationship types but in the best practice we should always have `many-to-one`
diff --git a/mkdocs.yml b/mkdocs.yml
index e8cc185..d8b3f3c 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -14,11 +14,13 @@ nav:
     - index.md
     - User Guide:
       - CLI Reference: nav/guide/cli-references.md
-      - Generate DBML: nav/guide/generate-dbml.md
-      - Generate Mermaid in Markdown: nav/guide/generate-markdown-mermaid-erd.md
-      - Generate PlantUML: nav/guide/generate-plantuml.md
+      - Generate the Targets:
+        - Generate DBML: nav/guide/targets/generate-dbml.md
+        - Generate Mermaid: nav/guide/targets/generate-markdown-mermaid-erd.md
+        - Generate PlantUML: nav/guide/targets/generate-plantuml.md
       - Metadata:
         - Ignore Tests: nav/metadata/ignore_in_erd.md
+        - Relationship Types: nav/metadata/relationship_type.md
     - Development:
       - Contribution Guideline: nav/development/contributing-guide.md
       - Change Log: nav/development/CHANGELOG.md
@@ -49,20 +51,20 @@ theme:
     # Palette toggle for automatic mode
     - media: "(prefers-color-scheme)"
       primary: black
+      scheme: slate
       toggle:
         icon: material/brightness-auto
         name: Switch to light mode
     # Palette toggle for light mode
     - media: "(prefers-color-scheme: light)"
       primary: black
-      scheme: default
       toggle:
         icon: material/brightness-7
         name: Switch to dark mode
     # Palette toggle for dark mode
     - media: "(prefers-color-scheme: dark)"
       primary: black
-      scheme: slate
+      scheme: default
       toggle:
         icon: material/brightness-4
         name: Switch to system preference


Test patch:
diff --git a/dbterd/adapters/algos/test_relationship.py b/dbterd/adapters/algos/test_relationship.py
index c8385ef..d4c8c1c 100644
--- a/dbterd/adapters/algos/test_relationship.py
+++ b/dbterd/adapters/algos/test_relationship.py
@@ -1,7 +1,11 @@
 from dbterd.adapters.algos import base
 from dbterd.adapters.algos.filter import is_selected_table
 from dbterd.adapters.algos.meta import Ref
-from dbterd.constants import DEFAULT_ALGO_RULE, TEST_META_IGNORE_IN_ERD
+from dbterd.constants import (
+    DEFAULT_ALGO_RULE,
+    TEST_META_IGNORE_IN_ERD,
+    TEST_META_RELATIONSHIP_TYPE,
+)
 
 
 def parse(manifest, catalog, **kwargs):
@@ -92,6 +96,9 @@ def get_relationships(manifest, **kwargs):
                     )
                 ).lower(),
             ],
+            type=get_relationship_type(
+                manifest.nodes[x].meta.get(TEST_META_RELATIONSHIP_TYPE, "")
+            ),
         )
         for x in manifest.nodes
         if (
@@ -112,3 +119,27 @@ def get_relationships(manifest, **kwargs):
         return distinct_list
 
     return []
+
+
+def get_relationship_type(meta: str) -> str:
+    """Get short form of the relationship type configured in test meta
+
+    Args:
+        meta (str): meta value
+
+    Returns:
+        str: |
+            Short relationship type. Accepted values: '0n','01','11','nn','n1' and '1n'.
+            And `1n` is default/fallback value
+    """
+    if meta.lower() == "zero-to-many":
+        return "0n"
+    if meta.lower() == "zero-to-one":
+        return "01"
+    if meta.lower() == "one-to-one":
+        return "11"
+    if meta.lower() == "many-to-many":
+        return "nn"
+    if meta.lower() == "one-to-many":
+        return "1n"
+    return "n1"  # "many-to-one"
diff --git a/dbterd/adapters/targets/dbml/dbml_test_relationship.py b/dbterd/adapters/targets/dbml/dbml_test_relationship.py
index 9e75c96..ce856e5 100644
--- a/dbterd/adapters/targets/dbml/dbml_test_relationship.py
+++ b/dbterd/adapters/targets/dbml/dbml_test_relationship.py
@@ -53,5 +53,23 @@ def parse(manifest, catalog, **kwargs):
     for rel in relationships:
         key_from = f'"{rel.table_map[1]}"."{rel.column_map[1]}"'
         key_to = f'"{rel.table_map[0]}"."{rel.column_map[0]}"'
-        dbml += f"Ref: {key_from} > {key_to}\n"
+        dbml += f"Ref: {key_from} {get_rel_symbol(rel.type)} {key_to}\n"
     return dbml
+
+
+def get_rel_symbol(relationship_type: str) -> str:
+    """Get DBML relationship symbol
+
+    Args:
+        relationship_type (str): relationship type
+
+    Returns:
+        str: Relation symbol supported in DBML
+    """
+    if relationship_type in ["01", "11"]:
+        return "-"
+    if relationship_type in ["0n", "1n"]:
+        return "<"
+    if relationship_type in ["nn"]:
+        return "<>"
+    return ">"  # n1
diff --git a/dbterd/adapters/targets/mermaid/mermaid_test_relationship.py b/dbterd/adapters/targets/mermaid/mermaid_test_relationship.py
index 89feb51..aba0691 100644
--- a/dbterd/adapters/targets/mermaid/mermaid_test_relationship.py
+++ b/dbterd/adapters/targets/mermaid/mermaid_test_relationship.py
@@ -43,11 +43,33 @@ def parse(manifest, catalog, **kwargs):
         )
 
     for rel in relationships:
-        key_from = f'"{rel.table_map[0]}"'
-        key_to = f'"{rel.table_map[1]}"'
-        reference_text = rel.column_map[1].replace(" ", "-")
-        if rel.column_map[1] != rel.column_map[0]:
-            reference_text += f"--{ rel.column_map[0].replace(' ','-')}"
-        mermaid += f"  {key_from.upper()} ||--o{{ {key_to.upper()}: {reference_text}\n"
+        key_from = f'"{rel.table_map[1]}"'
+        key_to = f'"{rel.table_map[0]}"'
+        reference_text = rel.column_map[0].replace(" ", "-")
+        if rel.column_map[0] != rel.column_map[1]:
+            reference_text += f"--{ rel.column_map[1].replace(' ','-')}"
+        mermaid += f"  {key_from.upper()} {get_rel_symbol(rel.type)} {key_to.upper()}: {reference_text}\n"
 
     return mermaid
+
+
+def get_rel_symbol(relationship_type: str) -> str:
+    """Get Mermaid relationship symbol
+
+    Args:
+        relationship_type (str): relationship type
+
+    Returns:
+        str: Relation symbol supported in Mermaid
+    """
+    if relationship_type in ["01"]:
+        return "}o--||"
+    if relationship_type in ["11"]:
+        return "||--||"
+    if relationship_type in ["0n"]:
+        return "}o--|{"
+    if relationship_type in ["1n"]:
+        return "||--|{"
+    if relationship_type in ["nn"]:
+        return "}|--|{"
+    return "}|--||"  # n1
diff --git a/dbterd/adapters/targets/plantuml/plantuml_test_relationship.py b/dbterd/adapters/targets/plantuml/plantuml_test_relationship.py
index 8483c1f..4ca30c4 100644
--- a/dbterd/adapters/targets/plantuml/plantuml_test_relationship.py
+++ b/dbterd/adapters/targets/plantuml/plantuml_test_relationship.py
@@ -41,10 +41,32 @@ def parse(manifest, catalog, **kwargs):
         key_from = f'"{rel.table_map[1]}"'
         key_to = f'"{rel.table_map[0]}"'
         # NOTE: plant uml doesn't have columns defined in the connector
-        new_rel = f"  {key_from} }}|..|| {key_to}\n"
+        new_rel = f"  {key_from} {get_rel_symbol(rel.type)} {key_to}\n"
         if new_rel not in plantuml:
             plantuml += new_rel
 
     plantuml += "@enduml"
 
     return plantuml
+
+
+def get_rel_symbol(relationship_type: str) -> str:
+    """Get PlantUML relationship symbol
+
+    Args:
+        relationship_type (str): relationship type
+
+    Returns:
+        str: Relation symbol supported in PlantUML
+    """
+    if relationship_type in ["01"]:
+        return "}o--||"
+    if relationship_type in ["11"]:
+        return "||--||"
+    if relationship_type in ["0n"]:
+        return "}o--|{"
+    if relationship_type in ["1n"]:
+        return "||--|{"
+    if relationship_type in ["nn"]:
+        return "}|--|{"
+    return "}|--||"  # n1
diff --git a/tests/unit/adapters/algos/test_test_relationship.py b/tests/unit/adapters/algos/test_test_relationship.py
index e902329..1965c51 100644
--- a/tests/unit/adapters/algos/test_test_relationship.py
+++ b/tests/unit/adapters/algos/test_test_relationship.py
@@ -62,6 +62,7 @@ class DummyManifestRel:
             "test.dbt_resto.relationships_table3": ["tabley", "tablex"],
             "test.dbt_resto.relationships_tablex": ["y", "x"],
             "test.dbt_resto.foreign_key_table1": ["table2", "table1"],
+            "test.dbt_resto.relationships_table4": ["table-m2", "table-m1"],
         }
     )
     nodes = {
@@ -100,6 +101,13 @@ class DummyManifestRel:
             meta={},
             columns={},
         ),
+        "test.dbt_resto.relationships_table4": ManifestNode(
+            test_metadata=ManifestNodeTestMetaData(
+                kwargs={"column_name": "f1", "field": "f2"}
+            ),
+            meta={"relationship_type": "one-to-one"},
+            columns={},
+        ),
     }
 
 
@@ -269,6 +277,12 @@ def test_get_compiled(self, manifest, expected):
                         table_map=["tabley", "tablex"],
                         column_map=["f2", "f1"],
                     ),
+                    Ref(
+                        name="test.dbt_resto.relationships_table4",
+                        table_map=["table-m2", "table-m1"],
+                        column_map=["f2", "f1"],
+                        type="11",
+                    ),
                 ],
             ),
             (
@@ -287,3 +301,18 @@ def test_get_compiled(self, manifest, expected):
     )
     def test_get_relationships(self, manifest, algorithm, expected):
         assert algo.get_relationships(manifest=manifest, algo=algorithm) == expected
+
+    @pytest.mark.parametrize(
+        "meta, type",
+        [
+            ("zero-to-many", "0n"),
+            ("one-to-many", "1n"),
+            ("zero-to-one", "01"),
+            ("one-to-one", "11"),
+            ("many-to-many", "nn"),
+            ("many-to-one", "n1"),
+            ("--irrelevant--", "n1"),
+        ],
+    )
+    def test_get_relationship_type(self, meta, type):
+        assert algo.get_relationship_type(meta=meta) == type
diff --git a/tests/unit/adapters/targets/dbml/test_dbml_test_relationship.py b/tests/unit/adapters/targets/dbml/test_dbml_test_relationship.py
index 9601de1..a362f3f 100644
--- a/tests/unit/adapters/targets/dbml/test_dbml_test_relationship.py
+++ b/tests/unit/adapters/targets/dbml/test_dbml_test_relationship.py
@@ -238,3 +238,18 @@ def test_parse(
                 ).replace("\n", "")
                 mock_get_tables.assert_called_once()
                 mock_get_relationships.assert_called_once()
+
+    @pytest.mark.parametrize(
+        "relationship_type, symbol",
+        [
+            ("0n", "<"),
+            ("1n", "<"),
+            ("01", "-"),
+            ("11", "-"),
+            ("nn", "<>"),
+            ("n1", ">"),
+            ("--irrelevant--", ">"),
+        ],
+    )
+    def test_get_rel_symbol(self, relationship_type, symbol):
+        assert engine.get_rel_symbol(relationship_type=relationship_type) == symbol
diff --git a/tests/unit/adapters/targets/mermaid/test_mermaid_test_relationship.py b/tests/unit/adapters/targets/mermaid/test_mermaid_test_relationship.py
index 9a442d6..b130d2f 100644
--- a/tests/unit/adapters/targets/mermaid/test_mermaid_test_relationship.py
+++ b/tests/unit/adapters/targets/mermaid/test_mermaid_test_relationship.py
@@ -81,8 +81,8 @@ class TestMermaidTestRelationship:
                   "SOURCE.DBT_RESTO.TABLE3" {
                     name3-type3 name3
                   }
-                  "MODEL.DBT_RESTO.TABLE2" ||--o{ "MODEL.DBT_RESTO.TABLE1": name1--name2
-                  "MODEL.DBT_RESTO.TABLE2" ||--o{ "MODEL.DBT_RESTO.TABLE1": name-notexist1--name-notexist2
+                  "MODEL.DBT_RESTO.TABLE1" }|--|| "MODEL.DBT_RESTO.TABLE2": name2--name1
+                  "MODEL.DBT_RESTO.TABLE1" }|--|| "MODEL.DBT_RESTO.TABLE2": name-notexist2--name-notexist1
                 """,
             ),
             (
@@ -236,3 +236,18 @@ def test_parse(
                 ).replace(" ", "").replace("\n", "")
                 mock_get_tables.assert_called_once()
                 mock_get_relationships.assert_called_once()
+
+    @pytest.mark.parametrize(
+        "relationship_type, symbol",
+        [
+            ("0n", "}o--|{"),
+            ("1n", "||--|{"),
+            ("01", "}o--||"),
+            ("11", "||--||"),
+            ("nn", "}|--|{"),
+            ("n1", "}|--||"),
+            ("--irrelevant--", "}|--||"),
+        ],
+    )
+    def test_get_rel_symbol(self, relationship_type, symbol):
+        assert engine.get_rel_symbol(relationship_type=relationship_type) == symbol
diff --git a/tests/unit/adapters/targets/plantuml/test_plantuml_test_relationship.py b/tests/unit/adapters/targets/plantuml/test_plantuml_test_relationship.py
index 29dfc67..68b3f7d 100644
--- a/tests/unit/adapters/targets/plantuml/test_plantuml_test_relationship.py
+++ b/tests/unit/adapters/targets/plantuml/test_plantuml_test_relationship.py
@@ -82,7 +82,7 @@ class TestPlantUMLTestRelationship:
                     entity "source.dbt_resto.table3" {
                         name3 : name3-type3
                     }
-                    "model.dbt_resto.table1" }|..|| "model.dbt_resto.table2"
+                    "model.dbt_resto.table1" }|--|| "model.dbt_resto.table2"
                 @enduml
                 """,
             ),
@@ -242,3 +242,18 @@ def test_parse(
                 ).replace(" ", "").replace("\n", "")
                 mock_get_tables.assert_called_once()
                 mock_get_relationships.assert_called_once()
+
+    @pytest.mark.parametrize(
+        "relationship_type, symbol",
+        [
+            ("0n", "}o--|{"),
+            ("1n", "||--|{"),
+            ("01", "}o--||"),
+            ("11", "||--||"),
+            ("nn", "}|--|{"),
+            ("n1", "}|--||"),
+            ("--irrelevant--", "}|--||"),
+        ],
+    )
+    def test_get_rel_symbol(self, relationship_type, symbol):
+        assert engine.get_rel_symbol(relationship_type=relationship_type) == symbol