Repository: datnguye/dbterd (language: python)

Issue:
Add PlantUML as new output
**Is your feature request related to a problem? Please describe.**
Support PlanUML ER as output

**Describe the solution you'd like**
- Add new output as PlanUML ER [here](https://plantuml.com/ie-diagram)
- Add docs for new output

**Describe alternatives you've considered**
N/A

**Additional context**
N/A



Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index cbddede..21f400f 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,5 @@
 # dbterd
+
 CLI to generate Diagram-as-a-code file ([DBML](https://dbdiagram.io/d), [Mermaid](https://mermaid-js.github.io/mermaid-live-editor/)) from dbt artifact files (required: `manifest.json`, `catalog.json`)
 
 [![PyPI version](https://badge.fury.io/py/dbterd.svg)](https://pypi.org/project/dbterd/)
@@ -7,52 +8,54 @@ CLI to generate Diagram-as-a-code file ([DBML](https://dbdiagram.io/d), [Mermaid
 [![python](https://img.shields.io/badge/Python-3.9|3.10|3.11-3776AB.svg?style=flat&logo=python&logoColor=white)](https://www.python.org)
 [![codecov](https://codecov.io/gh/datnguye/dbterd/branch/main/graph/badge.svg?token=N7DMQBLH4P)](https://codecov.io/gh/datnguye/dbterd)
 
-```
+```bash
 pip install dbterd --upgrade
 ```
 
 Verify installed version:
-```
+
+```bash
 dbterd --version
 ```
 
 ## Quick examine with existing samples
+
 ```bash
 # select all models in dbt_resto
-dbterd run -ad samples/dbtresto -o target
+dbterd run -ad samples/dbtresto
 # select all models in dbt_resto, Select multiple dbt resources
-dbterd run -ad samples/dbtresto -o target -rt model -rt source
+dbterd run -ad samples/dbtresto -rt model -rt source
 # select only models in dbt_resto excluding staging
-dbterd run -ad samples/dbtresto -o target -s model.dbt_resto -ns model.dbt_resto.staging
+dbterd run -ad samples/dbtresto -s model.dbt_resto -ns model.dbt_resto.staging
 # select only models in schema name mart excluding staging
-dbterd run -ad samples/dbtresto -o target -s schema:mart -ns model.dbt_resto.staging
+dbterd run -ad samples/dbtresto -s schema:mart -ns model.dbt_resto.staging
 # select only models in schema full name dbt.mart excluding staging
-dbterd run -ad samples/dbtresto -o target -s schema:dbt.mart -ns model.dbt_resto.staging
+dbterd run -ad samples/dbtresto -s schema:dbt.mart -ns model.dbt_resto.staging
 
 # other samples
-dbterd run -ad samples/fivetranlog -o target
-dbterd run -ad samples/fivetranlog -o target -rt model -rt source
+dbterd run -ad samples/fivetranlog
+dbterd run -ad samples/fivetranlog -rt model -rt source
 
-dbterd run -ad samples/facebookad -o target
-dbterd run -ad samples/facebookad -o target -rt model -rt source
+dbterd run -ad samples/facebookad
+dbterd run -ad samples/facebookad -rt model -rt source
 
-dbterd run -ad samples/shopify -o target
-dbterd run -ad samples/shopify -o target -rt model -rt source
+dbterd run -ad samples/shopify -s wildcard:*shopify.shopify__*
+dbterd run -ad samples/shopify -rt model -rt source
 
-dbterd run -ad samples/dbt-constraints \
-  -a "test_relationship:(name:foreign_key|c_from:fk_column_name|c_to:pk_column_name)"
+dbterd run -ad samples/dbt-constraints -a "test_relationship:(name:foreign_key|c_from:fk_column_name|c_to:pk_column_name)"
 
 # your own sample without commiting to repo
-dbterd run -ad samples/local -o target -rt model -rt source
+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`:
 
 ![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!
 
 See the contributing docs [CONTRIBUTING](https://dbterd.datnguyen.de/latest/nav/development/contributing-guide.html) for more information.
diff --git a/SECURITY.md b/SECURITY.md
index 67c7427..7b4ca13 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -9,7 +9,7 @@ currently being supported with security updates.
 | ------- | ------------------ |
 | 1.1.x   | :white_check_mark: |
 | 1.0.x   | :white_check_mark: |
-| < 1.0   | :white_check_mark: |
+| < 1.0   | :x: |
 
 ## Reporting a Vulnerability
 
diff --git a/dbterd/adapters/targets/constants.py b/dbterd/adapters/targets/constants.py
index cbf3b43..ff618f9 100644
--- a/dbterd/adapters/targets/constants.py
+++ b/dbterd/adapters/targets/constants.py
@@ -1,3 +1,4 @@
 class Strategies:
     DBML_TEST_RELATIONSHIP = "dbml_test_relationship"
     MERMAID_TEST_RELATIONSHIP = "mermaid_test_relationship"
+    PLANTUML_TEST_RELATIONSHIP = "plantuml_test_relationship"
diff --git a/dbterd/adapters/targets/plantuml/__init__.py b/dbterd/adapters/targets/plantuml/__init__.py
new file mode 100644
index 0000000..f7c9c25
--- /dev/null
+++ b/dbterd/adapters/targets/plantuml/__init__.py
@@ -0,0 +1,8 @@
+from dbterd.adapters.targets.constants import Strategies
+from dbterd.adapters.targets.default import default
+from dbterd.adapters.targets.plantuml import plantuml_test_relationship
+
+run_operation_default = default
+run_operation_dispatcher = {
+    Strategies.PLANTUML_TEST_RELATIONSHIP: plantuml_test_relationship.run,
+}
diff --git a/docs/nav/guide/cli-references.md b/docs/nav/guide/cli-references.md
index 9439ad7..a38348e 100644
--- a/docs/nav/guide/cli-references.md
+++ b/docs/nav/guide/cli-references.md
@@ -19,8 +19,8 @@ run    Run the convert<br />
     </span>
 </div>
 
-
 ## run
+
 Command to generate diagram-as-a-code file
 
 **Examples:**
@@ -121,6 +121,7 @@ Rules:
     ```
 
 #### `AND` and `OR` logic
+
 - `AND` logic is applied to a single selection splitted by comma (,)
 - `OR` logic is applied to 2+ selection
 
@@ -138,13 +139,11 @@ Rules:
     dbterd run -s schema:abc -s wildcard:*xyz.*
     ```
 
-
 ### --exclude (-ns)
 
 Exclusion criteria. Rules are the same as Selection Criteria.
 > Do not exclude any dbt models if not specified, supports mulitple options
 
-
 **Examples:**
 === "CLI"
 
@@ -166,7 +165,7 @@ Supports:
 
 - [`dbml`](https://www.dbml.org/home/)
 - [`mermaid`](https://mermaid.js.org/syntax/entityRelationshipDiagram.html)
-- [`planuml`](https://plantuml.com/ie-diagram) (comming soon)
+- [`plantuml`](https://plantuml.com/ie-diagram)
 
 **Examples:**
 === "CLI"
@@ -186,18 +185,16 @@ Specified algorithm in the way to detect diagram connectors
 > Default to `test_relationship`
 
 In the advanced use case, the test name can be configurable by following syntax:
-```
-{algorithm_name}:(name:{contains_test_name}|c_from:{referencing_column_name}|c_to:{referenced_column_name})
-```
+
+`{algorithm_name}:(name:{contains_test_name}|c_from:{referencing_column_name}|c_to:{referenced_column_name})`
 
 In the above:
 
-  - `algorithm_name`: `test_relationship` (only supported value now)
-  - `contains_test_name`: Configure the test name (detected with `contains` logic). Default to `relationship`
-  - `c_from`: Configure the test metadata attribute (1) for the foreign key column name(s). If (1)'s value is multiple columns, it will concat them all   with `_and` wording
+- `algorithm_name`: `test_relationship` (only supported value now)
+- `contains_test_name`: Configure the test name (detected with `contains` logic). Default to `relationship`
+- `c_from`: Configure the test metadata attribute (1) for the foreign key column name(s). If (1)'s value is multiple columns, it will concat them all   with `_and` wording
       > NOTE: It always looking at the `column_name` attribute firstly
-  - `c_to`: Configure the test metadata attribute (2) for the referenced column name(s). If (2)'s value is multiple columns, it will concat them all with `_and` wording. Default to `field`
-
+- `c_to`: Configure the test metadata attribute (2) for the referenced column name(s). If (2)'s value is multiple columns, it will concat them all with `_and` wording. Default to `field`
 
 **Examples:**
 === "CLI"
@@ -250,7 +247,6 @@ Specified dbt resource type(seed, model, source, snapshot).
     dbterd run --resource-type model
     ```
 
-
 ## debug
 
 Shows hidden configured values
diff --git a/docs/nav/guide/generate-markdown-mermaid-erd.md b/docs/nav/guide/generate-markdown-mermaid-erd.md
index 386f43e..ceee1ec 100644
--- a/docs/nav/guide/generate-markdown-mermaid-erd.md
+++ b/docs/nav/guide/generate-markdown-mermaid-erd.md
@@ -1,12 +1,14 @@
-# Generate markdown with mermaid ERD inclusive
+# Generate Markdown with Mermaid ERD inclusive
 
-### 1. Generate mermaid ERD content
-```
+## 1. Generate mermaid ERD content
+
+```bash
 dbterd run -t mermaid -ad "samples/dbtresto" -o "target" -s schema:dbt.mart -ns model.dbt_resto.staging
 # Expected: ./target/output.md
 ```
 
-### 2. Copy mermaid to ERD.md
+## 2. Copy mermaid to ERD.md
+
 ```bash
 echo \`\`\`mermaid          >  ./samples/dbtresto/ERD.md
 echo ---                    >> ./samples/dbtresto/ERD.md
diff --git a/docs/nav/guide/generate-plantuml.md b/docs/nav/guide/generate-plantuml.md
new file mode 100644
index 0000000..2b790ac
--- /dev/null
+++ b/docs/nav/guide/generate-plantuml.md
@@ -0,0 +1,27 @@
+# Generate PlantUML
+
+## 1. Generate PlantUML ERD content
+
+```bash
+dbterd run -t plantuml -ad "samples/dbtresto" -s schema:dbt.mart
+# Expected: ./target/output.plantuml
+```
+
+## 2. Wrapping UML up to the web server
+
+- Go to [PlantUML Web Server](http://www.plantuml.com/plantuml/uml)
+- Paste the PlantUML content generated as above
+- Wait for a second and get the URL
+
+### 3. Embeded the PlantUML URL into Markdown
+
+```markdown
+![](https://www.plantuml.com/plantuml/dpng/{your-hash})
+```
+
+![erd](https://www.plantuml.com/plantuml/dpng/fLRRZgCm37tlLw3vW4hbGgLzcZ-9N71tK251IPZPxhR_lh08gnjCZ3HzAPpZayknu_3kF5W_TEq1jM_yFNdhJ8tjiRvuPT5vSwoRJbtChxVapo4PV-EZkk4z-P5yWgq-m1BQr0nOWySHdlu8i-Y6rYizT1UqRcGrgRW8THf36kqts3JAPb4sZx95b2sZx9dOPzJPoPZ5skbvyfRyuZmax1xC_uLu2w3EQF_1OKf3XqosxWXsvy9xs_ocbVzx2Sg2y6KolMQChN7FX5Ue5cUNAUStzGdjW2zRD5KrM8kwua7L6wTEsrIwJMNRLBvD5TjKkKsTNwWMd5VGzmWMT4M545AqEIaWbG8oAI2N0Wefe9G2pXImhohKrLYimFcbF9Rt-V24it6dYXJKbwYKIITtBnhb5AOmtjT8bygwbDDbiYxZTBciopbT8UiAJbUAEWhPctjN6TledvJwaM9w4LSHiKuoZOhgj4BI5PAja7o552larGWB-aQdzzeQ2E2KXpCUpdcuuE6IOD7lEKtaCn34Vn4mA7zHy5UVDifSrPVHPDIYdrD5ooQyXwR-3swScXaFDzHxoyfXUDc8vpwyTuJM_JmamYNtV1oFZqVac-6G24cAZJ3kvri3pAxAGyYxU4TT3rtx5m00)
+
+## Known Limitation💡
+
+- Plant UML connectors seem not to support to indicate the columns which are related to the Relationship
+- Left the question _"How to automate the markdown image URL generated?"_ opened
diff --git a/mkdocs.yml b/mkdocs.yml
index 9edc2d5..e8cc185 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -16,11 +16,12 @@ nav:
       - 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
+      - Metadata:
+        - Ignore Tests: nav/metadata/ignore_in_erd.md
     - Development:
       - Contribution Guideline: nav/development/contributing-guide.md
       - Change Log: nav/development/CHANGELOG.md
-    - Metadata:
-      - Ignore Tests: nav/metadata/ignore_in_erd.md
     - About:
       - license.md
 
diff --git a/samples/dbt-constraints/ERD.md b/samples/dbt-constraints/ERD.md
index 4bc7773..e9e684a 100644
--- a/samples/dbt-constraints/ERD.md
+++ b/samples/dbt-constraints/ERD.md
@@ -1,3 +1,5 @@
+# Sample Mermaid ERD
+
 ```mermaid
 ---
 title: Sample ERD


Test patch:
diff --git a/dbterd/adapters/targets/plantuml/plantuml_test_relationship.py b/dbterd/adapters/targets/plantuml/plantuml_test_relationship.py
new file mode 100644
index 0000000..8483c1f
--- /dev/null
+++ b/dbterd/adapters/targets/plantuml/plantuml_test_relationship.py
@@ -0,0 +1,50 @@
+from dbterd.adapters.algos import test_relationship
+
+
+def run(manifest, catalog, **kwargs):
+    """Parse dbt artifacts and export PlantUML file
+
+    Args:
+        manifest (dict): Manifest json
+        catalog (dict): Catalog json
+
+    Returns:
+        Tuple(str, str): File name and the PlantUML content
+    """
+    return ("output.plantuml", parse(manifest, catalog, **kwargs))
+
+
+def parse(manifest, catalog, **kwargs):
+    """Get the PlantUML content from dbt artifacts
+
+    Args:
+        manifest (dict): Manifest json
+        catalog (dict): Catalog json
+
+    Returns:
+        str: PlantUML content
+    """
+    tables, relationships = test_relationship.parse(
+        manifest=manifest, catalog=catalog, **kwargs
+    )
+
+    # Build PlantUML content
+    # https://plantuml.com/ie-diagram, https://www.plantuml.com/plantuml/uml
+    plantuml = "@startuml\n"
+    for table in tables:
+        plantuml += 'entity "{table}" {{\n{columns}\n  }}\n'.format(
+            table=table.name,
+            columns="\n".join([f"    {x.name} : {x.data_type}" for x in table.columns]),
+        )
+
+    for rel in relationships:
+        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"
+        if new_rel not in plantuml:
+            plantuml += new_rel
+
+    plantuml += "@enduml"
+
+    return plantuml
diff --git a/tests/unit/adapters/targets/plantuml/test_plantuml_test_relationship.py b/tests/unit/adapters/targets/plantuml/test_plantuml_test_relationship.py
new file mode 100644
index 0000000..29dfc67
--- /dev/null
+++ b/tests/unit/adapters/targets/plantuml/test_plantuml_test_relationship.py
@@ -0,0 +1,244 @@
+from unittest import mock
+
+import pytest
+
+from dbterd.adapters.algos.meta import Column, Ref, Table
+from dbterd.adapters.targets.plantuml import plantuml_test_relationship as engine
+
+
+class TestPlantUMLTestRelationship:
+    @pytest.mark.parametrize(
+        "tables, relationships, select, exclude, resource_type, expected",
+        [
+            (
+                [
+                    Table(
+                        name="model.dbt_resto.table1",
+                        database="--database--",
+                        schema="--schema--",
+                        columns=[Column(name="name1", data_type="name1-type")],
+                        raw_sql="--irrelevant--",
+                    )
+                ],
+                [],
+                [],
+                [],
+                ["model"],
+                """@startuml
+                    entity "model.dbt_resto.table1" {
+                        name1 : name1-type
+                    }
+                @enduml
+                """,
+            ),
+            (
+                [
+                    Table(
+                        name="model.dbt_resto.table1",
+                        database="--database--",
+                        schema="--schema--",
+                        columns=[Column(name="name1", data_type="name1-type")],
+                        raw_sql="--irrelevant--",
+                    ),
+                    Table(
+                        name="model.dbt_resto.table2",
+                        database="--database2--",
+                        schema="--schema2--",
+                        columns=[Column(name="name2", data_type="name2-type2")],
+                        raw_sql="--irrelevant--",
+                    ),
+                    Table(
+                        name="source.dbt_resto.table3",
+                        database="--database3--",
+                        schema="--schema3--",
+                        columns=[Column(name="name3", data_type="name3-type3")],
+                        raw_sql="--irrelevant--",
+                    ),
+                ],
+                [
+                    Ref(
+                        name="test.dbt_resto.relationships_table1",
+                        table_map=["model.dbt_resto.table2", "model.dbt_resto.table1"],
+                        column_map=["name2", "name1"],
+                    ),
+                    Ref(
+                        name="test.dbt_resto.relationships_table1",
+                        table_map=["model.dbt_resto.table2", "model.dbt_resto.table1"],
+                        column_map=["name-notexist2", "name-notexist1"],
+                    ),
+                ],
+                [],
+                [],
+                ["model", "source"],
+                """@startuml
+                    entity "model.dbt_resto.table1" {
+                        name1 : name1-type
+                        name-notexist1 : unknown
+                    }
+                    entity "model.dbt_resto.table2" {
+                        name2 : name2-type2
+                        name-notexist2: unknown
+                    }
+                    entity "source.dbt_resto.table3" {
+                        name3 : name3-type3
+                    }
+                    "model.dbt_resto.table1" }|..|| "model.dbt_resto.table2"
+                @enduml
+                """,
+            ),
+            (
+                [
+                    Table(
+                        name="model.dbt_resto.table1",
+                        database="--database--",
+                        schema="--schema--",
+                        columns=[Column(name="name1", data_type="name1-type")],
+                        raw_sql="--irrelevant--",
+                    ),
+                    Table(
+                        name="model.dbt_resto.table2",
+                        database="--database2--",
+                        schema="--schema2--",
+                        columns=[Column(name="name2", data_type="name2-type2")],
+                        raw_sql="--irrelevant--",
+                    ),
+                ],
+                [
+                    Ref(
+                        name="test.dbt_resto.relationships_table1",
+                        table_map=["model.dbt_resto.table2", "model.dbt_resto.table1"],
+                        column_map=["name2", "name1"],
+                    )
+                ],
+                ["schema:--schema--"],
+                [],
+                ["model", "source"],
+                """@startuml
+                    entity "model.dbt_resto.table1" {
+                        name1 : name1-type
+                    }
+                @enduml
+                """,
+            ),
+            (
+                [
+                    Table(
+                        name="model.dbt_resto.table1",
+                        database="--database--",
+                        schema="--schema--",
+                        columns=[Column(name="name1", data_type="name1-type")],
+                        raw_sql="--irrelevant--",
+                    )
+                ],
+                [],
+                [],
+                ["model.dbt_resto.table1"],
+                ["model"],
+                """@startuml
+                @enduml
+                """,
+            ),
+            (
+                [
+                    Table(
+                        name="model.dbt_resto.table1",
+                        database="--database--",
+                        schema="--schema--",
+                        columns=[Column(name="name1", data_type="name1-type")],
+                        raw_sql="--irrelevant--",
+                    ),
+                    Table(
+                        name="model.dbt_resto.table2",
+                        database="--database--",
+                        schema="--schema--",
+                        columns=[Column(name="name2", data_type="name2-type")],
+                        raw_sql="--irrelevant--",
+                    ),
+                ],
+                [],
+                ["model.dbt_resto"],
+                ["model.dbt_resto.table2"],
+                ["model"],
+                """@startuml
+                    entity "model.dbt_resto.table1" {
+                        name1 : name1-type
+                    }
+                @enduml
+                """,
+            ),
+            (
+                [
+                    Table(
+                        name="model.dbt_resto.table1",
+                        database="--database--",
+                        schema="--schema--",
+                        columns=[Column(name="name1", data_type="name1-type")],
+                        raw_sql="--irrelevant--",
+                    ),
+                ],
+                [],
+                ["schema:", "wildcard:", ""],
+                [],
+                ["model"],
+                """@startuml
+                    entity "model.dbt_resto.table1" {
+                        name1 : name1-type
+                    }
+                @enduml
+                """,
+            ),
+            (
+                [
+                    Table(
+                        name="model.dbt_resto.table1",
+                        database="--database--",
+                        schema="--schema--",
+                        columns=[Column(name="name1", data_type="name1-type")],
+                        raw_sql="--irrelevant--",
+                    ),
+                    Table(
+                        name="model.dbt_resto.table2",
+                        database="--database--",
+                        schema="--schema--",
+                        columns=[Column(name="name2", data_type="name2-type")],
+                        raw_sql="--irrelevant--",
+                    ),
+                ],
+                [],
+                ["schema:--schema--,wildcard:*dbt_resto.table*"],
+                ["wildcard:*table2"],
+                ["model"],
+                """@startuml
+                    entity "model.dbt_resto.table1" {
+                        name1 : name1-type
+                    }
+                @enduml
+                """,
+            ),
+        ],
+    )
+    def test_parse(
+        self, tables, relationships, select, exclude, resource_type, expected
+    ):
+        with mock.patch(
+            "dbterd.adapters.algos.base.get_tables",
+            return_value=tables,
+        ) as mock_get_tables:
+            with mock.patch(
+                "dbterd.adapters.algos.test_relationship.get_relationships",
+                return_value=relationships,
+            ) as mock_get_relationships:
+                plantuml = engine.parse(
+                    manifest="--manifest--",
+                    catalog="--catalog--",
+                    select=select,
+                    exclude=exclude,
+                    resource_type=resource_type,
+                )
+                print("plantuml ", plantuml.replace(" ", "").replace("\n", ""))
+                print("expected", expected.replace(" ", "").replace("\n", ""))
+                assert plantuml.replace(" ", "").replace("\n", "") == str(
+                    expected
+                ).replace(" ", "").replace("\n", "")
+                mock_get_tables.assert_called_once()
+                mock_get_relationships.assert_called_once()
diff --git a/tests/unit/cli/test_runner.py b/tests/unit/cli/test_runner.py
index c017548..fab0afc 100644
--- a/tests/unit/cli/test_runner.py
+++ b/tests/unit/cli/test_runner.py
@@ -70,6 +70,7 @@ def test_invoke_run_with_invalid_strategy(self, dbterd: dbterdRunner) -> None:
         [
             ("dbml", "output.dbml"),
             ("mermaid", "output.md"),
+            ("plantuml", "output.plantuml"),
         ],
     )
     def test_invoke_run_ok(self, target, output, dbterd: dbterdRunner) -> None: