diff --git a/.gitignore b/.gitignore
index e883298..f023b0e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,7 @@ venv.bak/
.eggs/
dist/
build/
+uv.lock
# AI
.claude
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..4b25ad6
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,20 @@
+# AGENTS.md
+
+## Commands
+- **Install**: `uv sync --all-extras`
+- **Test all**: `uv run pytest`
+- **Test single**: `uv run pytest tests/test_models.py::TestKeyPart::test_valid_key`
+- **Build docs**: `uv run mkdocs build`
+- **Serve docs**: `uv run mkdocs serve`
+
+## Code Style
+- **Python**: 3.12+, use `uv` for dependency management
+- **Imports**: Group stdlib, third-party, local imports; use `from pathlib import Path` for paths
+- **Types**: Full type hints required, use Pydantic models with discriminated unions
+- **Naming**:
+ - Classes: PascalCase
+ - Functions/variables: snake_case
+ - Constants: UPPER_SNAKE_CASE
+ - Keys: end with `_ID`, tables: lowercase_with_underscores, value sets: end with `_set`
+- **Error handling**: Use Pydantic validators, raise ValueError with descriptive messages
+- **Models**: Use frozen ConfigDict for immutable data, populate_by_name for alias support
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
index f0870fd..2f244ac 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,21 +1,395 @@
-MIT License
-
-Copyright (c) 2025 modelEAU
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+Attribution 4.0 International
+
+=======================================================================
+
+Creative Commons Corporation ("Creative Commons") is not a law firm and
+does not provide legal services or legal advice. Distribution of
+Creative Commons public licenses does not create a lawyer-client or
+other relationship. Creative Commons makes its licenses and related
+information available on an "as-is" basis. Creative Commons gives no
+warranties regarding its licenses, any material licensed under their
+terms and conditions, or any related information. Creative Commons
+disclaims all liability for damages resulting from their use to the
+fullest extent possible.
+
+Using Creative Commons Public Licenses
+
+Creative Commons public licenses provide a standard set of terms and
+conditions that creators and other rights holders may use to share
+original works of authorship and other material subject to copyright
+and certain other rights specified in the public license below. The
+following considerations are for informational purposes only, are not
+exhaustive, and do not form part of our licenses.
+
+ Considerations for licensors: Our public licenses are
+ intended for use by those authorized to give the public
+ permission to use material in ways otherwise restricted by
+ copyright and certain other rights. Our licenses are
+ irrevocable. Licensors should read and understand the terms
+ and conditions of the license they choose before applying it.
+ Licensors should also secure all rights necessary before
+ applying our licenses so that the public can reuse the
+ material as expected. Licensors should clearly mark any
+ material not subject to the license. This includes other CC-
+ licensed material, or material used under an exception or
+ limitation to copyright. More considerations for licensors:
+ wiki.creativecommons.org/Considerations_for_licensors
+
+ Considerations for the public: By using one of our public
+ licenses, a licensor grants the public permission to use the
+ licensed material under specified terms and conditions. If
+ the licensor's permission is not necessary for any reason--for
+ example, because of any applicable exception or limitation to
+ copyright--then that use is not regulated by the license. Our
+ licenses grant only permissions under copyright and certain
+ other rights that a licensor has authority to grant. Use of
+ the licensed material may still be restricted for other
+ reasons, including because others have copyright or other
+ rights in the material. A licensor may make special requests,
+ such as asking that all changes be marked or described.
+ Although not required by our licenses, you are encouraged to
+ respect those requests where reasonable. More_considerations
+ for the public:
+ wiki.creativecommons.org/Considerations_for_licensees
+
+=======================================================================
+
+Creative Commons Attribution 4.0 International Public License
+
+By exercising the Licensed Rights (defined below), You accept and agree
+to be bound by the terms and conditions of this Creative Commons
+Attribution 4.0 International Public License ("Public License"). To the
+extent this Public License may be interpreted as a contract, You are
+granted the Licensed Rights in consideration of Your acceptance of
+these terms and conditions, and the Licensor grants You such rights in
+consideration of benefits the Licensor receives from making the
+Licensed Material available under these terms and conditions.
+
+
+Section 1 -- Definitions.
+
+ a. Adapted Material means material subject to Copyright and Similar
+ Rights that is derived from or based upon the Licensed Material
+ and in which the Licensed Material is translated, altered,
+ arranged, transformed, or otherwise modified in a manner requiring
+ permission under the Copyright and Similar Rights held by the
+ Licensor. For purposes of this Public License, where the Licensed
+ Material is a musical work, performance, or sound recording,
+ Adapted Material is always produced where the Licensed Material is
+ synched in timed relation with a moving image.
+
+ b. Adapter's License means the license You apply to Your Copyright
+ and Similar Rights in Your contributions to Adapted Material in
+ accordance with the terms and conditions of this Public License.
+
+ c. Copyright and Similar Rights means copyright and/or similar rights
+ closely related to copyright including, without limitation,
+ performance, broadcast, sound recording, and Sui Generis Database
+ Rights, without regard to how the rights are labeled or
+ categorized. For purposes of this Public License, the rights
+ specified in Section 2(b)(1)-(2) are not Copyright and Similar
+ Rights.
+
+ d. Effective Technological Measures means those measures that, in the
+ absence of proper authority, may not be circumvented under laws
+ fulfilling obligations under Article 11 of the WIPO Copyright
+ Treaty adopted on December 20, 1996, and/or similar international
+ agreements.
+
+ e. Exceptions and Limitations means fair use, fair dealing, and/or
+ any other exception or limitation to Copyright and Similar Rights
+ that applies to Your use of the Licensed Material.
+
+ f. Licensed Material means the artistic or literary work, database,
+ or other material to which the Licensor applied this Public
+ License.
+
+ g. Licensed Rights means the rights granted to You subject to the
+ terms and conditions of this Public License, which are limited to
+ all Copyright and Similar Rights that apply to Your use of the
+ Licensed Material and that the Licensor has authority to license.
+
+ h. Licensor means the individual(s) or entity(ies) granting rights
+ under this Public License.
+
+ i. Share means to provide material to the public by any means or
+ process that requires permission under the Licensed Rights, such
+ as reproduction, public display, public performance, distribution,
+ dissemination, communication, or importation, and to make material
+ available to the public including in ways that members of the
+ public may access the material from a place and at a time
+ individually chosen by them.
+
+ j. Sui Generis Database Rights means rights other than copyright
+ resulting from Directive 96/9/EC of the European Parliament and of
+ the Council of 11 March 1996 on the legal protection of databases,
+ as amended and/or succeeded, as well as other essentially
+ equivalent rights anywhere in the world.
+
+ k. You means the individual or entity exercising the Licensed Rights
+ under this Public License. Your has a corresponding meaning.
+
+
+Section 2 -- Scope.
+
+ a. License grant.
+
+ 1. Subject to the terms and conditions of this Public License,
+ the Licensor hereby grants You a worldwide, royalty-free,
+ non-sublicensable, non-exclusive, irrevocable license to
+ exercise the Licensed Rights in the Licensed Material to:
+
+ a. reproduce and Share the Licensed Material, in whole or
+ in part; and
+
+ b. produce, reproduce, and Share Adapted Material.
+
+ 2. Exceptions and Limitations. For the avoidance of doubt, where
+ Exceptions and Limitations apply to Your use, this Public
+ License does not apply, and You do not need to comply with
+ its terms and conditions.
+
+ 3. Term. The term of this Public License is specified in Section
+ 6(a).
+
+ 4. Media and formats; technical modifications allowed. The
+ Licensor authorizes You to exercise the Licensed Rights in
+ all media and formats whether now known or hereafter created,
+ and to make technical modifications necessary to do so. The
+ Licensor waives and/or agrees not to assert any right or
+ authority to forbid You from making technical modifications
+ necessary to exercise the Licensed Rights, including
+ technical modifications necessary to circumvent Effective
+ Technological Measures. For purposes of this Public License,
+ simply making modifications authorized by this Section 2(a)
+ (4) never produces Adapted Material.
+
+ 5. Downstream recipients.
+
+ a. Offer from the Licensor -- Licensed Material. Every
+ recipient of the Licensed Material automatically
+ receives an offer from the Licensor to exercise the
+ Licensed Rights under the terms and conditions of this
+ Public License.
+
+ b. No downstream restrictions. You may not offer or impose
+ any additional or different terms or conditions on, or
+ apply any Effective Technological Measures to, the
+ Licensed Material if doing so restricts exercise of the
+ Licensed Rights by any recipient of the Licensed
+ Material.
+
+ 6. No endorsement. Nothing in this Public License constitutes or
+ may be construed as permission to assert or imply that You
+ are, or that Your use of the Licensed Material is, connected
+ with, or sponsored, endorsed, or granted official status by,
+ the Licensor or others designated to receive attribution as
+ provided in Section 3(a)(1)(A)(i).
+
+ b. Other rights.
+
+ 1. Moral rights, such as the right of integrity, are not
+ licensed under this Public License, nor are publicity,
+ privacy, and/or other similar personality rights; however, to
+ the extent possible, the Licensor waives and/or agrees not to
+ assert any such rights held by the Licensor to the limited
+ extent necessary to allow You to exercise the Licensed
+ Rights, but not otherwise.
+
+ 2. Patent and trademark rights are not licensed under this
+ Public License.
+
+ 3. To the extent possible, the Licensor waives any right to
+ collect royalties from You for the exercise of the Licensed
+ Rights, whether directly or through a collecting society
+ under any voluntary or waivable statutory or compulsory
+ licensing scheme. In all other cases the Licensor expressly
+ reserves any right to collect such royalties.
+
+
+Section 3 -- License Conditions.
+
+Your exercise of the Licensed Rights is expressly made subject to the
+following conditions.
+
+ a. Attribution.
+
+ 1. If You Share the Licensed Material (including in modified
+ form), You must:
+
+ a. retain the following if it is supplied by the Licensor
+ with the Licensed Material:
+
+ i. identification of the creator(s) of the Licensed
+ Material and any others designated to receive
+ attribution, in any reasonable manner requested by
+ the Licensor (including by pseudonym if
+ designated);
+
+ ii. a copyright notice;
+
+ iii. a notice that refers to this Public License;
+
+ iv. a notice that refers to the disclaimer of
+ warranties;
+
+ v. a URI or hyperlink to the Licensed Material to the
+ extent reasonably practicable;
+
+ b. indicate if You modified the Licensed Material and
+ retain an indication of any previous modifications; and
+
+ c. indicate the Licensed Material is licensed under this
+ Public License, and include the text of, or the URI or
+ hyperlink to, this Public License.
+
+ 2. You may satisfy the conditions in Section 3(a)(1) in any
+ reasonable manner based on the medium, means, and context in
+ which You Share the Licensed Material. For example, it may be
+ reasonable to satisfy the conditions by providing a URI or
+ hyperlink to a resource that includes the required
+ information.
+
+ 3. If requested by the Licensor, You must remove any of the
+ information required by Section 3(a)(1)(A) to the extent
+ reasonably practicable.
+
+ 4. If You Share Adapted Material You produce, the Adapter's
+ License You apply must not prevent recipients of the Adapted
+ Material from complying with this Public License.
+
+
+Section 4 -- Sui Generis Database Rights.
+
+Where the Licensed Rights include Sui Generis Database Rights that
+apply to Your use of the Licensed Material:
+
+ a. for the avoidance of doubt, Section 2(a)(1) grants You the right
+ to extract, reuse, reproduce, and Share all or a substantial
+ portion of the contents of the database;
+
+ b. if You include all or a substantial portion of the database
+ contents in a database in which You have Sui Generis Database
+ Rights, then the database in which You have Sui Generis Database
+ Rights (but not its individual contents) is Adapted Material; and
+
+ c. You must comply with the conditions in Section 3(a) if You Share
+ all or a substantial portion of the contents of the database.
+
+For the avoidance of doubt, this Section 4 supplements and does not
+replace Your obligations under this Public License where the Licensed
+Rights include other Copyright and Similar Rights.
+
+
+Section 5 -- Disclaimer of Warranties and Limitation of Liability.
+
+ a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
+ EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
+ AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
+ ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
+ IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
+ WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
+ PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
+ ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
+ KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
+ ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
+
+ b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
+ TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
+ NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
+ INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
+ COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
+ USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
+ ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
+ DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
+ IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
+
+ c. The disclaimer of warranties and limitation of liability provided
+ above shall be interpreted in a manner that, to the extent
+ possible, most closely approximates an absolute disclaimer and
+ waiver of all liability.
+
+
+Section 6 -- Term and Termination.
+
+ a. This Public License applies for the term of the Copyright and
+ Similar Rights licensed here. However, if You fail to comply with
+ this Public License, then Your rights under this Public License
+ terminate automatically.
+
+ b. Where Your right to use the Licensed Material has terminated under
+ Section 6(a), it reinstates:
+
+ 1. automatically as of the date the violation is cured, provided
+ it is cured within 30 days of Your discovery of the
+ violation; or
+
+ 2. upon express reinstatement by the Licensor.
+
+ For the avoidance of doubt, this Section 6(b) does not affect any
+ right the Licensor may have to seek remedies for Your violations
+ of this Public License.
+
+ c. For the avoidance of doubt, the Licensor may also offer the
+ Licensed Material under separate terms or conditions or stop
+ distributing the Licensed Material at any time; however, doing so
+ will not terminate this Public License.
+
+ d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
+ License.
+
+
+Section 7 -- Other Terms and Conditions.
+
+ a. The Licensor shall not be bound by any additional or different
+ terms or conditions communicated by You unless expressly agreed.
+
+ b. Any arrangements, understandings, or agreements regarding the
+ Licensed Material not stated herein are separate from and
+ independent of the terms and conditions of this Public License.
+
+
+Section 8 -- Interpretation.
+
+ a. For the avoidance of doubt, this Public License does not, and
+ shall not be interpreted to, reduce, limit, restrict, or impose
+ conditions on any use of the Licensed Material that could lawfully
+ be made without permission under this Public License.
+
+ b. To the extent possible, if any provision of this Public License is
+ deemed unenforceable, it shall be automatically reformed to the
+ minimum extent necessary to make it enforceable. If the provision
+ cannot be reformed, it shall be severed from this Public License
+ without affecting the enforceability of the remaining terms and
+ conditions.
+
+ c. No term or condition of this Public License will be waived and no
+ failure to comply consented to unless expressly agreed to by the
+ Licensor.
+
+ d. Nothing in this Public License constitutes or may be interpreted
+ as a limitation upon, or waiver of, any privileges and immunities
+ that apply to the Licensor or You, including from the legal
+ processes of any jurisdiction or authority.
+
+
+=======================================================================
+
+Creative Commons is not a party to its public
+licenses. Notwithstanding, Creative Commons may elect to apply one of
+its public licenses to material it publishes and in those instances
+will be considered the “Licensor.” The text of the Creative Commons
+public licenses is dedicated to the public domain under the CC0 Public
+Domain Dedication. Except for the limited purpose of indicating that
+material is shared under a Creative Commons public license or as
+otherwise permitted by the Creative Commons policies published at
+creativecommons.org/policies, Creative Commons does not authorize the
+use of the trademark "Creative Commons" or any other trademark or logo
+of Creative Commons without its prior written consent including,
+without limitation, in connection with any unauthorized modifications
+to any of its public licenses or any other arrangements,
+understandings, or agreements concerning use of licensed material. For
+the avoidance of doubt, this paragraph does not form part of the
+public licenses.
+
+Creative Commons may be contacted at creativecommons.org.
diff --git a/README.md b/README.md
index a965834..1e7812f 100644
--- a/README.md
+++ b/README.md
@@ -62,4 +62,4 @@ uv pip install -e ".[dev]"
```uv run mkdocs serve```
## License
-dat*EAU*base is published under the MIT license.
+dat*EAU*base is published under the CC-BY 4.0 license.
diff --git a/docs/assets/erd_interactive.html b/docs/assets/erd_interactive.html
new file mode 100644
index 0000000..49b9cac
--- /dev/null
+++ b/docs/assets/erd_interactive.html
@@ -0,0 +1,2420 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/assets/erd_simple.html b/docs/assets/erd_simple.html
new file mode 100644
index 0000000..49b9cac
--- /dev/null
+++ b/docs/assets/erd_simple.html
@@ -0,0 +1,2420 @@
+
+
+
+
+
+ datEAUbase ERD
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Field Details
+
+
+
+
+ Select a field to view details.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/contributing/dictionary.md b/docs/contributing/dictionary.md
new file mode 100644
index 0000000..a224a84
--- /dev/null
+++ b/docs/contributing/dictionary.md
@@ -0,0 +1,464 @@
+# The Dictionary: A Self-Documenting Database Schema
+
+## Purpose
+
+The dictionary (stored as `dictionary.json` at the project root) serves as a comprehensive metadata repository that defines every component of the dat*EAU*base data model. It acts as a single source of truth from which you can generate SQL schemas, documentation, and entity-relationship diagrams.
+
+**Key Principle**: Each unique field concept gets exactly ONE entry in the dictionary, even if that field appears in multiple tables. The `table_presence` object indicates where each field appears and in what role.
+
+The dictionary is self-referential: it contains the definitions needed to describe itself, making it bootstrapped and internally consistent.
+
+## Understanding the Structure
+
+### JSON Format
+
+The dictionary uses a hierarchical JSON structure that eliminates sparse columns. Each part has only the metadata it needs:
+
+```json
+{
+ "parts": [
+ {
+ "Part_ID": "Contact_ID",
+ "Label": "Contact ID",
+ "Description": "Identifier for contacts",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "table_presence": {
+ "contact": {
+ "role": "key",
+ "required": true,
+ "order": 1
+ },
+ "project_has_contact": {
+ "role": "compositeKeySecond",
+ "required": false,
+ "order": 2
+ }
+ }
+ }
+ ]
+}
+```
+
+### Core Fields
+
+Every part has these core metadata fields:
+
+- **Part_ID**: Unique identifier for this field/table/value
+- **Label**: Human-readable name
+- **Description**: Detailed explanation of what this part represents
+- **Part_type**: Classification (`table`, `key`, `property`, `compositeKeyFirst`, `compositeKeySecond`, `parentKey`, `valueSet`, `valueSetMember`)
+- **Value_set_part_ID**: If this property is constrained by a value set, which set (optional)
+- **Member_of_set_part_ID**: If this is a value set member, which set it belongs to (required for valueSetMember)
+- **Ancestor_part_ID**: For `parentKey` type, the Part_ID of the ancestor being referenced (enables hierarchical relationships within the same table)
+- **SQL_data_type**: SQL data type (e.g., `int`, `nvarchar(100)`, `datetime`) (optional)
+- **Is_required**: Whether this field is mandatory (NOT NULL) (optional)
+- **Default_value**: Default value for the field (optional)
+- **Sort_order**: Display order for documentation/UI (optional)
+
+### Table Presence Object
+
+For fields (keys and properties), the `table_presence` object maps table names to metadata about how the field appears:
+
+```json
+"table_presence": {
+ "table_name": {
+ "role": "key|property|compositeKeyFirst|compositeKeySecond",
+ "required": true|false,
+ "order": 1
+ }
+}
+```
+
+**Roles**:
+
+- **`key`**: This field is the primary key in this table
+- **`compositeKeyFirst`**: First part of a composite primary key
+- **`compositeKeySecond`**: Second part of a composite primary key
+- **`property`**: This field is a regular column in this table
+
+**Example**: `Equipment_ID` has:
+
+```json
+{
+ "Part_ID": "Equipment_ID",
+ "table_presence": {
+ "equipment": {"role": "key", "required": true, "order": 1},
+ "metadata": {"role": "property", "required": false, "order": 5},
+ "project_has_equipment": {"role": "compositeKeySecond", "required": false, "order": 2}
+ }
+}
+```
+
+## Reading the Dictionary
+
+### Find acceptable values for a field
+
+To determine what values a field can accept, check if it references a valueSet:
+
+```python exec="true" source="above" result="console"
+from open_dateaubase.data_model.helpers import DictionaryManager
+mgr = DictionaryManager.load("src/dictionary.json")
+
+# Get the value set for a field
+field = mgr._find_part("Site_type")
+if field and hasattr(field, 'value_set_part_id'):
+ value_set_id = field.value_set_part_id
+ print(f"Field 'Site_type' uses value set: {value_set_id}")
+else:
+ print("Field 'Site_type' has no value set constraint")
+```
+
+```python exec="true" source="above" result="console"
+from open_dateaubase.data_model.helpers import DictionaryManager
+mgr = DictionaryManager.load("src/dictionary.json")
+
+# Get all valid values for that set
+members = mgr.get_value_set_members("Site_type")
+for member in members:
+ print(f"{member['Part_ID']}: {member['Label']} - {member['Description']}")
+```
+
+### Get all columns in a table
+
+To retrieve all columns that appear in a specific table:
+
+```python exec="true" source="above" result="console"
+from open_dateaubase.data_model.helpers import DictionaryManager
+mgr = DictionaryManager.load("src/dictionary.json")
+
+# Get all columns in the site table
+columns = mgr.get_table_columns("site")
+for col in columns:
+ print(f"{col['Part_ID']}: {col['Label']} ({col['SQL_data_type']}) - {col['Role']} - Required: {col['Is_required']}")
+```
+
+Or to see what role each field plays:
+
+```python exec="true" source="above" result="console"
+from open_dateaubase.data_model.helpers import DictionaryManager
+mgr = DictionaryManager.load("src/dictionary.json")
+
+# Show role information for each field in the site table
+columns = mgr.get_table_columns("site")
+for col in columns:
+ print(f"{col['Part_ID']}: {col['Label']} - Role: {col['Role']}")
+```
+
+### Find which tables contain a specific field
+
+To see all tables where `Equipment_ID` appears:
+
+```python exec="true" source="above" result="console"
+from open_dateaubase.data_model.helpers import DictionaryManager
+mgr = DictionaryManager.load("src/dictionary.json")
+
+# Find all tables where Equipment_ID appears
+tables = mgr.get_field_tables("Equipment_ID")
+for table_info in tables:
+ print(f"Table: {table_info['Table_ID']}, Role: {table_info['Role']}, Required: {table_info['Required']}, Order: {table_info['Order']}")
+```
+
+### Find all primary keys in the database
+
+```python exec="true" source="above" result="console"
+from open_dateaubase.data_model.helpers import DictionaryManager
+mgr = DictionaryManager.load("src/dictionary.json")
+
+# Get all primary keys
+primary_keys = mgr.get_primary_keys()
+for pk in primary_keys:
+ print(f"{pk['Part_ID']}: {pk['Label']} ({pk['SQL_data_type']}) - Primary in: {pk['Primary_in_tables']}")
+```
+
+Or to see just the key names:
+
+```python exec="true" source="above" result="console"
+from open_dateaubase.data_model.helpers import DictionaryManager
+mgr = DictionaryManager.load("src/dictionary.json")
+
+# Just the key names
+primary_keys = mgr.get_primary_keys()
+for pk in primary_keys:
+ print(f"{pk['Part_ID']}: {pk['Label']}")
+```
+
+### List all tables in the model
+
+```python exec="true" source="above" result="console"
+from open_dateaubase.data_model.helpers import DictionaryManager
+mgr = DictionaryManager.load("src/dictionary.json")
+
+# List all tables
+tables = mgr.list_tables()
+for table_id in tables:
+ table = mgr._find_part(table_id)
+ print(f"{table_id}: {table.label} - {table.description}")
+```
+
+### Find fields that appear in multiple tables
+
+```python exec="true" source="above" result="console"
+from open_dateaubase.data_model.helpers import DictionaryManager
+mgr = DictionaryManager.load("src/dictionary.json")
+
+# Find fields that appear in multiple tables
+shared_fields = mgr.get_shared_fields()
+for field in shared_fields[:10]: # Show first 10
+ print(f"{field['Part_ID']}: {field['Label']} ({field['Part_type']}) - Used in {field['Table_count']} tables")
+ for table in field['Tables']:
+ print(f" - {table['Table_ID']}: {table['Role']}")
+ print()
+```
+
+## Editing the Dictionary
+
+The dictionary should be edited using the `DictionaryManager` helper class, which ensures validation and consistency.
+
+### Adding a New Value Set
+
+```python
+from open_dateaubase.data_model.helpers import DictionaryManager
+
+mgr = DictionaryManager.load("src/dictionary.json")
+
+# Create a new value set
+mgr.create_value_set("Status_set", "Status Values", "Valid status values for records")
+
+# Add members to the set
+mgr.add_value_set_member("Status_set", "active", "Active", "Record is currently active", order=1)
+mgr.add_value_set_member("Status_set", "inactive", "Inactive", "Record is currently inactive", order=2)
+mgr.add_value_set_member("Status_set", "pending", "Pending", "Record is pending review", order=3)
+
+# Save the changes
+mgr.save()
+```
+
+### Adding a New Table
+
+```python
+from open_dateaubase.data_model.helpers import DictionaryManager
+
+mgr = DictionaryManager.load("src/dictionary.json")
+
+# Create the table
+mgr.create_table("observation", "Observation", "Environmental observation records")
+
+# Add primary key
+mgr.add_field_to_table(
+ table_id="observation",
+ field_id="Observation_ID",
+ label="Observation ID",
+ description="Primary key for observations",
+ role="key",
+ sql_data_type="int",
+ required=True,
+ order=1
+)
+
+# Add regular fields
+mgr.add_field_to_table(
+ table_id="observation",
+ field_id="Observation_date",
+ label="Observation Date",
+ description="Date when observation was made",
+ role="property",
+ sql_data_type="datetime",
+ required=True,
+ order=2
+)
+
+mgr.add_field_to_table(
+ table_id="observation",
+ field_id="Value",
+ label="Value",
+ description="Observed value",
+ role="property",
+ sql_data_type="float",
+ required=False,
+ order=3
+)
+
+# Save the changes
+mgr.save()
+```
+
+### Adding Fields to an Existing Table
+
+The `add_field_to_table()` method handles both new and existing fields automatically:
+
+- **If the field already exists** (like `Site_ID` used in multiple tables), it updates the field's `table_presence` to include this table
+- **If the field doesn't exist**, it creates a new field part
+
+```python
+from open_dateaubase.data_model.helpers import DictionaryManager
+
+mgr = DictionaryManager.load("src/dictionary.json")
+
+# Add a foreign key (Site_ID likely already exists in the dictionary)
+mgr.add_field_to_table(
+ table_id="observation",
+ field_id="Site_ID",
+ label="Site ID",
+ description="Foreign key to site",
+ role="property",
+ sql_data_type="int",
+ required=True,
+ order=4
+)
+
+# Add a new field with value set constraint
+mgr.add_field_to_table(
+ table_id="observation",
+ field_id="Status",
+ label="Status",
+ description="Current status of observation",
+ role="property",
+ sql_data_type="nvarchar(50)",
+ required=False,
+ value_set_id="Status_set",
+ order=5
+)
+
+# Add another new field
+mgr.add_field_to_table(
+ table_id="observation",
+ field_id="Notes",
+ label="Notes",
+ description="Additional notes about observation",
+ role="property",
+ sql_data_type="nvarchar(500)",
+ required=False,
+ order=6
+)
+
+mgr.save()
+```
+
+### Adding a Hierarchical Relationship (Parent Key)
+
+For tables with parent-child relationships within the same table:
+
+```python
+from open_dateaubase.data_model.helpers import DictionaryManager
+
+mgr = DictionaryManager.load("src/dictionary.json")
+
+# Add a parent key for hierarchical structure
+mgr.add_parent_key(
+ table_id="site",
+ parent_key_id="Parent_Site_ID",
+ ancestor_key_id="Site_ID",
+ label="Parent Site ID",
+ description="Reference to parent site in hierarchy",
+ sql_data_type="int",
+ required=False,
+ order=10
+)
+
+mgr.save()
+```
+
+This creates:
+
+- A new `Parent_Site_ID` field of type `parentKey`
+- With `Ancestor_part_ID` pointing to `Site_ID`
+- Appearing in the `site` table as a `property`
+
+### Handling Name Collisions
+
+If a non-ID field name appears in multiple tables with different meanings (e.g., `Description`, `City`):
+
+- Create **separate Part_ID entries** with table prefixes
+- Examples: `site_City`, `contact_City`, `purpose_Description`, `project_Description`
+- Each gets its own part with `table_presence` set for only that table
+- Labels can be the same or differentiated: "City", "City", etc.
+
+```python
+from open_dateaubase.data_model.helpers import DictionaryManager
+
+mgr = DictionaryManager.load("src/dictionary.json")
+
+# Add site-specific description
+mgr.add_field_to_table(
+ table_id="site",
+ field_id="site_Description",
+ label="Description",
+ description="Description of the site",
+ role="property",
+ sql_data_type="nvarchar(500)",
+ required=False,
+ order=6
+)
+
+# Add project-specific description (different content)
+mgr.add_field_to_table(
+ table_id="project",
+ field_id="project_Description",
+ label="Description",
+ description="Description of the project",
+ role="property",
+ sql_data_type="nvarchar(500)",
+ required=False,
+ order=7
+)
+
+mgr.save()
+```
+
+**Exception**: ID fields (`*_ID`) always use the same Part_ID across tables and are tracked via `table_presence`.
+
+### Regenerating Documentation and SQL
+
+After editing the dictionary, regenerate all outputs:
+
+```bash
+# Regenerate documentation
+uv run python scripts/orchestrate_docs.py
+
+# Or regenerate specific components
+uv run python scripts/generate_dictionary_reference.py dictionary.json docs/reference
+uv run python scripts/generate_erd.py dictionary.json docs/reference
+uv run python scripts/generate_sql.py dictionary.json sql_generation_scripts mssql
+```
+
+## Naming Conventions
+
+The following naming rules apply:
+
+### Part_IDs
+
+- **Tables**: Singular nouns, lowercase, words separated by underscores
+ - Examples: `watershed`, `sampling_point`, `equipment_model`, `weather_condition`
+
+- **Primary Keys**: `[Table_name]_ID` with capitalized first letters
+ - Examples: `Watershed_ID`, `Sampling_point_ID`, `Equipment_model_ID`
+ - Rule: These appear in multiple tables with the same Part_ID
+
+- **Foreign Keys**: Use the exact same Part_ID as the referenced primary key
+ - Example: `site` table references `watershed` via `Watershed_ID`
+ - The dictionary shows this with different roles in `table_presence`
+
+- **Regular Fields**: Descriptive names, mixed case with underscores
+ - Examples: `Site_name`, `Street_number`, `Latitude_GPS`, `Purchase_date`
+
+- **Table-Prefixed Fields**: When non-ID names collide across tables
+ - Format: `tablename_FieldName`
+ - Examples: `site_City`, `contact_City`, `purpose_Description`
+
+### Junction Tables (Many-to-Many)
+
+- Format: `[table1]_has_[table2]` where both tables are singular
+- Examples: `project_has_equipment`, `project_has_contact`, `equipment_model_has_procedure`
+- Primary keys are composite (two `compositeKey*` fields)
+
+### Value Sets and Members
+
+- **Value Sets**: descriptive name + `Set` or `_set` suffix
+ - Part_type: `valueSet`
+ - Examples: `Part_type_set`, `Site_type_set`, `StatusSet`
+
+- **Members**: short, descriptive identifiers
+ - Part_type: `valueSetMember`
+ - Member_of_set_part_ID: points to the set
+ - Examples: `table`, `key`, `property`, `valueSet`, `valueSetMember`, `active`, `inactive`
diff --git a/docs/contributing/parts_table.md b/docs/contributing/parts_table.md
deleted file mode 100644
index 6d20de3..0000000
--- a/docs/contributing/parts_table.md
+++ /dev/null
@@ -1,344 +0,0 @@
-# The Parts Table: A Self-Documenting Database Schema
-
-## Purpose
-
-The Parts table (stored as `dictionary.csv`) serves as a comprehensive metadata repository that defines every component of your database model. It acts as a single source of truth from which you can generate SQL schemas, documentation, and entity-relationship diagrams.
-
-**Key Principle**: Each unique field concept gets exactly ONE row in the dictionary, even if that field appears in multiple tables. The `TableName_present` columns indicate where each field appears and in what role.
-
-The Parts table is self-referential: it contains the definitions needed to describe itself, making it bootstrapped and internally consistent.
-
-## Understanding the Structure
-
-### Core Columns
-
-Every part has these core metadata columns:
-
-- **Part_ID**: Unique identifier for this field/table/value
-- **Label**: Human-readable name
-- **Description**: Detailed explanation of what this part represents
-- **Part_type**: Classification (`table`, `key`, `property`, `compositeKeyFirst`, `compositeKeySecond`, `parentKey`, `valueSet`, `valueSetMember`)
-- **Value_set_part_ID**: If this property is constrained by a value set, which set
-- **Member_of_set_part_ID**: If this is a value set member, which set it belongs to
-- **Ancestor_part_ID**: For `parentKey` type, the Part_ID of the ancestor being referenced (enables hierarchical relationships within the same table)
-- **SQL_data_type**: SQL data type (e.g., `int`, `nvarchar(100)`, `ntext`)
-- **Is_required**: Whether this field is mandatory (NOT NULL)
-- **Default_value**: Default value for the field
-- **Sort_order**: Display order for documentation/UI
-
-### Table Presence Columns
-
-For each table in the database, there are `TableName_present` columns that indicate if and how a field appears in that table:
-
-- **`key`**: This field is the primary key in this table
-- **`compositeKeyFirst`**: First part of a composite primary key
-- **`compositeKeySecond`**: Second part of a composite primary key
-- **`property`**: This field is a regular column in this table
-- **(empty)**: This field does not appear in this table
-
-**Example**: `Equipment_ID` has a single row with:
-
-- `equipment_present = key` (primary key in equipment table)
-- `metadata_present = property` (foreign key in metadata table)
-- `project_has_equipment_present = compositeKeySecond` (part of composite key)
-
-### Table Metadata Columns
-
-For tracking additional metadata, each table also has:
-
-- **TableName_required**: Whether this part is required in that table
-- **TableName_order**: Display order of this part in that table
-
-## Reading the Dictionary
-
-### Find acceptable values for a field
-
-To determine what values a field can accept, check if it references a valueSet:
-
-```sql
--- Get the valueSet for a field
-SELECT Value_set_part_ID
-FROM Parts
-WHERE Part_ID = 'Site_type';
-```
-
-```sql
--- Get all valid values for that set
-SELECT Part_ID, Label, Description
-FROM Parts
-WHERE Member_of_set_part_ID = 'Site_type_set'
-ORDER BY Sort_order;
-```
-
-### Get all columns in a table
-
-To retrieve all columns that appear in a specific table:
-
-```sql
-SELECT Part_ID, Label, SQL_data_type, Is_required
-FROM Parts
-WHERE site_present != '' -- Field appears in site table
- AND Part_type IN ('key', 'property', 'compositeKeyFirst', 'compositeKeySecond')
-ORDER BY site_order;
-```
-
-Or to see what role each field plays:
-
-```sql
-SELECT Part_ID, Label, site_present AS role_in_site
-FROM Parts
-WHERE site_present != ''
-ORDER BY site_order;
-```
-
-### Find which tables contain a specific field
-
-To see all tables where `Equipment_ID` appears:
-
-```sql
-SELECT Part_ID,
- CASE WHEN equipment_present != '' THEN 'equipment (' || equipment_present || ')' END,
- CASE WHEN metadata_present != '' THEN 'metadata (' || metadata_present || ')' END,
- CASE WHEN project_has_equipment_present != '' THEN 'project_has_equipment (' || project_has_equipment_present || ')' END
-FROM Parts
-WHERE Part_ID = 'Equipment_ID';
-```
-
-### Find all primary keys in the database
-
-```sql
-SELECT Part_ID, Label
-FROM Parts
-WHERE Part_type = 'key'
-ORDER BY Part_ID;
-```
-
-Or to see which table each key belongs to (checking all `_present` columns):
-
-```sql
-SELECT Part_ID, Label, SQL_data_type
-FROM Parts
-WHERE Part_type = 'key'
- AND Parts_present IS NULL -- Exclude Parts table metadata fields
-ORDER BY Part_ID;
-```
-
-### List all tables in the model
-
-```sql
-SELECT Part_ID, Label, Description
-FROM Parts
-WHERE Part_type = 'table'
-ORDER BY Label;
-```
-
-### Find fields that appear in multiple tables
-
-```sql
--- This query identifies fields (especially ID fields) used across tables
--- by counting non-empty _present columns
-SELECT Part_ID, Label, Part_type
-FROM Parts
-WHERE Part_type IN ('key', 'property')
- AND (
- -- Count number of tables where this field appears
- -- (You'd need to list all _present columns)
- (equipment_present != '') +
- (metadata_present != '') +
- (project_present != '') -- etc.
- ) > 1;
-```
-
-## Editing the Dictionary
-
-### Adding a New Field to an Existing Table
-
-1. Check if a field with this name already exists (search for Part_ID)
-2. If it exists and is an ID field, just update the appropriate `TableName_present` column
-3. If it doesn't exist or is a different concept, add a new row:
- - **Part_ID**: Field name (or `TableName_FieldName` if name collision)
- - **Label**: Human-readable label
- - **Description**: What the field represents
- - **Part_type**: Usually `property`, `key` for primary keys
- - **SQL_data_type**: The SQL data type
- - **Is_required**: TRUE if NOT NULL
- - **Sort_order**: Position in table definition
- - **TableName_present**: Set to `key`, `property`, `compositeKeyFirst`, or `compositeKeySecond`
-
-### Adding a New Table
-
-1. Add a table definition row:
- - **Part_ID**: Table name (lowercase with underscores)
- - **Label**: Title case version
- - **Part_type**: `table`
-
-2. Add the primary key field (or composite key fields)
-
-3. Add all property fields, setting `TableName_present` for each
-
-4. Add `TableName_present`, `TableName_required`, and `TableName_order` columns to the Parts table metadata
-
-### Handling Name Collisions
-
-If a non-ID field name appears in multiple tables (e.g., `Description`, `City`):
-
-- Create **separate Part_ID entries** with table prefixes
-- Examples: `site_City`, `contact_City`, `purpose_Description`, `project_Description`
-- Each gets its own row with `TableName_present` set for only that table
-- Labels should include both parts: "Site City", "Contact City", etc.
-
-**Exception**: ID fields (`*_ID`) always use the same Part_ID across tables, with multiple `_present` columns filled in.
-
-### Handling Hierarchical Relationships (parentKey)
-
-When a table has a hierarchical structure (parent-child within the same table):
-
-- Create a **new Part_ID** for the parent reference (don't reuse the table's primary key Part_ID)
-- Set **Part_type** to `parentKey`
-- Set **Ancestor_part_ID** to the Part_ID of the field being referenced (usually the table's primary key)
-- The field appears in the table as a regular `property` in the `TableName_present` column
-
-**Example**: For a hierarchical `site` table where sites can have parent sites:
-
-```csv
-Part_ID,Part_type,Ancestor_part_ID,site_present,...
-Site_ID,key,,key,...
-Parent_site_ID,parentKey,Site_ID,property,...
-```
-
-This models:
-
-- `Site_ID` is the primary key
-- `Parent_site_ID` is a semantically different field that references `Site_ID`
-- The hierarchical relationship is explicit via `Ancestor_part_ID`
-
-### Regenerating from SQL Schema
-
-If you update the SQL schema file, you can regenerate the dictionary:
-
-```bash
-uv run python generate_dictionary.py
-```
-
-This will create `dictionary_new.csv`. Review it and replace `dictionary.csv` if correct.
-
-## Naming Conventions
-
-The following naming rules apply:
-
-### Part_IDs
-
-- **Tables**: Singular nouns, lowercase, words separated by underscores
- - Examples: `watershed`, `sampling_points`, `equipment_model`, `weather_condition`
-
-- **Primary Keys**: `[Table_name]_ID` with capitalized first letters
- - Examples: `Watershed_ID`, `Sampling_point_ID`, `Equipment_model_ID`
- - Rule: These appear in multiple tables with the same Part_ID
-
-- **Foreign Keys**: Use the exact same Part_ID as the referenced primary key
- - Example: `site` table references `watershed` via `Watershed_ID`
- - The dictionary shows this with `site_present = property` and `watershed_present = key`
-
-- **Regular Fields**: Descriptive names, mixed case with underscores
- - Examples: `Site_name`, `Street_number`, `Latitude_GPS`, `Purchase_date`
-
-- **Table-Prefixed Fields**: When non-ID names collide across tables
- - Format: `tablename_FieldName`
- - Examples: `site_City`, `contact_City`, `purpose_Description`
-
-### Junction Tables (Many-to-Many)
-
-- Format: `[table1]_has_[table2]` where both tables are singular
-- Examples: `project_has_equipment`, `project_has_contact`, `equipment_model_has_procedures`
-- Primary keys are composite (two `compositeKey*` fields)
-
-### Value Sets and Members
-
-- **Value Sets**: descriptive name + `_set` suffix
- - Part_type: `valueSet`
- - Examples: `Part_type_set`, `Site_type_set`
-
-- **Members**: short, descriptive identifiers
- - Part_type: `valueSetMember`
- - Member_of_set_part_ID: points to the set
- - Examples: `table`, `key`, `property`, `valueSet`, `valueSetMember`
-
-## Practical Examples
-
-### Example 1: Understanding Equipment_ID
-
-The `Equipment_ID` field has ONE row in the dictionary:
-
-| Column | Value |
-|--------|-------|
-| Part_ID | Equipment_ID |
-| Label | Equipment ID |
-| Description | Identifier for equipment, also used in 2 other table(s) |
-| Part_type | key |
-| SQL_data_type | int |
-| equipment_present | key |
-| metadata_present | property |
-| project_has_equipment_present | compositeKeySecond |
-| *(all other _present columns)* | *(empty)* |
-
-This tells us:
-
-- Equipment_ID is a primary key (`Part_type = key`)
-- It's the primary key in the `equipment` table
-- It appears as a foreign key in `metadata`
-- It's part of a composite key in `project_has_equipment`
-
-### Example 2: Understanding Description Fields
-
-Because `Description` appears in multiple tables with different meanings, there are MULTIPLE rows:
-
-| Part_ID | Label | purpose_present | project_present | site_present |
-|---------|-------|-----------------|-----------------|--------------|
-| Description | Description | | | |
-| purpose_Description | Purpose Description | property | | |
-| project_Description | Project Description | | property | |
-| site_Description | Site Description | | | property |
-
-Note: The plain `Description` is the Parts table's own Description field (with `Parts_present = property`).
-
-### Example 3: Adding a New Field
-
-To add a `Latitude` field to the `site` table:
-
-1. Check if `Latitude` already exists (it doesn't, but `Latitude_GPS` does in `sampling_points`)
-2. Since the names are different, add a new row:
-
- ```csv
- Part_ID,Label,Description,Part_type,SQL_data_type,Is_required,Sort_order,site_present
- Latitude,Latitude,Latitude coordinate of site,property,real,False,15,property
- ```
-
-3. Save and validate the dictionary
-
-### Example 4: Adding an Existing Field to a New Table
-
-To add `Contact_ID` to a new `project_contact_history` table:
-
-1. Find the existing `Contact_ID` row
-2. Add a new column `project_contact_history_present`
-3. Set the value to `property` (or `compositeKeyFirst`/`compositeKeySecond` if it's part of the primary key)
-4. No need to create a new row—just update the existing one!
-
-## Tips for Working with the Dictionary
-
-1. **Always search before adding**: Use your editor's find function to check if a Part_ID exists
-2. **ID fields are shared**: If you see `_ID` at the end, it's likely used across multiple tables
-3. **Use table prefixes for collisions**: When the same field name means different things in different tables
-4. **Validate after changes**: Run `validate_dictionary.py` to check for duplicates and issues
-5. **Keep it synchronized**: If you edit the SQL schema, regenerate the dictionary and merge changes carefully
-6. **Document value sets**: When adding enumerations, create both the valueSet and all valueSetMembers
-
-## Self-Reference: The Parts Table Describes Itself
-
-The dictionary includes rows that describe its own structure. For example:
-
-- `Part_ID` (the field) has `Parts_present = key`
-- `Label` has `Parts_present = property`
-- `equipment_present` (one of the many `_present` columns) has `Parts_present = property`
-
-This self-referential structure means the dictionary is "bootstrapped"—it fully describes itself using its own format.
diff --git a/docs/hooks/call_orchestrator.py b/docs/hooks/call_orchestrator.py
new file mode 100644
index 0000000..2a92ab6
--- /dev/null
+++ b/docs/hooks/call_orchestrator.py
@@ -0,0 +1,52 @@
+import os
+from pathlib import Path
+from datetime import datetime
+from importlib.metadata import version
+
+package_version = version("open-dateaubase")
+TARGET_DBS = ["mssql"]
+
+
+def on_pre_build(config):
+ """
+ MkDocs hook that runs before build process.
+ Calls the orchestrator script to generate all documentation components.
+ """
+ # Define paths
+ project_root = Path(config["config_file_path"]).parent
+ json_path = project_root / "src" / "dictionary.json"
+ docs_dir = Path(config["docs_dir"])
+ output_path = docs_dir / "reference"
+ sql_path = project_root / "sql_generation_scripts"
+ assets_path = docs_dir / "assets"
+
+ # Call orchestrator script
+ scripts_dir = project_root / "scripts"
+ orchestrator = scripts_dir / "orchestrate_docs.py"
+
+ # Build command
+ cmd = [
+ "uv",
+ "run",
+ "python",
+ str(orchestrator),
+ str(json_path),
+ str(output_path),
+ str(sql_path),
+ str(assets_path),
+ ]
+
+ # Add target databases
+ cmd.extend(TARGET_DBS)
+
+ print(f"Running documentation generation orchestrator...")
+ print(f"Command: {' '.join(cmd)}")
+
+ # Run orchestrator
+ result = os.system(" ".join(cmd))
+
+ if result != 0:
+ print("Error: Documentation generation failed!")
+ return
+
+ print("Documentation generation completed successfully!")
diff --git a/docs/hooks/generate_docs.py b/docs/hooks/generate_docs.py
deleted file mode 100644
index 94e88f4..0000000
--- a/docs/hooks/generate_docs.py
+++ /dev/null
@@ -1,571 +0,0 @@
-import csv
-import os
-from pathlib import Path
-from datetime import datetime
-from importlib.metadata import version
-
-package_version = version("open-dateaubase")
-TARGET_DBS = ["mssql"]
-
-def on_pre_build(config):
- """
- MkDocs hook that runs before the build process.
- Reads Parts_table.csv and generates reference.md
- """
- # Define paths
- project_root = Path(config['config_file_path']).parent
- csv_path = project_root / 'src/dictionary.csv'
- docs_dir = Path(config['docs_dir'])
- output_path = docs_dir / 'reference'
- sql_path = project_root / 'sql_generation_scripts'
-
- # Read and parse CSV
- parts_data = parse_parts_table(csv_path)
-
- # Generate markdown
- image = generate_schema_image(parts_data)
- tables = generate_tables_markdown(parts_data)
- value_sets = generate_value_sets_markdown(parts_data)
-
- # Generate SQL schema(s)
- generate_sql_schemas(parts_data, sql_path, TARGET_DBS)
-
- # Write to file
- # TODO: Write the schema image to a file in the docs/assets directory
- (output_path / "tables.md").write_text(tables, encoding='utf-8')
- (output_path / "valuesets.md").write_text(value_sets, encoding='utf-8')
- print(f"Generated {output_path}")
-
-
-
-def generate_sql_schemas(parts_data, path, db_list):
- for target_db in db_list:
- sql_schema = generate_sql_schema(parts_data, target_db=target_db)
- version_str = package_version
- filename = f"v{version_str}_as-designed_{target_db}.sql"
- (path / filename).write_text(sql_schema, encoding='utf-8')
- print(f"Generated SQL schema for {target_db} at {path / filename}")
-
-
-def generate_schema_image(data):
- return "Schema image generation not yet implemented!"
-
-def parse_parts_table(csv_path):
- """
- Parse the Parts_table.csv into a structured format.
- Returns a dict organized by tables and value sets.
-
- NEW FORMAT: Uses TableName_present columns instead of Table_part_ID
- """
- data = {
- 'tables': {},
- 'value_sets': {},
- 'metadata': {},
- 'id_field_locations': {} # Track where ID fields appear for FK detection
- }
-
- with open(csv_path, 'r', encoding='utf-8') as f:
- reader = csv.DictReader(f)
- # Read all rows to ensure fieldnames is populated
- rows = list(reader)
- fieldnames = reader.fieldnames or []
-
- # Find all *_present columns (excluding Parts_present which is self-reference)
- present_columns = [col for col in fieldnames
- if col.endswith('_present') and col != 'Parts_present']
-
- # Find all *_order columns for sorting
- order_columns = {col.replace('_order', ''): col
- for col in fieldnames if col.endswith('_order')}
-
- for row in rows:
- part_id = row['Part_ID']
- part_type = row['Part_type']
-
- if part_type == 'table':
- # Skip Parts table self-reference
- if part_id != 'Parts':
- data['tables'][part_id] = {
- 'label': row['Label'],
- 'description': row['Description'],
- 'fields': []
- }
-
- elif part_type in ['key', 'property', 'compositeKeyFirst', 'compositeKeySecond', 'parentKey']:
- # Check all *_present columns to see which tables this field appears in
- for present_col in present_columns:
- table_id = present_col.replace('_present', '')
- role = row.get(present_col, '').strip()
-
- if role: # Field appears in this table
- # Ensure table exists
- if table_id not in data['tables']:
- continue
-
- # Get sort order for this table
- order_col = order_columns.get(table_id, '')
- sort_order = int(row.get(order_col, '999')) if row.get(order_col) else 999
-
- # Track ID field locations for FK detection
- if part_id.endswith('_ID'):
- if part_id not in data['id_field_locations']:
- data['id_field_locations'][part_id] = {}
- data['id_field_locations'][part_id][table_id] = role
-
- # For parentKey type, fk_to comes from Ancestor_part_ID
- fk_to = ''
- if part_type == 'parentKey':
- fk_to = row.get('Ancestor_part_ID', '')
-
- field_info = {
- 'part_id': part_id,
- 'label': row['Label'],
- 'description': row['Description'],
- 'part_type': role, # Use role from _present column (key, property, etc.)
- 'sql_data_type': row['SQL_data_type'],
- 'is_required': row['Is_required'] == 'True',
- 'default_value': row['Default_value'],
- 'fk_to': fk_to, # Set for parentKey, otherwise determined later from ID patterns
- 'value_set': row['Value_set_part_ID'],
- 'sort_order': sort_order
- }
- data['tables'][table_id]['fields'].append(field_info)
-
- elif part_type == 'valueSet':
- data['value_sets'][part_id] = {
- 'label': row['Label'],
- 'description': row['Description'],
- 'members': []
- }
-
- elif part_type == 'valueSetMember':
- value_set_id = row['Member_of_set_part_ID']
- if value_set_id and value_set_id in data['value_sets']:
- member_info = {
- 'part_id': part_id,
- 'label': row['Label'],
- 'description': row['Description'],
- 'sort_order': int(row['Sort_order']) if row['Sort_order'] else 999
- }
- data['value_sets'][value_set_id]['members'].append(member_info)
-
- # Derive foreign key relationships from ID field patterns
- # Two cases:
- # 1. An ID field that appears as 'key' in one table and 'property' in others is a FK
- # 2. A field ending in _ID that references another table's primary key (e.g., TestTable_Parent_ID -> TestTable_ID)
-
- for id_field, locations in data['id_field_locations'].items():
- # Find the table where this is the primary key
- pk_table = None
- for table_id, role in locations.items():
- if role == 'key':
- pk_table = table_id
- break
-
- if pk_table:
- # Mark all other occurrences as foreign keys
- for table_id, role in locations.items():
- if table_id != pk_table and role == 'property':
- # Find the field in this table and set fk_to
- for field in data['tables'][table_id]['fields']:
- if field['part_id'] == id_field:
- field['fk_to'] = id_field
-
- # Also detect FK fields that reference other tables by name pattern
- # E.g., TestTable_Parent_ID should reference TestTable_ID
- for table_id, table_info in data['tables'].items():
- for field in table_info['fields']:
- if field['part_id'].endswith('_ID') and not field['fk_to']:
- # Try to find a matching primary key
- # Extract potential table name from field name
- # E.g., "TestTable_Parent_ID" -> look for "TestTable_ID"
- parts = field['part_id'].rsplit('_', 1) # Split from right to get [..., 'ID']
- if len(parts) == 2:
- prefix = parts[0] # E.g., "TestTable_Parent"
- # Look for any table whose PK this might reference
- # Check if prefix ends with a table name
- for potential_table in data['tables'].keys():
- if prefix.startswith(potential_table + '_'):
- # This might be a FK to potential_table
- target_pk = potential_table + '_ID'
- if target_pk in data['id_field_locations']:
- field['fk_to'] = target_pk
- break
-
- # Sort fields and members by sort_order
- for table in data['tables'].values():
- table['fields'].sort(key=lambda x: x['sort_order'])
-
- for value_set in data['value_sets'].values():
- value_set['members'].sort(key=lambda x: x['sort_order'])
-
- return data
-
-
-def generate_tables_markdown(data):
- """
- Generate markdown documentation from parsed data.
- """
- md = ["# Database Tables\n"]
- md.append("This documentation is auto-generated from the Parts metadata table.\n")
-
- # Generate table documentation
- md.append("\n## Tables\n")
-
- for table_id, table_info in sorted(data['tables'].items()):
- # Anchor as invisible span, table name as regular heading
- md.append(f'\n\n')
- md.append(f"### {table_info['label']}\n")
- md.append(f"{table_info['description']}\n")
-
- if table_info['fields']:
- md.append("\n#### Fields\n")
- md.append("| Field | SQL Type | Value Set | Required | Description | Constraints |")
- md.append("|-------|----------|-----------|----------|-------------|-------------|")
-
- for field in table_info['fields']:
- field_name = field['label']
- field_id = field['part_id']
-
- # SQL Type column
- sql_type = field['sql_data_type'] if field['sql_data_type'] else '-'
- if field['part_type'] in ['key', 'compositeKeyFirst', 'compositeKeySecond']:
- if field['part_type'] == 'key':
- sql_type += ' **(PK)**'
- elif field['part_type'] == "compositeKeyFirst":
- sql_type += ' **(CK-1)**'
- elif field['part_type'] == "compositeKeySecond":
- sql_type += ' **(CK-2)**'
- else:
- raise ValueError(f"Found unknown part type: {field['part_type']}. Correct the parts table OR update the documentation generation code.")
-
- # Value Set column - link to the value set definition
- value_set = f"[{field['value_set']}](valuesets.md#{field['value_set']})" if field['value_set'] else '-'
-
- required = '✓' if field['is_required'] else ''
-
- # Anchor the description with the Part_ID
- description = f'{field["description"]}'
-
- # Build constraints column
- constraints = []
- if field['fk_to']:
- # Link to the FK target field
- constraints.append(f"FK → [{field['fk_to']}](#{field['fk_to']})")
- if field['default_value']:
- constraints.append(f"Default: `{field['default_value']}`")
-
- constraints_str = ' '.join(constraints) if constraints else '-'
-
- md.append(f"| {field_name} | {sql_type} | {value_set} | {required} | {description} | {constraints_str} |")
-
- return '\n'.join(md)
-
-
-def generate_value_sets_markdown(data):
- """
- Generate value set documentation with proper anchoring.
- """
- md = ["# Value Sets\n"]
- md.append("Controlled vocabularies used throughout the database.\n")
-
- if data['value_sets']:
- for value_set_id, value_set_info in sorted(data['value_sets'].items()):
- # Anchor as invisible span, value set name as regular heading
- md.append(f'\n\n')
- md.append(f"## {value_set_info['label']}\n")
- md.append(f"{value_set_info['description']}\n")
-
- if value_set_info['members']:
- md.append("\n| Value | Description |")
- md.append("|-------|-------------|")
-
- for member in value_set_info['members']:
- member_id = member['part_id']
- # Anchor each member with its Part_ID
- md.append(f"| `{member_id}` | {member['description']} |")
- else:
- md.append("No value sets currently appear in the dictionary.")
-
- return '\n'.join(md)
-
-def generate_sql_schema(data, target_db='mssql', include_timestamp=True):
- """
- Generate SQL CREATE statements from parsed metadata.
-
- Args:
- data: Parsed parts table data
- target_db: Target database flavor ('mssql', 'postgres', 'mysql' - future)
- include_timestamp: Whether to include generation timestamp (default: True)
-
- Returns:
- SQL DDL as a string
-
- Raises:
- ValueError: If circular foreign key dependencies detected
- """
- # Validate no circular FK dependencies
- validate_no_circular_fks(data)
-
- sql = ["-- Auto-generated SQL schema from Parts metadata table"]
- sql.append(f"-- Target database: {target_db.upper()}")
- if include_timestamp:
- sql.append(f"-- Generated: {datetime.now().isoformat()}")
- sql.append("\n")
-
- # Get DB-specific config
- db_config = get_db_config(target_db)
-
- # First pass: Create all tables without foreign keys
- for table_id, table_info in sorted(data['tables'].items()):
- sql.append(f"\n-- {table_info['description']}")
- sql.append(f"CREATE TABLE {db_config['quote'](table_id)} (")
-
- field_definitions = []
- pk_fields = []
-
- for field in table_info['fields']:
- field_def = generate_field_definition(field, data, db_config)
- field_definitions.append(field_def)
-
- # Track primary key fields
- if field['part_type'] in ['key', 'compositeKeyFirst', 'compositeKeySecond']:
- field_name = extract_field_name(field['part_id'])
- pk_fields.append(f"{db_config['quote'](field_name)}")
-
- # Add primary key constraint
- if pk_fields:
- pk_name = "PK_" + table_id
- pk_constraint = f" CONSTRAINT {db_config['quote'](pk_name)} PRIMARY KEY ({', '.join(pk_fields)})"
- field_definitions.append(pk_constraint)
-
- sql.append(",\n".join(field_definitions))
- sql.append(");\n")
-
- # Second pass: Add foreign key constraints
- sql.append("\n-- Foreign Key Constraints\n")
- for table_id, table_info in sorted(data['tables'].items()):
- for field in table_info['fields']:
- if field['fk_to']:
- fk_sql = generate_foreign_key_constraint(table_id, field, db_config)
- if fk_sql:
- sql.append(fk_sql)
-
- return '\n'.join(sql)
-
-
-def get_db_config(target_db):
- """
- Get database-specific configuration.
-
- Args:
- target_db: Database flavor string
-
- Returns:
- Dict with DB-specific settings
- """
- configs = {
- 'mssql': {
- 'quote_char': '[',
- 'quote_char_end': ']',
- 'type_mappings': {
- 'nvarchar': 'nvarchar',
- 'ntext': 'nvarchar(max)', # ntext deprecated in modern MSSQL
- 'int': 'int',
- 'float': 'float',
- 'real': 'real',
- 'numeric': 'numeric',
- 'bit': 'bit'
- },
- 'supports_check_constraints': True,
- 'supports_deferred_constraints': False
- },
- # Future: postgres, mysql, sqlite configs
- }
-
- if target_db not in configs:
- raise ValueError(f"Unsupported database: {target_db}. Supported: {list(configs.keys())}")
-
- config = configs[target_db]
-
- # Add convenience method for quoting identifiers
- if config['quote_char_end']:
- config['quote'] = lambda name: f"{config['quote_char']}{name}{config['quote_char_end']}"
- else:
- config['quote'] = lambda name: f"{config['quote_char']}{name}{config['quote_char']}"
-
- return config
-
-
-def extract_field_name(part_id):
- """
- Extract field name from Part_ID.
-
- NEW FORMAT handling:
- - ID fields (e.g., 'Equipment_ID', 'Project_ID'): Use as-is (these are the actual SQL field names)
- - Table-prefixed fields (e.g., 'site_City', 'purpose_Description'): Remove table prefix
- - Non-prefixed fields: Use as-is
-
- Args:
- part_id: Part_ID from dictionary
-
- Returns:
- Field name to use in SQL
- """
- # ID fields are used as-is in SQL
- if part_id.endswith('_ID'):
- return part_id
-
- # Table-prefixed non-ID fields: remove prefix
- # Format is lowercase_table_MixedCaseField (e.g., 'site_City', 'contact_City')
- if '_' in part_id:
- # Check if first part looks like a table name (lowercase)
- parts = part_id.split('_', 1)
- if len(parts) == 2 and parts[0].islower():
- # This is likely a table-prefixed field, remove prefix
- return parts[1]
-
- # Otherwise use as-is
- return part_id
-
-
-def validate_no_circular_fks(data):
- """
- Check for circular foreign key dependencies between tables.
-
- Args:
- data: Parsed parts table data
-
- Raises:
- ValueError: If circular FK dependencies found
- """
- # Build adjacency list of FK relationships
- fk_graph = {table_id: set() for table_id in data['tables']}
-
- for table_id, table_info in data['tables'].items():
- for field in table_info['fields']:
- if field['fk_to'] and '_' in field['fk_to']:
- target_table = field['fk_to'].split('_', 1)[0]
- if target_table in fk_graph:
- fk_graph[table_id].add(target_table)
-
- # Check for bidirectional relationships (A->B and B->A)
- circular_deps = []
- for table_a, targets in fk_graph.items():
- for table_b in targets:
- if table_b == table_a:
- # self-referential FKs are allowed
- continue
- if table_a in fk_graph.get(table_b, set()):
- # Found circular dependency
- pair = tuple(sorted([table_a, table_b]))
- if pair not in circular_deps:
- circular_deps.append(pair)
-
- if circular_deps:
- error_msg = "Circular foreign key dependencies detected:\n"
- for table_a, table_b in circular_deps:
- error_msg += f" - {table_a} ↔ {table_b}\n"
- error_msg += "\nEach pair of tables has FKs pointing to each other, which creates ambiguity in table creation order."
- raise ValueError(error_msg)
-
-
-def generate_field_definition(field, data, db_config):
- """
- Generate SQL field definition with constraints.
-
- Args:
- field: Field metadata dict
- data: Full parsed data (for value set lookups)
- db_config: Database-specific configuration
-
- Returns:
- SQL field definition string
- """
- field_name = extract_field_name(field['part_id'])
-
- quote = db_config['quote']
-
- parts = [f" {quote(field_name)}"]
-
- # Data type with mapping
- sql_type = field['sql_data_type'] if field['sql_data_type'] else 'nvarchar(255)'
- # Apply type mapping for target DB
- base_type = sql_type.split('(')[0] # Extract base type (e.g., 'nvarchar' from 'nvarchar(255)')
- if base_type in db_config['type_mappings']:
- # Preserve parameters if they exist
- if '(' in sql_type:
- params = sql_type[sql_type.index('('):]
- sql_type = db_config['type_mappings'][base_type].split('(')[0] + params
- else:
- sql_type = db_config['type_mappings'][base_type]
-
- parts.append(sql_type)
-
- # NULL constraint
- if field['is_required']:
- parts.append("NOT NULL")
- else:
- parts.append("NULL")
-
- # Default value
- if field['default_value']:
- default_val = field['default_value']
- # Handle boolean defaults
- if default_val in ['True', 'False']:
- default_val = '1' if default_val == 'True' else '0'
- # Handle numeric vs string defaults
- if field['sql_data_type'] and field['sql_data_type'].split('(')[0] in ['int', 'float', 'real', 'numeric', 'bit']:
- parts.append(f"DEFAULT {default_val}")
- else:
- parts.append(f"DEFAULT '{default_val}'")
-
- # Note: Value set CHECK constraints removed per requirement #3
- # Future: could add back conditionally based on target_db config
-
- return ' '.join(parts)
-
-
-def generate_foreign_key_constraint(table_id, field, db_config):
- """
- Generate ALTER TABLE statement for foreign key.
-
- NEW FORMAT: fk_to is the Part_ID of the target field (e.g., 'TestTable_ID')
- We need to find which table has this field as a primary key.
-
- Args:
- table_id: Source table ID
- field: Field metadata with FK reference
- db_config: Database-specific configuration
-
- Returns:
- SQL ALTER TABLE statement or None
- """
- if not field['fk_to']:
- return None
-
- # fk_to is the Part_ID of the target (e.g., 'TestTable_ID')
- # For ID fields, this is the actual field name
- # We need to determine the target table from the naming
- fk_target = field['fk_to']
- source_field = extract_field_name(field['part_id'])
-
- # For ID fields like 'TestTable_ID', the target table is the part before '_ID'
- if fk_target.endswith('_ID'):
- target_field = fk_target # e.g., 'TestTable_ID'
- # Extract table name (everything before '_ID')
- target_table = fk_target[:-3] # Remove '_ID' to get 'TestTable'
- else:
- # Non-ID FK (shouldn't happen in new format, but fallback)
- return None
-
- quote = db_config['quote']
- constraint_name = f"FK_{table_id}_{source_field}"
-
- sql = f"""ALTER TABLE {quote(table_id)}
- ADD CONSTRAINT {quote(constraint_name)}
- FOREIGN KEY ({quote(source_field)})
- REFERENCES {quote(target_table)} ({quote(target_field)});
-"""
-
- return sql
\ No newline at end of file
diff --git a/docs/reference/erd.md b/docs/reference/erd.md
new file mode 100644
index 0000000..809f346
--- /dev/null
+++ b/docs/reference/erd.md
@@ -0,0 +1,36 @@
+# Entity Relationship Diagram (ERD)
+
+This interactive diagram shows all tables and their relationships in datEAUbase schema.
+
+## Interactive ERD
+
+The interactive version allows you to:
+- 🖱️ **Drag tables** to rearrange layout
+- 🔍 **Zoom in/out** for better visibility
+- 📐 **Auto-layout** to reorganize tables automatically
+- 💾 **Export** diagram as PNG
+
+
+
+[Open in new window](../assets/erd_interactive.html){: target="_blank" .md-button .md-button--primary}
+
+## Legend
+
+### Field Markers
+- **PK** badge: Primary Key - Unique identifier for each record
+- **FK** badge: Foreign Key - Reference to another table's primary key
+- **\*** Required field (NOT NULL)
+
+### Relationship Notation
+Relationships use standard crow's foot notation:
+- **Single line (|)**: "One" side of relationship
+- **Crow's foot (⟨)**: "Many" side of relationship
+
+**Relationship Types:**
+- **One-to-One**: Single line on both ends (e.g., watershed ↔ hydrological_characteristics)
+- **One-to-Many**: Crow's foot on child side, single line on parent (e.g., site ↔ sampling_points)
+- **Many-to-Many**: Crow's foot on both ends (via junction tables like project_has_contact)
+
+## Table Count
+
+The current schema contains **23** tables with **27** relationships.
diff --git a/docs/reference/schema.md b/docs/reference/schema.md
index e3ca853..71a906b 100644
--- a/docs/reference/schema.md
+++ b/docs/reference/schema.md
@@ -1,170 +1,77 @@
-Schema generation not yet implemented!
+# datEAUbase Schema Documentation.
+## 1. Overview
-# 📘 datEAUbase Schema Documentation (AS-IS 2025)
+### 1.1 Purpose
-> **Version :** 2025-09-12
-> **Auteur :** Lala (documentation interne – datEAUbase)
-> **Source :** Schéma Lucidchart “datEAUbase_AS-IS_2025.pdf”
-> **Contexte :** Base de données centrale du SI pilEAUte / datEAUbase, interconnectée avec FactoryTalk, API Python et MQTT pour la gestion, l’ingestion et la validation de données hydrologiques, environnementales et opérationnelles.
+datEAUbase is a relational database designed to:
----
-
-## 1. Conventions et domaines fonctionnels
-
-| Couleur | Domaine | Description |
-|----------|----------|-------------|
-| 🟩 Vert | **Géospatiale et environnement** | Sites, bassins versants, caractéristiques urbaines et hydrologiques |
-| 🟧 Orange | **Métadonnées et valeurs** | Données scientifiques et de mesure |
-| 🟪 Rose | **Instrumentation & procédures** | Équipements, modèles, paramètres et procédures associées |
-| 🟨 Jaune | **Projets & liaisons** | Relations projet-équipement-contact-points |
-| 🟦 Bleu | **Référentiels de support** | Unités, statuts, types, sources et opérations |
-| ⚙️ Gris | **Systèmes & contrôle** | Boucles de régulation, synchronisation, historisation |
-
----
-
-## 2. Structure générale et dépendances
-
-```text
-value ─┬──▶ metadata ─┬──▶ parameter
- │ ├──▶ equipment
- │ ├──▶ project
- │ ├──▶ sampling_points ─▶ site ─▶ watershed
- │ ├──▶ purpose
- │ ├──▶ condition (weather_condition)
- │ └──▶ contact
- │
- └──▶ comment
-```
-
-Relations secondaires :
-- `equipment_model` ←→ `parameter` via `equipment_model_has_specification`
-- `equipment_model` ←→ `procedures` via `equipment_model_has_procedures`
-- `parameter` ←→ `procedures` via `parameter_has_procedures`
-- `project` ←→ (`equipment`, `contact`, `sampling_points`) via tables d’association
-- `source`, `operations`, `type_data`, `status` : nouveaux référentiels pour ingestion et contrôle qualité
-- `control_loop` : lie `measurement`, `controller` et `actuator`
-
----
-
-## 3. Détail des domaines
-
-### 3.1 Métadonnées et valeurs
-
-| Table | Description | Clés | Relations |
-|-------|--------------|------|------------|
-| **value** | Données brutes et validées (mesures, résultats d’expériences, etc.) | `Value_ID (PK)` | `Metadata_ID → metadata`, `Comment_ID → comments` |
-| **metadata** | Contexte complet d’une valeur : paramètre, unité, site, équipement, projet, condition météo, etc. | `Metadata_ID (PK)` | FK vers `parameter`, `unit`, `equipment`, `contact`, `project`, `sampling_points`, `weather_condition`, `purpose`, `type_data`, `source`, `status` |
-| **purpose** | Objectif de la donnée (ex. suivi, calibration, simulation) | `Purpose_ID (PK)` | 1-N avec `metadata` |
-| **unit** | Référentiel d’unités (mg/L, m³/s, °C…) | `Unit_ID (PK)` | Référencée par `parameter`, `metadata`, `equipment_model_has_specification` |
-| **comments** | Notes descriptives ou remarques sur une valeur | `Comment_ID (PK)` | 1-N avec `value` |
-| **status** | Référentiel qualité (raw, flagged, validated, replaced, rejected) | `Status_ID (PK)` | FK depuis `metadata` ou `value` |
-| **type_data** | Catégorisation du type d’enregistrement (measurement, laboratory, control_signal…) | `Type_ID (PK)` | FK depuis `metadata` |
-
----
-
-### 3.2 Instrumentation et procédures
+- Centralize water quality data from multiple sources (online sensors, laboratories, manual observations)
+- Document measurements with comprehensive metadata (who, what, where, when, how, why)
+- Ensure data traceability from physical sensor to final storage
+- Keep track of data as it gets processed to improve its quality.
+- Maintain historical records of equipment usage, research projects, and site evolution
-| Table | Description | Clés | Relations |
-|-------|--------------|------|------------|
-| **equipment_model** | Modèle d’équipement (méthode, fonctions, fabricant, manuels) | `Equipment_model_ID (PK)` | Liée à `equipment`, `parameter`, `procedures` |
-| **equipment** | Équipement individuel (identifiant, numéro de série, propriétaire, date d’achat, mise en service) | `Equipment_ID (PK)` | FK `Equipment_model_ID` |
-| **parameter** | Variable mesurée (température, NH₄, débit, etc.) avec unité et description | `Parameter_ID (PK)` | FK `Unit_ID` |
-| **procedures** | Procédures opératoires ou de maintenance | `Procedure_ID (PK)` | liées à `parameter` et `equipment_model` |
-| **equipment_model_has_specification** | Table de correspondance (remplace l’ancienne `equipment_model_has_parameter`) | `Equipment_model_ID`, `Parameter_ID` (CK) | inclut champs `Range_min`, `Range_max`, `Resolution`, `Unit_ID` |
-| **parameter_has_procedures** | Relation N-N entre paramètres et procédures | `Parameter_ID`, `Procedure_ID` (CK) |
-| **equipment_model_has_procedures** | Relation N-N entre modèles et procédures | `Equipment_model_ID`, `Procedure_ID` (CK) |
+## 2. Functional Domains
----
-
-### 3.3 Référentiels d’ingestion et d’opérations
-
-| Table | Description | Clés | Relations |
-|-------|--------------|------|------------|
-| **source** | Provenance du signal ou des fichiers (MQTT, API, OPC, CSV, manuel) | `Source_ID (PK)` | FK depuis `metadata` |
-| **operations** | Seuils et paramètres opérationnels (NO3_min, NO3_max, alarmes) | `Operation_ID (PK)` | reliée à `source` |
-| **syncdiagrams**, **maxtimestamp** | Tables internes de synchronisation et historique de timestamps | `AK`, `PK` divers | utilisées pour ingestion automatisée |
-| **holiday** | Gestion des jours fériés pour planification | `Message_ID (PK)` | sans dépendances externes |
-
----
-
-### 3.4 Domaine géospatial et environnemental
-
-| Table | Description | Clés | Relations |
-|-------|--------------|------|------------|
-| **site** | Localisation physique d’un échantillonnage (adresse, ville, pays, type) | `Site_ID (PK)` | FK `Watershed_ID` |
-| **sampling_points** | Points d’échantillonnage liés à un site, avec GPS et photos | `Sampling_point_ID (PK)` | FK `Site_ID` |
-| **watershed** | Bassin versant associé au site | `Watershed_ID (PK)` | 1-N vers `site` |
-| **urban_characteristics** | Surfaces urbaines, industrielles, agricoles, etc. | `Watershed_ID (FK)` | 1-1 avec `watershed` |
-| **hydrological_characteristics** | Données hydrologiques détaillées (zones humides, forêts, prairies) | `Watershed_ID (FK)` | 1-1 avec `watershed` |
-| **weather_condition** | Conditions météorologiques observées | `Condition_ID (PK)` | FK depuis `metadata` |
-
----
+The database is organized into several color-coded functional domains:
-### 3.5 Projets et associations
-
-| Table | Description | Clés | Relations |
-|-------|--------------|------|------------|
-| **project** | Projet de recherche ou d’exploitation lié à des sites et instruments | `Project_ID (PK)` | central |
-| **project_has_equipment** | N-N entre projet et équipement | `(Project_ID, Equipment_ID)` (CK) |
-| **project_has_contact** | N-N entre projet et contact | `(Project_ID, Contact_ID)` (CK) |
-| **project_has_sampling_points** | N-N entre projet et points d’échantillonnage | `(Project_ID, Sampling_point_ID)` (CK) |
-| **equipment_has_sampling_points** | N-N entre équipement et points d’échantillonnage | `(Equipment_ID, Sampling_point_ID)` (CK) |
-| **contact** | Informations sur les personnes et organisations liées aux projets | `Contact_ID (PK)` | partagée entre projets, métadonnées, équipement_model |
+| Domain | Color | Tables | Description |
+|--------|-------|--------|-------------|
+| **Metadata and Values** 3 | Core measurement data and context |
+| **Instrumentation & Procedures**| 9 | Equipment, models, parameters, and SOPs |
+| **Geospatial & Environmental** | 5 | Sites, watersheds, land use characteristics |
+| **Projects & Associations** | 8 | Research projects and contacts |
---
-### 3.6 Contrôle, automatisation et validation
+## 3. Database Structure
-| Table | Description | Clés | Relations |
-|-------|--------------|------|------------|
-| **control_loop** | Décrit les boucles de régulation automatiques (capteur-contrôleur-actionneur) | `Measurement (FK)`, `Controller (FK)`, `Actuator (FK)` | intégrée avec les flux en temps réel |
-| **value_before_12_04_2025**, **value_test_hedi** | Tables d’historisation ou de test (migration & validation) | `Value_ID (PK)` | même structure que `value` |
+### 3.1 Core Data Flow
----
+```text
+value ──▶ metadata (Central Hub linking the value with their specific context)
+ │ │
+ │ ├──▶ parameter ──▶ unit
+ │ ├──▶ equipment ──▶ equipment_model
+ │ ├──▶ sampling_points ──▶ site ──▶ watershed
+ │ ├──▶ contact
+ │ ├──▶ project
+ │ ├──▶ purpose
+ │ ├──▶ procedures
+ │ ├──▶ weather_condition
+ │ ├──▶ type_data
+ │ ├──▶ status
+ │ ├──▶ operations
+ │ └──▶ source
+ │
+ └──▶ comments
+```
-## 4. Contraintes clés et intégrité référentielle
+### 3.2 Key Relationships
-- **PK :** toutes les tables principales utilisent un `INT` auto-increment (SQL Server IDENTITY).
-- **FK :** contraints en cascade `ON UPDATE CASCADE` / `ON DELETE NO ACTION` pour la plupart.
-- **CK :** relations N-N avec `compositeKeyFirst`, `compositeKeySecond`.
-- **Indexes :** `IX_Metadata_Parameter`, `IX_Value_Timestamp`, `IX_Site_Watershed`.
-- **FK notables :**
- - `value.Metadata_ID → metadata.Metadata_ID`
- - `metadata.Parameter_ID → parameter.Parameter_ID`
- - `metadata.Equipment_ID → equipment.Equipment_ID`
- - `equipment.Equipment_model_ID → equipment_model.Equipment_model_ID`
- - `site.Watershed_ID → watershed.Watershed_ID`
+
+**One-to-One (1:1)**
+- watershed ↔ urban_characteristics
+- watershed ↔ hydrological_characteristics
----
+**One-to-Many (1:N)**
+- equipment_model → equipment
+- site → sampling_points
+- project → metadata
-## 5. Interconnexions externes (AS-IS)
-
-| Source | Type | Description |
-|--------|------|-------------|
-| **FactoryTalk Historian** | OPC/CSV | Extraction automatique vers table `source` |
-| **Python API (pilEAUte)** | REST | Insertion contrôlée vers `value` et `metadata` |
-| **MQTT Broker** | Temps réel | Publication de `value` vers `control_loop` |
-| **Grafana** | Visualisation | Lecture sur `value`, `metadata`, `status` |
-| **Power BI / CSV Export** | Reporting | Requêtes consolidées multi-projets |
+**Many-to-Many (M:N) via junction tables**
+- equipment_model ↔ parameter (via equipment_model_has_specification)
+- equipment_model ↔ procedures (via equipment_model_has_procedures)
+- parameter ↔ procedures (via parameter_has_procedures)
+- project ↔ equipment (via project_has_equipment)
+- project ↔ contact (via project_has_contact)
+- project ↔ sampling_points (via project_has_sampling_points)
+- equipment ↔ sampling_points (via equipment_has_sampling_points)
---
-## 6. Évolution prévisible (TO-BE 2025+)
-
-- Uniformisation du modèle vers une architecture **Docker + PostgreSQL + API REST**
-- Ajout d’une couche **data lineage** (audit, tracking de corrections)
-- Simplification des relations N-N (`project_has_*`) via vues logiques
-- Dépréciation des tables `value_before_12_04_2025` et `value_test_hedi`
-- Extension du domaine “operations” vers les boucles de contrôle prédictives (IA embarquée)
-
----
-## 7. Références croisées
+### 4. Key Publications
-| Fichier | Usage |
-|----------|-------|
-| `tables.md` | Détail des champs, types SQL, descriptions |
-| `valuesets.md` | Vocabulaire contrôlé (status, type_data, source_protocol, etc.) |
-| `schema.md` | Vue d’ensemble du modèle relationnel |
-| `architecture.md` *(à venir)* | Flux de données et interconnexions (API, MQTT, Historian) |
+1. Plana, Q., et al. (2018). "Towards a water quality database for raw and validated data with emphasis on structured metadata." *Water Quality Research Journal*, 54(1), 1-9.
diff --git a/docs/reference/tables.md b/docs/reference/tables.md
index 449eca1..327198c 100644
--- a/docs/reference/tables.md
+++ b/docs/reference/tables.md
@@ -1,6 +1,6 @@
# Database Tables
-This documentation is auto-generated from the Parts metadata table.
+This documentation is auto-generated from dictionary.json.
## Tables
@@ -10,395 +10,395 @@ This documentation is auto-generated from the Parts metadata table.
### Comments
-Table for Comments
+Stores any additional textual comments, notes, or observations related to a specific measured value
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Comment | ntext(1073741823) | - | | Comment in comments table | - |
-| Comment ID | int **(PK)** | - | | Identifier for comments, also used in 1 other table(s) | - |
+| Comment | ntext(1073741823) | - | | Comment on the data in the Value table | - |
+| Comment ID | int **(PK)** | - | | A unique ID is generated automatically by MySQL | - |
### Contact
-Table for Contact
+Stores detailed personal and professional information for people involved in projects (e.g., name, affiliation, function, e-mail, phone)
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Company | ntext(1073741823) | - | | Company in contact table | - |
-| Contact ID | int **(PK)** | - | | Identifier for contact, also used in 2 other table(s) | - |
-| Email | nvarchar(100) | - | | Email in contact table | - |
-| First Name | nvarchar(255) | - | | First Name in contact table | - |
-| Function | ntext(1073741823) | - | | Function in contact table | - |
-| Last Name | nvarchar(100) | - | | Last Name in contact table | - |
-| Linkedin | nvarchar(100) | - | | Linkedin in contact table | - |
-| Office Number | nvarchar(100) | - | | Office Number in contact table | - |
-| Phone | nvarchar(100) | - | | Phone in contact table | - |
-| Skype Name | nvarchar(100) | - | | Skype Name in contact table | - |
-| Status | nvarchar(255) | - | | Status in contact table | - |
-| Website | nvarchar(60) | - | | Website in contact table | - |
-| Contact City | nvarchar(255) | - | | Contact City in contact table | - |
-| Contact Country | nvarchar(255) | - | | Contact Country in contact table | - |
-| Contact Street Name | nvarchar(100) | - | | Contact Street Name in contact table | - |
-| Contact Street Number | nvarchar(100) | - | | Contact Street Number in contact table | - |
-| Contact Zip Code | nvarchar(45) | - | | Contact Zip Code in contact table | - |
+| Company | ntext(1073741823) | - | | Company name | - |
+| Contact ID | int **(PK)** | - | | Link to the Contact table | - |
+| Email | nvarchar(100) | - | | E-mail address | - |
+| First Name | nvarchar(255) | - | | First name of the contact | - |
+| Function | ntext(1073741823) | - | | More detailed description about the functions | - |
+| Last Name | nvarchar(100) | - | | Last name of the contact | - |
+| Linkedin | nvarchar(100) | - | | LinkedIn account | - |
+| Office Number | nvarchar(100) | - | | Number of the office | - |
+| Phone | nvarchar(100) | - | | Phone number | - |
+| Skype Name | nvarchar(100) | - | | Skype name | - |
+| Status | nvarchar(255) | - | | Status of the person. For example: "Master student", "Postdoc" or "Intern" | - |
+| Website | nvarchar(60) | - | | Website URL of the contact or organization | - |
+| Contact City | nvarchar(255) | - | | Address: name of the city | - |
+| Contact Country | nvarchar(255) | - | | Address: name of the country | - |
+| Contact Street Name | nvarchar(100) | - | | Address: name of the street | - |
+| Contact Street Number | nvarchar(100) | - | | Address: number of the street | - |
+| Contact Zip Code | nvarchar(45) | - | | Address: zip code | - |
### Equipment
-Table for Equipment
+Stores information about a specific, physical piece of equipment (e.g., serial number, owner, purchase date, storage location)
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Equipment ID | int **(PK)** | - | | Identifier for equipment, also used in 2 other table(s) | - |
-| Equipment IDentifier | nvarchar(100) | - | | Equipment IDentifier in equipment table | - |
-| Equipment Model ID | int | - | | Identifier for equipment_model, also used in 3 other table(s) | FK → [Equipment_model_ID](#Equipment_model_ID) |
-| Owner | ntext(1073741823) | - | | Owner in equipment table | - |
-| Purchase Date | date | - | | Purchase Date in equipment table | - |
-| Serial Number | nvarchar(100) | - | | Serial Number in equipment table | - |
-| Storage Location | nvarchar(100) | - | | Storage Location in equipment table | - |
+| Equipment ID | int **(PK)** | - | | Link to the Equipment table | - |
+| Equipment IDentifier | nvarchar(100) | - | | Identification name of the equipments | - |
+| Equipment Model ID | int | - | | Link to the Equipment model table | FK → [Equipment_model_ID](#Equipment_model_ID) |
+| Owner | ntext(1073741823) | - | | Name of the owner of the equipment | - |
+| Purchase Date | date | - | | Date when the equipment was bought: 'YYYY-MM-DD | - |
+| Serial Number | nvarchar(100) | - | | Serial number of the equipment | - |
+| Storage Location | nvarchar(100) | - | | Where is the procedure stored | - |
### Equipment Model
-Table for Equipment Model
+Stores detailed, non-redundant specifications for a specific sensor or instrument model (e.g., manufacturer, functions, method)
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Equipment Model | nvarchar(100) | - | | Equipment Model in equipment_model table | - |
-| Equipment Model ID | int **(PK)** | - | | Identifier for equipment_model, also used in 3 other table(s) | - |
-| Functions | ntext(1073741823) | - | | Functions in equipment_model table | - |
-| Manual Location | nvarchar(100) | - | | Manual Location in equipment_model table | - |
-| Manufacturer | nvarchar(100) | - | | Manufacturer in equipment_model table | - |
-| Method | nvarchar(100) | - | | Method in equipment_model table | - |
+| Equipment Model | nvarchar(100) | - | | Name of the equipment model. For example: ammo::lyser | - |
+| Equipment Model ID | int **(PK)** | - | | Link to the Equipment model table | - |
+| Functions | ntext(1073741823) | - | | Description of the functions of the equipment | - |
+| Manual Location | nvarchar(100) | - | | Location where the manual is stored | - |
+| Manufacturer | nvarchar(100) | - | | Name of the manufacturer | - |
+| Method | nvarchar(100) | - | | Method behind the equipment | - |
### Equipment Model Has Parameter
-Table for Equipment Model Has Parameter
+Links equipment models to the parameters they can measure
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Equipment Model ID | int **(CK-1)** | - | | Identifier for equipment_model, also used in 3 other table(s) | - |
-| Parameter ID | int **(CK-2)** | - | | Identifier for parameter, also used in 3 other table(s) | - |
+| Equipment Model ID | int **(CK-1)** | - | | Link to the Equipment model table | FK → [Equipment_model_ID](#Equipment_model_ID) |
+| Parameter ID | int **(CK-2)** | - | | Link to the Parameter table | - |
### Equipment Model Has Procedures
-Table for Equipment Model Has Procedures
+Links equipment models to the relevant maintenance procedures
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Equipment Model ID | int **(CK-1)** | - | | Identifier for equipment_model, also used in 3 other table(s) | - |
-| Procedure ID | int **(CK-2)** | - | | Identifier for procedures, also used in 3 other table(s) | - |
+| Equipment Model ID | int **(CK-1)** | - | | Link to the Equipment model table | FK → [Equipment_model_ID](#Equipment_model_ID) |
+| Procedure ID | int **(CK-2)** | - | | Link to the Procedures table | FK → [Procedure_ID](#Procedure_ID) |
### Hydrological Characteristics
-Table for Hydrological Characteristics
+Stores the hydrological land use percentages (e.g., forest, wetlands, cropland, grassland) within the watershed
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Cropland | real | - | | Cropland in hydrological_characteristics table | - |
-| Forest | real | - | | Forest in hydrological_characteristics table | - |
-| Grassland | real | - | | Grassland in hydrological_characteristics table | - |
-| Meadow | real | - | | Meadow in hydrological_characteristics table | - |
-| Urban Area | real | - | | Urban Area in hydrological_characteristics table | - |
-| Watershed ID | int **(PK)** | - | | Identifier for hydrological_characteristics, also used in 3 other table(s) | - |
-| Wetlands | real | - | | Wetlands in hydrological_characteristics table | - |
+| Cropland | real | - | | Percentage [%] of croplands | - |
+| Forest | real | - | | Percentage [%] of forest areas | - |
+| Grassland | real | - | | Percentage [%] of grasslands | - |
+| Meadow | real | - | | Percentage [%] of meadow areas | - |
+| Urban Area | real | - | | Percentage [%] of urban areas | - |
+| Watershed ID | int **(PK)** | - | | Linked to the Watershed table | FK → [Watershed_ID](#Watershed_ID) |
+| Wetlands | real | - | | Percentage [%] of wetlands | - |
### Metadata
-Table for Metadata
+Contains a list of all existing unique metadata combinations (represented by a series of foreign keys/IDs) that describe a single measurement
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Condition ID | int | - | | Identifier for weather_condition, also used in 1 other table(s) | FK → [Condition_ID](#Condition_ID) |
-| Contact ID | int | - | | Identifier for contact, also used in 2 other table(s) | FK → [Contact_ID](#Contact_ID) |
-| Equipment ID | int | - | | Identifier for equipment, also used in 2 other table(s) | FK → [Equipment_ID](#Equipment_ID) |
-| Metadata ID | int **(PK)** | - | | Identifier for metadata, also used in 1 other table(s) | - |
-| Parameter ID | int | - | | Identifier for parameter, also used in 3 other table(s) | FK → [Parameter_ID](#Parameter_ID) |
-| Procedure ID | int | - | | Identifier for procedures, also used in 3 other table(s) | FK → [Procedure_ID](#Procedure_ID) |
-| Project ID | int | - | | Identifier for project, also used in 4 other table(s) | FK → [Project_ID](#Project_ID) |
-| Purpose ID | int | - | | Identifier for purpose, also used in 1 other table(s) | FK → [Purpose_ID](#Purpose_ID) |
-| Sampling Point ID | int | - | | Identifier for sampling_points, also used in 2 other table(s) | FK → [Sampling_point_ID](#Sampling_point_ID) |
-| Unit ID | int | - | | Identifier for unit, also used in 2 other table(s) | FK → [Unit_ID](#Unit_ID) |
+| Condition ID | int | - | | A unique ID is generated automatically by MySQL | FK → [Condition_ID](#Condition_ID) |
+| Contact ID | int | - | | Link to the Contact table | FK → [Contact_ID](#Contact_ID) |
+| Equipment ID | int | - | | Link to the Equipment table | FK → [Equipment_ID](#Equipment_ID) |
+| Metadata ID | int **(PK)** | - | | A unique ID is generated automatically by MySQL | - |
+| Parameter ID | int | - | | Link to the Parameter table | FK → [Parameter_ID](#Parameter_ID) |
+| Procedure ID | int | - | | Link to the Procedures table | FK → [Procedure_ID](#Procedure_ID) |
+| Project ID | int | - | | Link to the Project table | FK → [Project_ID](#Project_ID) |
+| Purpose ID | int | - | | A unique ID is generated automatically by MySQL | FK → [Purpose_ID](#Purpose_ID) |
+| Sampling Point ID | int | - | | Link to the Sampling_point table | FK → [Sampling_point_ID](#Sampling_point_ID) |
+| Unit ID | int | - | | A unique ID is generated automatically by MySQL | FK → [Unit_ID](#Unit_ID) |
### Parameter
-Table for Parameter
+Stores the different water quality or quantity parameters that are measured (e.g., pH, TSS, N-components)
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Parameter | nvarchar(100) | - | | Parameter in parameter table | - |
-| Parameter ID | int **(PK)** | - | | Identifier for parameter, also used in 3 other table(s) | - |
-| Unit ID | int | - | | Identifier for unit, also used in 2 other table(s) | FK → [Unit_ID](#Unit_ID) |
-| Parameter Description | ntext(1073741823) | - | | Parameter Description in parameter table | - |
+| Parameter | nvarchar(100) | - | | Name of the parameter | - |
+| Parameter ID | int **(PK)** | - | | Link to the Parameter table | - |
+| Unit ID | int | - | | A unique ID is generated automatically by MySQL | FK → [Unit_ID](#Unit_ID) |
+| Parameter Description | ntext(1073741823) | - | | Description of the parameter | - |
### Parameter Has Procedures
-Table for Parameter Has Procedures
+Links parameters to the relevant measurement procedures
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Parameter ID | int **(CK-1)** | - | | Identifier for parameter, also used in 3 other table(s) | - |
-| Procedure ID | int **(CK-2)** | - | | Identifier for procedures, also used in 3 other table(s) | - |
+| Parameter ID | int **(CK-1)** | - | | Link to the Parameter table | - |
+| Procedure ID | int **(CK-2)** | - | | Link to the Procedures table | FK → [Procedure_ID](#Procedure_ID) |
### Procedures
-Table for Procedures
+Stores details for different measurement procedures (e.g., calibration, validation, standard operating procedures, ISO methods)
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Procedure ID | int **(PK)** | - | | Identifier for procedures, also used in 3 other table(s) | - |
-| Procedure Location | nvarchar(100) | - | | Procedure Location in procedures table | - |
-| Procedure Name | nvarchar(100) | - | | Procedure Name in procedures table | - |
-| Procedure Type | nvarchar(255) | - | | Procedure Type in procedures table | - |
-| Procedures Description | ntext(1073741823) | - | | Procedures Description in procedures table | - |
+| Procedure ID | int **(PK)** | - | | Link to the Procedures table | - |
+| Procedure Location | nvarchar(100) | - | | Where is the procedure stored | - |
+| Procedure Name | nvarchar(100) | - | | Title name of the procedure | - |
+| Procedure Type | nvarchar(255) | - | | Type of the procedure. For example, SOP | - |
+| Procedures Description | ntext(1073741823) | - | | Description of the procedure | - |
### Project
-Table for Project
+Stores descriptive information about the research or monitoring project for which the data was collected
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Project ID | int **(PK)** | - | | Identifier for project, also used in 4 other table(s) | - |
-| Project Name | nvarchar(100) | - | | Project Name in project table | - |
-| Project Description | ntext(1073741823) | - | | Project Description in project table | - |
+| Project ID | int **(PK)** | - | | Link to the Project table | - |
+| Project Name | nvarchar(100) | - | | Name of the project | - |
+| Project Description | ntext(1073741823) | - | | Description of the project | - |
### Project Has Contact
-Table for Project Has Contact
+Links projects to the personnel involved in them
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Contact ID | int **(CK-2)** | - | | Identifier for contact, also used in 2 other table(s) | - |
-| Project ID | int **(CK-1)** | - | | Identifier for project, also used in 4 other table(s) | - |
+| Contact ID | int **(CK-2)** | - | | Link to the Contact table | FK → [Contact_ID](#Contact_ID) |
+| Project ID | int **(CK-1)** | - | | Link to the Project table | FK → [Project_ID](#Project_ID) |
### Project Has Equipment
-Table for Project Has Equipment
+Links projects to the specific equipment used within them
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Equipment ID | int **(CK-2)** | - | | Identifier for equipment, also used in 2 other table(s) | - |
-| Project ID | int **(CK-1)** | - | | Identifier for project, also used in 4 other table(s) | - |
+| Equipment ID | int **(CK-2)** | - | | Link to the Equipment table | FK → [Equipment_ID](#Equipment_ID) |
+| Project ID | int **(CK-1)** | - | | Link to the Project table | FK → [Project_ID](#Project_ID) |
### Project Has Sampling Points
-Table for Project Has Sampling Points
+Links projects to the sampling points used within them
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Project ID | int **(CK-1)** | - | | Identifier for project, also used in 4 other table(s) | - |
-| Sampling Point ID | int **(CK-2)** | - | | Identifier for sampling_points, also used in 2 other table(s) | - |
+| Project ID | int **(CK-1)** | - | | Link to the Project table | FK → [Project_ID](#Project_ID) |
+| Sampling Point ID | int **(CK-2)** | - | | Link to the Sampling_point table | FK → [Sampling_point_ID](#Sampling_point_ID) |
### Purpose
-Table for Purpose
+Stores information about the aim of the measurement (e.g., on-line measurement, laboratory analysis, calibration, validation, cleaning)
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Purpose | nvarchar(100) | - | | Purpose in purpose table | - |
-| Purpose ID | int **(PK)** | - | | Identifier for purpose, also used in 1 other table(s) | - |
-| Purpose Description | ntext(1073741823) | - | | Purpose Description in purpose table | - |
+| Purpose | nvarchar(100) | - | | Purpose of the data collection. For example, "Measurement", "Lab_analysis", "Calibration" and "Cleaning" | - |
+| Purpose ID | int **(PK)** | - | | A unique ID is generated automatically by MySQL | - |
+| Purpose Description | ntext(1073741823) | - | | Description of the purpose | - |
### Sampling Points
-Table for Sampling Points
+Stores the identification, specific geographical coordinates (Latitude/Longitude/GPS), and description of a particular spot where a sample or measurement is taken
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Latitude GPS | nvarchar(100) | - | | Latitude GPS in sampling_points table | - |
-| Longitude GPS | nvarchar(100) | - | | Longitude GPS in sampling_points table | - |
-| Pictures | BLOB | - | | Pictures in sampling_points table | - |
-| Sampling Location | nvarchar(100) | - | | Sampling Location in sampling_points table | - |
-| Sampling Point | nvarchar(100) | - | | Sampling Point in sampling_points table | - |
-| Sampling Point ID | int **(PK)** | - | | Identifier for sampling_points, also used in 2 other table(s) | - |
-| Site ID | int | - | | Identifier for site, also used in 1 other table(s) | FK → [Site_ID](#Site_ID) |
-| Sampling Points Description | ntext(1073741823) | - | | Sampling Points Description in sampling_points table | - |
+| Latitude GPS | nvarchar(100) | - | | GPS coordinates. For example: 47°54′25.103" | - |
+| Longitude GPS | nvarchar(100) | - | | GPS coordinates. For example: $73^{\circ}47^{\prime}00.024^{\prime\prime}$ | - |
+| Pictures | BLOB | - | | Picture of the site | - |
+| Sampling Location | nvarchar(100) | - | | Where the sample was taken. For example: "Biofiltration", "Sewer 01" or "Retention Tank" | - |
+| Sampling Point | nvarchar(100) | - | | Where the sample was taken. For example: "Inlet", "Outlet" or "Upstream" | - |
+| Sampling Point ID | int **(PK)** | - | | Link to the Sampling_point table | - |
+| Site ID | int | - | | A unique ID is generated automatically by MySQL | FK → [Site_ID](#Site_ID) |
+| Sampling Points Description | ntext(1073741823) | - | | Description of the sampling point | - |
### Site
-Table for Site
+Stores general site information, including address, site type, and a link to the associated watershed
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Picture | image(2147483647) | - | | Picture in site table | - |
-| Province | nvarchar(255) | - | | Province in site table | - |
-| Site ID | int **(PK)** | - | | Identifier for site, also used in 1 other table(s) | - |
-| Site Name | nvarchar(100) | - | | Site Name in site table | - |
-| Site Type | nvarchar(255) | - | | Site Type in site table | - |
-| Watershed ID | int | - | | Identifier for hydrological_characteristics, also used in 3 other table(s) | FK → [Watershed_ID](#Watershed_ID) |
-| Site City | nvarchar(255) | - | | Site City in site table | - |
-| Site Country | nvarchar(255) | - | | Site Country in site table | - |
-| Site Description | ntext(1073741823) | - | | Site Description in site table | - |
-| Site Street Name | nvarchar(100) | - | | Site Street Name in site table | - |
-| Site Street Number | nvarchar(100) | - | | Site Street Number in site table | - |
-| Site Zip Code | nvarchar(100) | - | | Site Zip Code in site table | - |
+| Picture | image(2147483647) | - | | Picture of the site | - |
+| Province | nvarchar(255) | - | | Address: name of the province | - |
+| Site ID | int **(PK)** | - | | A unique ID is generated automatically by MySQL | - |
+| Site Name | nvarchar(100) | - | | Name of the site | - |
+| Site Type | nvarchar(255) | - | | For example: "WWTP", "River" or "Sewer_system" | - |
+| Watershed ID | int | - | | Linked to the Watershed table | FK → [Watershed_ID](#Watershed_ID) |
+| Site City | nvarchar(255) | - | | Address: name of the city | - |
+| Site Country | nvarchar(255) | - | | Address: name of the country | - |
+| Site Description | ntext(1073741823) | - | | Description of the site | - |
+| Site Street Name | nvarchar(100) | - | | Address: name of the street | - |
+| Site Street Number | nvarchar(100) | - | | Address: number of the street | - |
+| Site Zip Code | nvarchar(100) | - | | Address: zip code | - |
### Unit
-Table for Unit
+Stores the SI units of measurement (or other relevant units) corresponding to the parameters (e.g., mg/L, g/L, s)
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Unit | nvarchar(100) | - | | Unit in unit table | - |
-| Unit ID | int **(PK)** | - | | Identifier for unit, also used in 2 other table(s) | - |
+| Unit | nvarchar(100) | - | | SI-units only | - |
+| Unit ID | int **(PK)** | - | | A unique ID is generated automatically by MySQL | - |
### Urban Characteristics
-Table for Urban Characteristics
+Stores the urban land use percentages (e.g., commercial, residential, green spaces) within the watershed
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Agricultural | real | - | | Agricultural in urban_characteristics table | - |
-| Commercial | real | - | | Commercial in urban_characteristics table | - |
-| Green Spaces | real | - | | Green Spaces in urban_characteristics table | - |
-| Industrial | real | - | | Industrial in urban_characteristics table | - |
-| Institutional | real | - | | Institutional in urban_characteristics table | - |
-| Recreational | real | - | | Recreational in urban_characteristics table | - |
-| Residential | real | - | | Residential in urban_characteristics table | - |
-| Watershed ID | int **(PK)** | - | | Identifier for hydrological_characteristics, also used in 3 other table(s) | - |
+| Agricultural | real | - | | Percentage [%] of agricultural land use. For example farm land | - |
+| Commercial | real | - | | Percentage [%] of commercial areas. For example stores or bank areas | - |
+| Green Spaces | real | - | | Percentage [%] of green spaces | - |
+| Industrial | real | - | | Percentage [%] of industrial areas. For example factories | - |
+| Institutional | real | - | | Percentage [%] of institutional areas. For example schools, police stations or city hall | - |
+| Recreational | real | - | | Percentage [%] of recreational areas. For example parks or sport fields | - |
+| Residential | real | - | | Percentage [%] of residential areas. For example houses or apartment buildings | - |
+| Watershed ID | int **(PK)** | - | | Linked to the Watershed table | FK → [Watershed_ID](#Watershed_ID) |
### Value
-Table for Value
+Stores each measured water quality or quantity value, its time stamp, replicate identification, and the link to its specific metadata set
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Comment ID | int | - | | Identifier for comments, also used in 1 other table(s) | FK → [Comment_ID](#Comment_ID) |
-| Metadata ID | int | - | | Identifier for metadata, also used in 1 other table(s) | FK → [Metadata_ID](#Metadata_ID) |
-| Number Of Experiment | numeric | - | | Number Of Experiment in value table | - |
-| Timestamp | int | - | | Timestamp in value table | - |
-| Value | float | - | | Value in value table | - |
-| Value ID | int **(PK)** | - | | Unique identifier for value | - |
+| Comment ID | int | - | | A unique ID is generated automatically by MySQL | FK → [Comment_ID](#Comment_ID) |
+| Metadata ID | int | - | | A unique ID is generated automatically by MySQL | FK → [Metadata_ID](#Metadata_ID) |
+| Number Of Experiment | numeric | - | | Number of replica of an experiment | - |
+| Timestamp | int | - | | Unix timestamp combining date and time of collected data | - |
+| Value | float | - | | Value of collected data | - |
+| Value ID | int **(PK)** | - | | A unique ID is generated automatically by MySQL | - |
### Watershed
-Table for Watershed
+Stores general information about the watershed area, including surface area, concentration time, and impervious surface percentage
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Concentration Time | int | - | | Concentration Time in watershed table | - |
-| Impervious Surface | real | - | | Impervious Surface in watershed table | - |
-| Surface Area | real | - | | Surface Area in watershed table | - |
-| Watershed ID | int **(PK)** | - | | Identifier for hydrological_characteristics, also used in 3 other table(s) | - |
-| Watershed Name | nvarchar(100) | - | | Watershed Name in watershed table | - |
-| Watershed Description | ntext(1073741823) | - | | Watershed Description in watershed table | - |
+| Concentration Time | int | - | | Concentration time in minutes [min] | - |
+| Impervious Surface | real | - | | Percentage of the impervious surface of the watershed in percentage [%] | - |
+| Surface Area | real | - | | Surface area of the watershed [ha] | - |
+| Watershed ID | int **(PK)** | - | | Linked to the Watershed table | - |
+| Watershed Name | nvarchar(100) | - | | Name of the watershed | - |
+| Watershed Description | ntext(1073741823) | - | | Description of the watershed | - |
### Weather Condition
-Table for Weather Condition
+Stores descriptive information about the prevailing weather conditions when the measurement was taken (e.g., dry weather, wet weather, snow melt)
#### Fields
| Field | SQL Type | Value Set | Required | Description | Constraints |
|-------|----------|-----------|----------|-------------|-------------|
-| Condition ID | int **(PK)** | - | | Identifier for weather_condition, also used in 1 other table(s) | - |
-| Weather Condition | nvarchar(100) | - | | Weather Condition in weather_condition table | - |
-| Weather Condition Description | ntext(1073741823) | - | | Weather Condition Description in weather_condition table | - |
\ No newline at end of file
+| Condition ID | int **(PK)** | - | | A unique ID is generated automatically by MySQL | - |
+| Weather Condition | nvarchar(100) | - | | Type of weather condition | - |
+| Weather Condition Description | ntext(1073741823) | - | | Description of the condition | - |
\ No newline at end of file
diff --git a/docs/reference/valuesets.md b/docs/reference/valuesets.md
index 7a9a99a..81b23dc 100644
--- a/docs/reference/valuesets.md
+++ b/docs/reference/valuesets.md
@@ -1,6 +1,6 @@
# Value Sets
-Controlled vocabularies used throughout the database.
+Controlled vocabularies used throughout database.
diff --git a/mkdocs.yml b/mkdocs.yml
index 384908e..55583bc 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -14,22 +14,24 @@ theme:
plugins:
- search
-
+ - markdown-exec
hooks:
- - docs/hooks/generate_docs.py
+ - docs/hooks/call_orchestrator.py
nav:
- Home: index.md
- Contributing:
- - The parts table: contributing/parts_table.md
+ - The Dictionary: contributing/dictionary.md
- Reference:
- Schema: reference/schema.md
- Tables: reference/tables.md
- Value Sets: reference/valuesets.md
+ - ERD Diagram: reference/erd.md
markdown_extensions:
- admonition
- codehilite
- tables
- toc:
- permalink: true
\ No newline at end of file
+ permalink: true
+ - pymdownx.superfences
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 380436b..31407ed 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,9 +7,22 @@ requires-python = ">=3.12"
dependencies = [
"mkdocs>=1.6.1",
"mkdocs-material>=9.6.22",
+ "pydantic>=2.0.0",
+ "mkdocs-gen-files>=0.5.0",
+ "markdown-exec>=1.12.1",
+ "pymdown-extensions>=10.16.1",
]
-[dependency-groups]
+[project.optional-dependencies]
dev = [
"pytest>=8.4.2",
]
+
+[tool.setuptools]
+package-dir = { "" = "src" }
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[tool.setuptools.package-data]
+"" = ["*.json"]
diff --git a/scripts/generate_dictionary_reference.py b/scripts/generate_dictionary_reference.py
new file mode 100644
index 0000000..26f0968
--- /dev/null
+++ b/scripts/generate_dictionary_reference.py
@@ -0,0 +1,260 @@
+#!/usr/bin/env python3
+"""
+Generate dictionary reference documentation (tables and value sets).
+
+This script extracts table and value set generation logic from the main
+generate_docs hook to create modular, testable components.
+
+Usage:
+ python generate_dictionary_reference.py
+"""
+
+import sys
+import json
+from pathlib import Path
+
+
+def parse_parts_json(json_path):
+ """
+ Parse dictionary.json using Pydantic validation.
+ Returns same dict structure as parse_parts_table() for compatibility.
+ """
+ # Add src to path to import models
+ project_root = Path(json_path).parent.parent
+ sys.path.insert(0, str(project_root / "src"))
+
+ from open_dateaubase.data_model.models import Dictionary
+
+ # Load and validate
+ with open(json_path, "r", encoding="utf-8") as f:
+ raw_data = json.load(f)
+
+ # Pydantic validation
+ dictionary = Dictionary.model_validate(raw_data)
+
+ # Transform to legacy format for generators
+ data = {"tables": {}, "value_sets": {}, "metadata": {}, "id_field_locations": {}}
+
+ # Process tables
+ for part in dictionary.parts:
+ if part.part_type == "table":
+ data["tables"][part.part_id] = {
+ "label": part.label,
+ "description": part.description,
+ "fields": [],
+ }
+
+ # Process fields
+ for part in dictionary.parts:
+ # Only process field parts that have table_presence
+ if hasattr(part, "table_presence") and part.part_type in [
+ "key",
+ "property",
+ "compositeKeyFirst",
+ "compositeKeySecond",
+ "parentKey",
+ ]:
+ for table_name, presence in part.table_presence.items():
+ if table_name not in data["tables"]:
+ continue
+
+ # Track ID field locations
+ if part.part_id.endswith("_ID"):
+ if part.part_id not in data["id_field_locations"]:
+ data["id_field_locations"][part.part_id] = {}
+ data["id_field_locations"][part.part_id][table_name] = presence.role
+
+ # Determine FK target and relationship type from explicit metadata
+ fk_to = ""
+ relationship_type = None
+ if part.part_type == "parentKey":
+ fk_to = part.ancestor_part_id
+ elif presence.relationship_type:
+ # Infer FK target from field name (field ending in _ID references same-named primary key)
+ if part.part_id.endswith("_ID"):
+ fk_to = part.part_id
+ relationship_type = presence.relationship_type
+
+ field_info = {
+ "part_id": part.part_id,
+ "label": part.label,
+ "description": part.description,
+ "part_type": presence.role,
+ "sql_data_type": getattr(part, "sql_data_type", None) or "",
+ "is_required": presence.required,
+ "default_value": getattr(part, "default_value", None) or "",
+ "fk_to": fk_to,
+ "relationship_type": relationship_type,
+ "value_set": getattr(part, "value_set_part_id", None) or "",
+ "sort_order": presence.order,
+ }
+ data["tables"][table_name]["fields"].append(field_info)
+
+ # Process value sets
+ for part in dictionary.parts:
+ if part.part_type == "valueSet":
+ data["value_sets"][part.part_id] = {
+ "label": part.label,
+ "description": part.description,
+ "members": [],
+ }
+
+ for part in dictionary.parts:
+ if part.part_type == "valueSetMember":
+ value_set_id = part.member_of_set_part_id
+ if value_set_id in data["value_sets"]:
+ member_info = {
+ "part_id": part.part_id,
+ "label": part.label,
+ "description": part.description,
+ "sort_order": part.sort_order if part.sort_order else 999,
+ }
+ data["value_sets"][value_set_id]["members"].append(member_info)
+
+ # Sort fields and members
+ for table in data["tables"].values():
+ table["fields"].sort(key=lambda x: x["sort_order"])
+
+ for value_set in data["value_sets"].values():
+ value_set["members"].sort(key=lambda x: x["sort_order"])
+
+ return data
+
+
+def generate_tables_markdown(data):
+ """
+ Generate markdown documentation from parsed data.
+ """
+ md = ["# Database Tables\n"]
+ md.append("This documentation is auto-generated from dictionary.json.\n")
+
+ # Generate table documentation
+ md.append("\n## Tables\n")
+
+ for table_id, table_info in sorted(data["tables"].items()):
+ # Anchor as invisible span, table name as regular heading
+ md.append(f'\n\n')
+ md.append(f"### {table_info['label']}\n")
+ md.append(f"{table_info['description']}\n")
+
+ if table_info["fields"]:
+ md.append("\n#### Fields\n")
+ md.append(
+ "| Field | SQL Type | Value Set | Required | Description | Constraints |"
+ )
+ md.append(
+ "|-------|----------|-----------|----------|-------------|-------------|"
+ )
+
+ for field in table_info["fields"]:
+ field_name = field["label"]
+ field_id = field["part_id"]
+
+ # SQL Type column
+ sql_type = field["sql_data_type"] if field["sql_data_type"] else "-"
+ if field["part_type"] in [
+ "key",
+ "compositeKeyFirst",
+ "compositeKeySecond",
+ ]:
+ if field["part_type"] == "key":
+ sql_type += " **(PK)**"
+ elif field["part_type"] == "compositeKeyFirst":
+ sql_type += " **(CK-1)**"
+ elif field["part_type"] == "compositeKeySecond":
+ sql_type += " **(CK-2)**"
+ else:
+ raise ValueError(
+ f"Found unknown part type: {field['part_type']}. Correct dictionary OR update documentation generation code."
+ )
+
+ # Value Set column - link to value set definition
+ value_set = (
+ f"[{field['value_set']}](valuesets.md#{field['value_set']})"
+ if field["value_set"]
+ else "-"
+ )
+
+ required = "✓" if field["is_required"] else ""
+
+ # Anchor description with Part_ID
+ description = f'{field["description"]}'
+
+ # Build constraints column
+ constraints = []
+ if field["fk_to"]:
+ # Link to FK target field
+ constraints.append(f"FK → [{field['fk_to']}](#{field['fk_to']})")
+ if field["default_value"]:
+ constraints.append(f"Default: `{field['default_value']}`")
+
+ constraints_str = " ".join(constraints) if constraints else "-"
+
+ md.append(
+ f"| {field_name} | {sql_type} | {value_set} | {required} | {description} | {constraints_str} |"
+ )
+
+ return "\n".join(md)
+
+
+def generate_value_sets_markdown(data):
+ """
+ Generate value set documentation with proper anchoring.
+ """
+ md = ["# Value Sets\n"]
+ md.append("Controlled vocabularies used throughout database.\n")
+
+ if data["value_sets"]:
+ for value_set_id, value_set_info in sorted(data["value_sets"].items()):
+ # Anchor as invisible span, value set name as regular heading
+ md.append(f'\n\n')
+ md.append(f"## {value_set_info['label']}\n")
+ md.append(f"{value_set_info['description']}\n")
+
+ if value_set_info["members"]:
+ md.append("\n| Value | Description |")
+ md.append("|-------|-------------|")
+
+ for member in value_set_info["members"]:
+ member_id = member["part_id"]
+ # Anchor each member with its Part_ID
+ md.append(
+ f'| `{member_id}` | {member["description"]} |'
+ )
+ else:
+ md.append("No value sets currently appear in dictionary.")
+
+ return "\n".join(md)
+
+
+def main():
+ """Main entry point for script."""
+ if len(sys.argv) != 3:
+ print("Usage: python generate_dictionary_reference.py ")
+ print("Example: python generate_dictionary_reference.py dictionary.json docs/reference")
+ sys.exit(1)
+
+ json_path = Path(sys.argv[1])
+ output_path = Path(sys.argv[2])
+
+ # Ensure output directory exists
+ output_path.mkdir(parents=True, exist_ok=True)
+
+ # Parse JSON
+ parts_data = parse_parts_json(json_path)
+
+ # Generate markdown
+ tables = generate_tables_markdown(parts_data)
+ value_sets = generate_value_sets_markdown(parts_data)
+
+ # Write to files
+ (output_path / "tables.md").write_text(tables, encoding="utf-8")
+ (output_path / "valuesets.md").write_text(value_sets, encoding="utf-8")
+
+ print(f"Generated dictionary reference documentation:")
+ print(f" Tables: {output_path / 'tables.md'}")
+ print(f" Value sets: {output_path / 'valuesets.md'}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/generate_erd.py b/scripts/generate_erd.py
new file mode 100644
index 0000000..cf99b5d
--- /dev/null
+++ b/scripts/generate_erd.py
@@ -0,0 +1,1364 @@
+"""
+Generate ERD (Entity-Relationship Diagram) from dictionary.
+
+This script extracts ERD generation logic from the main generate_docs hook
+to create a modular, testable component.
+
+Usage:
+ python generate_erd.py
+"""
+
+import sys
+import json
+from pathlib import Path
+from typing import Dict, List, Any, Optional
+from dataclasses import dataclass, asdict
+
+# Add src to path to import models
+project_root = Path(__file__).parent.parent
+sys.path.insert(0, str(project_root / "src"))
+
+from open_dateaubase.data_model.models import Dictionary
+
+
+def parse_erd_json(json_path):
+ """
+ Parse dictionary.json using Pydantic validation.
+ Returns same dict structure as parse_parts_table() for compatibility.
+ """
+ # Load and validate
+ with open(json_path, "r", encoding="utf-8") as f:
+ raw_data = json.load(f)
+
+ # Pydantic validation
+ dictionary = Dictionary.model_validate(raw_data)
+
+ # Transform to legacy format for generators
+ data = {"tables": {}, "value_sets": {}, "metadata": {}, "id_field_locations": {}}
+
+ # Process tables
+ for part in dictionary.parts:
+ if part.part_type == "table":
+ data["tables"][part.part_id] = {
+ "label": part.label,
+ "description": part.description,
+ "fields": [],
+ }
+
+ # Process fields
+ for part in dictionary.parts:
+ # Only process field parts that have table_presence
+ if hasattr(part, "table_presence") and part.part_type in [
+ "key",
+ "property",
+ "compositeKeyFirst",
+ "compositeKeySecond",
+ "parentKey",
+ ]:
+ for table_name, presence in part.table_presence.items():
+ if table_name not in data["tables"]:
+ continue
+
+ # Track ID field locations
+ if part.part_id.endswith("_ID"):
+ if part.part_id not in data["id_field_locations"]:
+ data["id_field_locations"][part.part_id] = {}
+ data["id_field_locations"][part.part_id][table_name] = presence.role
+
+ # Determine FK target and relationship type from explicit metadata
+ fk_to = ""
+ relationship_type = None
+ if part.part_type == "parentKey":
+ fk_to = part.ancestor_part_id
+ elif presence.relationship_type:
+ # Infer FK target from field name (field ending in _ID references same-named primary key)
+ if part.part_id.endswith("_ID"):
+ fk_to = part.part_id
+ relationship_type = presence.relationship_type
+
+ field_info = {
+ "part_id": part.part_id,
+ "label": part.label,
+ "description": part.description,
+ "part_type": presence.role,
+ "sql_data_type": getattr(part, "sql_data_type", None) or "",
+ "is_required": presence.required,
+ "default_value": getattr(part, "default_value", None) or "",
+ "fk_to": fk_to,
+ "relationship_type": relationship_type,
+ "value_set": getattr(part, "value_set_part_id", None) or "",
+ "sort_order": presence.order,
+ }
+ data["tables"][table_name]["fields"].append(field_info)
+
+ # Process value sets
+ for part in dictionary.parts:
+ if part.part_type == "valueSet":
+ data["value_sets"][part.part_id] = {
+ "label": part.label,
+ "description": part.description,
+ "members": [],
+ }
+
+ for part in dictionary.parts:
+ if part.part_type == "valueSetMember":
+ value_set_id = part.member_of_set_part_id
+ if value_set_id in data["value_sets"]:
+ member_info = {
+ "part_id": part.part_id,
+ "label": part.label,
+ "description": part.description,
+ "sort_order": part.sort_order if part.sort_order else 999,
+ }
+ data["value_sets"][value_set_id]["members"].append(member_info)
+
+ # Sort fields and members
+ for table in data["tables"].values():
+ table["fields"].sort(key=lambda x: x["sort_order"])
+
+ for value_set in data["value_sets"].values():
+ value_set["members"].sort(key=lambda x: x["sort_order"])
+
+ return data
+
+
+def generate_erd_files(parts_data, assets_path, output_path):
+ """
+ Generate interactive ERD diagram.
+
+ Args:
+ parts_data: Parsed dictionary data
+ assets_path: Path to docs/assets directory
+ output_path: Path to docs/reference directory
+ """
+
+ # Generate ERD data
+ erd_data = generate_erd_data(parts_data)
+
+ # Create assets directory if it doesn't exist
+ assets_path.mkdir(parents=True, exist_ok=True)
+
+ # Generate JointJS (interactive) version only
+ jointjs_path = assets_path / "erd_interactive.html"
+
+ generate_erd_html(erd_data, jointjs_path, library="jointjs")
+
+ print(f"Generated interactive ERD at {jointjs_path}")
+
+ # Create ERD documentation page
+ erd_markdown = f"""# Entity Relationship Diagram (ERD)
+
+This interactive diagram shows all tables and their relationships in datEAUbase schema.
+
+## Interactive ERD
+
+The interactive version allows you to:
+- 🖱️ **Drag tables** to rearrange layout
+- 🔍 **Zoom in/out** for better visibility
+- 📐 **Auto-layout** to reorganize tables automatically
+- 💾 **Export** diagram as PNG
+
+
+
+[Open in new window](../assets/erd_interactive.html){{: target="_blank" .md-button .md-button--primary}}
+
+## Legend
+
+### Field Markers
+- **PK** badge: Primary Key - Unique identifier for each record
+- **FK** badge: Foreign Key - Reference to another table's primary key
+- **\\*** Required field (NOT NULL)
+
+### Relationship Notation
+Relationships use standard crow's foot notation:
+- **Single line (|)**: "One" side of relationship
+- **Crow's foot (⟨)**: "Many" side of relationship
+
+**Relationship Types:**
+- **One-to-One**: Single line on both ends (e.g., watershed ↔ hydrological_characteristics)
+- **One-to-Many**: Crow's foot on child side, single line on parent (e.g., site ↔ sampling_points)
+- **Many-to-Many**: Crow's foot on both ends (via junction tables like project_has_contact)
+
+## Table Count
+
+The current schema contains **{len(parts_data["tables"])}** tables with **{len(erd_data["relationships"])}** relationships.
+"""
+
+ (output_path / "erd.md").write_text(erd_markdown, encoding="utf-8")
+ print(f"Generated ERD documentation page at {output_path / 'erd.md'}")
+
+
+def main():
+ """Main entry point for script."""
+ if len(sys.argv) != 4:
+ print("Usage: python generate_erd.py ")
+ print("Example: python generate_erd.py dictionary.json docs/assets docs/reference")
+ sys.exit(1)
+
+ json_path = Path(sys.argv[1])
+ assets_path = Path(sys.argv[2])
+ output_path = Path(sys.argv[3])
+
+ # Ensure directories exist
+ assets_path.mkdir(parents=True, exist_ok=True)
+ output_path.mkdir(parents=True, exist_ok=True)
+
+ # Parse JSON
+ parts_data = parse_erd_json(json_path)
+
+ # Generate ERD files
+ generate_erd_files(parts_data, assets_path, output_path)
+
+
+
+
+@dataclass
+class ERDField:
+ """Represents a field in a table for ERD visualization."""
+
+ name: str
+ sql_type: str
+ is_pk: bool = False
+ is_fk: bool = False
+ is_required: bool = False
+ fk_target: Optional[str] = None # Format: "table_name.field_name"
+ description: Optional[str] = None
+
+
+@dataclass
+class ERDTable:
+ """Represents a table for ERD visualization."""
+
+ id: str
+ label: str
+ description: str
+ fields: List[ERDField]
+
+
+@dataclass
+class ERDRelationship:
+ """Represents a foreign key relationship from child (FK) to parent (PK)."""
+
+ from_table: str
+ to_table: str
+ from_field: str
+ to_field: str
+ # TODO: Add cardinality information in the future (one-to-one, one-to-many, many-to-many)
+ # This could be inferred from field.is_required and composite key patterns
+ relationship_type: str = (
+ "many-to-one" # Placeholder for future cardinality implementation
+ )
+
+
+def generate_erd_data(parts_data: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Transform parsed dictionary data into ERD-friendly format.
+
+ Args:
+ parts_data: Parsed data from parse_parts_json() in generate_docs.py
+
+ Returns:
+ Dict with 'tables' and 'relationships' for ERD rendering
+ """
+ tables = []
+ relationships = []
+
+ # Process each table
+ for table_id, table_info in parts_data["tables"].items():
+ fields = []
+
+ for field in table_info["fields"]:
+ is_pk = field["part_type"] in [
+ "key",
+ "compositeKeyFirst",
+ "compositeKeySecond",
+ ]
+ is_fk = bool(field.get("fk_to"))
+
+ fk_target = None
+ if is_fk and field["fk_to"]:
+ # Extract target table from FK field (e.g., "Contact_ID" -> "contact")
+ fk_field = field["fk_to"]
+ if fk_field.endswith("_ID"):
+ # Convert to lowercase to match table_id format
+ target_table = fk_field[:-3].lower()
+ fk_target = f"{target_table}.{fk_field}"
+
+ erd_field = ERDField(
+ name=field["label"],
+ sql_type=field["sql_data_type"] or "unknown",
+ is_pk=is_pk,
+ is_fk=is_fk,
+ is_required=field["is_required"],
+ fk_target=fk_target,
+ description=field["description"],
+ )
+ fields.append(erd_field)
+
+ erd_table = ERDTable(
+ id=table_id,
+ label=table_info["label"],
+ description=table_info["description"],
+ fields=fields,
+ )
+ tables.append(erd_table)
+
+ # Build a mapping of PK field IDs to their primary tables
+ # A field is a PRIMARY KEY in a table if part_type is 'key' (not compositeKeyFirst/Second)
+ # Composite keys in junction tables should NOT be treated as the primary definition
+ pk_id_to_table = {}
+ for tid, tinfo in parts_data["tables"].items():
+ for f in tinfo["fields"]:
+ # Only consider 'key' as the primary definition of where this field is a PK
+ # compositeKeyFirst/Second means it's part of a composite key in a junction table
+ if f["part_type"] == "key":
+ pk_field_id = f["part_id"]
+ # Store the table where this field is the primary key
+ pk_id_to_table[pk_field_id] = (tid, f["label"])
+
+ # Extract relationships from foreign keys
+ # A relationship exists when a field is:
+ # - A property (FK) in the source table (indicated by fk_to being set)
+ # - A primary key in the target table (part_type='key')
+ for table_id, table_info in parts_data["tables"].items():
+ for field in table_info["fields"]:
+ if field.get("fk_to"):
+ # fk_to contains the target field part_id (e.g., "Equipment_model_ID")
+ target_field_id = field["fk_to"]
+
+ # Look up which table has this field as its primary key
+ target_info = pk_id_to_table.get(target_field_id)
+
+ # Only create a relationship if we found a table with this as a primary key
+ if target_info:
+ target_table, target_pk_label = target_info
+ if target_table in parts_data["tables"]:
+ # Use explicit relationship_type from field metadata
+ rel_type = field.get("relationship_type", "one-to-many")
+
+ relationship = ERDRelationship(
+ from_table=table_id,
+ to_table=target_table,
+ from_field=field["label"],
+ to_field=target_pk_label,
+ relationship_type=rel_type,
+ )
+ relationships.append(relationship)
+
+ return {
+ "tables": [asdict(t) for t in tables],
+ "relationships": [asdict(r) for r in relationships],
+ }
+
+
+def generate_erd_html(
+ erd_data: Dict[str, Any], output_path: Path, library: str = "jointjs"
+) -> None:
+ """
+ Generate standalone HTML file with interactive ERD using JointJS.
+
+ Args:
+ erd_data: ERD data from generate_erd_data()
+ output_path: Path to write HTML file
+ library: Deprecated parameter, kept for backward compatibility. Only 'jointjs' is supported.
+ """
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+
+ if library != "jointjs":
+ raise ValueError(
+ f"Unsupported library. Only 'jointjs' library is supported. Got: {library}"
+ )
+
+ html_content = _generate_jointjs_html(erd_data)
+ output_path.write_text(html_content, encoding="utf-8")
+
+
+def _generate_jointjs_html(erd_data: Dict[str, Any]) -> str:
+ """Generate HTML using JointJS library with custom HTML elements (Lucid-like)."""
+
+ # Serialize ERD data as JSON for embedding
+ erd_json = json.dumps(erd_data, indent=2)
+
+ html = f"""
+
+
+
+
+ datEAUbase ERD
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Field Details
+
+
+
+
+ Select a field to view details.
+
+
+
+
+
+
+"""
+
+ return html
+
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/generate_sql.py b/scripts/generate_sql.py
new file mode 100644
index 0000000..e065982
--- /dev/null
+++ b/scripts/generate_sql.py
@@ -0,0 +1,460 @@
+#!/usr/bin/env python3
+"""
+Generate SQL schema from dictionary.
+
+This script extracts SQL generation logic from the main generate_docs hook
+to create a modular, testable component.
+
+Usage:
+ python generate_sql.py
+"""
+
+import sys
+import json
+from pathlib import Path
+from datetime import datetime
+from importlib.metadata import version
+
+# Add src to path to import models
+project_root = Path(__file__).parent.parent
+sys.path.insert(0, str(project_root / "src"))
+
+from open_dateaubase.data_model.models import Dictionary
+
+package_version = version("open-dateaubase")
+
+
+def parse_parts_json(json_path):
+ """
+ Parse dictionary.json using Pydantic validation.
+ Returns same dict structure as parse_parts_table() for compatibility.
+ """
+ # Load and validate
+ with open(json_path, "r", encoding="utf-8") as f:
+ raw_data = json.load(f)
+
+ # Pydantic validation
+ dictionary = Dictionary.model_validate(raw_data)
+
+ # Transform to legacy format for generators
+ data = {"tables": {}, "value_sets": {}, "metadata": {}, "id_field_locations": {}}
+
+ # Process tables
+ for part in dictionary.parts:
+ if part.part_type == "table":
+ data["tables"][part.part_id] = {
+ "label": part.label,
+ "description": part.description,
+ "fields": [],
+ }
+
+ # Process fields
+ for part in dictionary.parts:
+ # Only process field parts that have table_presence
+ if hasattr(part, "table_presence") and part.part_type in [
+ "key",
+ "property",
+ "compositeKeyFirst",
+ "compositeKeySecond",
+ "parentKey",
+ ]:
+ for table_name, presence in part.table_presence.items():
+ if table_name not in data["tables"]:
+ continue
+
+ # Track ID field locations
+ if part.part_id.endswith("_ID"):
+ if part.part_id not in data["id_field_locations"]:
+ data["id_field_locations"][part.part_id] = {}
+ data["id_field_locations"][part.part_id][table_name] = presence.role
+
+ # Determine FK target and relationship type from explicit metadata
+ fk_to = ""
+ relationship_type = None
+ if part.part_type == "parentKey":
+ fk_to = part.ancestor_part_id
+ elif presence.relationship_type:
+ # Infer FK target from field name (field ending in _ID references same-named primary key)
+ if part.part_id.endswith("_ID"):
+ fk_to = part.part_id
+ relationship_type = presence.relationship_type
+
+ field_info = {
+ "part_id": part.part_id,
+ "label": part.label,
+ "description": part.description,
+ "part_type": presence.role,
+ "sql_data_type": getattr(part, "sql_data_type", None) or "",
+ "is_required": presence.required,
+ "default_value": getattr(part, "default_value", None) or "",
+ "fk_to": fk_to,
+ "relationship_type": relationship_type,
+ "value_set": getattr(part, "value_set_part_id", None) or "",
+ "sort_order": presence.order,
+ }
+ data["tables"][table_name]["fields"].append(field_info)
+
+ # Process value sets
+ for part in dictionary.parts:
+ if part.part_type == "valueSet":
+ data["value_sets"][part.part_id] = {
+ "label": part.label,
+ "description": part.description,
+ "members": [],
+ }
+
+ for part in dictionary.parts:
+ if part.part_type == "valueSetMember":
+ value_set_id = part.member_of_set_part_id
+ if value_set_id in data["value_sets"]:
+ member_info = {
+ "part_id": part.part_id,
+ "label": part.label,
+ "description": part.description,
+ "sort_order": part.sort_order if part.sort_order else 999,
+ }
+ data["value_sets"][value_set_id]["members"].append(member_info)
+
+ # Sort fields and members
+ for table in data["tables"].values():
+ table["fields"].sort(key=lambda x: x["sort_order"])
+
+ for value_set in data["value_sets"].values():
+ value_set["members"].sort(key=lambda x: x["sort_order"])
+
+ return data
+
+
+def generate_sql_schemas(parts_data, output_path, db_list):
+ """Generate SQL schemas for multiple database types."""
+ for target_db in db_list:
+ sql_schema = generate_sql_schema(parts_data, target_db=target_db)
+ version_str = package_version
+ filename = f"v{version_str}_as-designed_{target_db}.sql"
+ (output_path / filename).write_text(sql_schema, encoding="utf-8")
+ print(f"Generated SQL schema for {target_db} at {output_path / filename}")
+
+
+def generate_sql_schema(data, target_db="mssql", include_timestamp=True):
+ """
+ Generate SQL CREATE statements from parsed metadata.
+
+ Args:
+ data: Parsed parts table data
+ target_db: Target database flavor ('mssql', 'postgres', 'mysql' - future)
+ include_timestamp: Whether to include generation timestamp (default: True)
+
+ Returns:
+ SQL DDL as a string
+
+ Raises:
+ ValueError: If circular foreign key dependencies detected
+ """
+ # Validate no circular FK dependencies
+ validate_no_circular_fks(data)
+
+ sql = ["-- Auto-generated SQL schema from dictionary.json"]
+ sql.append(f"-- Target database: {target_db.upper()}")
+ if include_timestamp:
+ sql.append(f"-- Generated: {datetime.now().isoformat()}")
+ sql.append("\n")
+
+ # Get DB-specific config
+ db_config = get_db_config(target_db)
+
+ # First pass: Create all tables without foreign keys
+ for table_id, table_info in sorted(data["tables"].items()):
+ sql.append(f"\n-- {table_info['description']}")
+ sql.append(f"CREATE TABLE {db_config['quote'](table_id)} (")
+
+ field_definitions = []
+ pk_fields = []
+
+ for field in table_info["fields"]:
+ field_def = generate_field_definition(field, data, db_config)
+ field_definitions.append(field_def)
+
+ # Track primary key fields
+ if field["part_type"] in ["key", "compositeKeyFirst", "compositeKeySecond"]:
+ field_name = extract_field_name(field["part_id"])
+ pk_fields.append(f"{db_config['quote'](field_name)}")
+
+ # Add primary key constraint
+ if pk_fields:
+ pk_name = "PK_" + table_id
+ pk_constraint = f" CONSTRAINT {db_config['quote'](pk_name)} PRIMARY KEY ({', '.join(pk_fields)})"
+ field_definitions.append(pk_constraint)
+
+ sql.append(",\n".join(field_definitions))
+ sql.append(");\n")
+
+ # Second pass: Add foreign key constraints
+ sql.append("\n-- Foreign Key Constraints\n")
+ for table_id, table_info in sorted(data["tables"].items()):
+ for field in table_info["fields"]:
+ if field["fk_to"]:
+ fk_sql = generate_foreign_key_constraint(table_id, field, data, db_config)
+ if fk_sql:
+ sql.append(fk_sql)
+
+ return "\n".join(sql)
+
+
+def get_db_config(target_db):
+ """
+ Get database-specific configuration.
+
+ Args:
+ target_db: Database flavor string
+
+ Returns:
+ Dict with DB-specific settings
+ """
+ configs = {
+ "mssql": {
+ "quote_char": "[",
+ "quote_char_end": "]",
+ "type_mappings": {
+ "nvarchar": "nvarchar",
+ "ntext": "nvarchar(max)", # ntext deprecated in modern MSSQL
+ "int": "int",
+ "float": "float",
+ "real": "real",
+ "numeric": "numeric",
+ "bit": "bit",
+ },
+ "supports_check_constraints": True,
+ "supports_deferred_constraints": False,
+ },
+ # Future: postgres, mysql, sqlite configs
+ }
+
+ if target_db not in configs:
+ raise ValueError(
+ f"Unsupported database: {target_db}. Supported: {list(configs.keys())}"
+ )
+
+ config = configs[target_db]
+
+ # Add convenience method for quoting identifiers
+ if config["quote_char_end"]:
+ config["quote"] = (
+ lambda name: f"{config['quote_char']}{name}{config['quote_char_end']}"
+ )
+ else:
+ config["quote"] = (
+ lambda name: f"{config['quote_char']}{name}{config['quote_char_end']}"
+ )
+
+ return config
+
+
+def extract_field_name(part_id):
+ """
+ Extract field name from Part_ID.
+
+ NEW FORMAT handling:
+ - ID fields (e.g., 'Equipment_ID', 'Project_ID'): Use as-is (these are actual SQL field names)
+ - Table-prefixed fields (e.g., 'site_City', 'purpose_Description'): Remove table prefix
+ - Non-prefixed fields: Use as-is
+
+ Args:
+ part_id: Part_ID from dictionary
+
+ Returns:
+ Field name to use in SQL
+ """
+ # ID fields are used as-is in SQL
+ if part_id.endswith("_ID"):
+ return part_id
+
+ # Table-prefixed non-ID fields: remove prefix
+ # Format is lowercase_table_MixedCaseField (e.g., 'site_City', 'contact_City')
+ if "_" in part_id:
+ # Check if first part looks like a table name (lowercase)
+ parts = part_id.split("_", 1)
+ if len(parts) == 2 and parts[0].islower():
+ # This is likely a table-prefixed field, remove prefix
+ return parts[1]
+
+ # Otherwise use as-is
+ return part_id
+
+
+def validate_no_circular_fks(data):
+ """
+ Check for circular foreign key dependencies between tables.
+
+ Args:
+ data: Parsed parts table data
+
+ Raises:
+ ValueError: If circular FK dependencies found
+ """
+ # Build adjacency list of FK relationships
+ fk_graph = {table_id: set() for table_id in data["tables"]}
+
+ for table_id, table_info in data["tables"].items():
+ for field in table_info["fields"]:
+ if field["fk_to"] and "_" in field["fk_to"]:
+ target_table = field["fk_to"].split("_", 1)[0]
+ if target_table in fk_graph:
+ fk_graph[table_id].add(target_table)
+
+ # Check for bidirectional relationships (A->B and B->A)
+ circular_deps = []
+ for table_a, targets in fk_graph.items():
+ for table_b in targets:
+ if table_b == table_a:
+ # self-referential FKs are allowed
+ continue
+ if table_a in fk_graph.get(table_b, set()):
+ # Found circular dependency
+ pair = tuple(sorted([table_a, table_b]))
+ if pair not in circular_deps:
+ circular_deps.append(pair)
+
+ if circular_deps:
+ error_msg = "Circular foreign key dependencies detected:\n"
+ for table_a, table_b in circular_deps:
+ error_msg += f" - {table_a} ↔ {table_b}\n"
+ error_msg += "\nEach pair of tables has FKs pointing to each other, which creates ambiguity in table creation order."
+ raise ValueError(error_msg)
+
+
+def generate_field_definition(field, data, db_config):
+ """
+ Generate SQL field definition with constraints.
+
+ Args:
+ field: Field metadata dict
+ data: Full parsed data (for value set lookups)
+ db_config: Database-specific configuration
+
+ Returns:
+ SQL field definition string
+ """
+ field_name = extract_field_name(field["part_id"])
+
+ quote = db_config["quote"]
+
+ parts = [f" {quote(field_name)}"]
+
+ # Data type with mapping
+ sql_type = field["sql_data_type"] if field["sql_data_type"] else "nvarchar(255)"
+ # Apply type mapping for target DB
+ base_type = sql_type.split("(")[
+ 0
+ ] # Extract base type (e.g., 'nvarchar' from 'nvarchar(255)')
+ if base_type in db_config["type_mappings"]:
+ # Preserve parameters if they exist
+ if "(" in sql_type:
+ params = sql_type[sql_type.index("(") :]
+ sql_type = db_config["type_mappings"][base_type].split("(")[0] + params
+ else:
+ sql_type = db_config["type_mappings"][base_type]
+
+ parts.append(sql_type)
+
+ # NULL constraint
+ if field["is_required"]:
+ parts.append("NOT NULL")
+ else:
+ parts.append("NULL")
+
+ # Default value
+ if field["default_value"]:
+ default_val = field["default_value"]
+ # Handle boolean defaults
+ if default_val in ["True", "False"]:
+ default_val = "1" if default_val == "True" else "0"
+ # Handle numeric vs string defaults
+ if field["sql_data_type"] and field["sql_data_type"].split("(")[0] in [
+ "int",
+ "float",
+ "real",
+ "numeric",
+ "bit",
+ ]:
+ parts.append(f"DEFAULT {default_val}")
+ else:
+ parts.append(f"DEFAULT '{default_val}'")
+
+ # Note: Value set CHECK constraints removed per requirement #3
+ # Future: could add back conditionally based on target_db config
+
+ return " ".join(parts)
+
+
+def generate_foreign_key_constraint(table_id, field, data, db_config):
+ """
+ Generate ALTER TABLE statement for foreign key.
+
+ NEW FORMAT: fk_to is Part_ID of target field (e.g., 'TestTable_ID')
+ We need to find which table has this field as a primary key by looking up
+ the table Part_ID from the data model.
+
+ Args:
+ table_id: Source table ID
+ field: Field metadata with FK reference
+ data: Full parsed data (for id_field_locations lookup)
+ db_config: Database-specific configuration
+
+ Returns:
+ SQL ALTER TABLE statement or None
+ """
+ if not field["fk_to"]:
+ return None
+
+ # fk_to is Part_ID of target (e.g., 'TestTable_ID')
+ fk_target = field["fk_to"]
+ source_field = extract_field_name(field["part_id"])
+
+ # For ID fields like 'TestTable_ID', extract table name from FK field
+ # This preserves the capitalization pattern from the data model
+ if fk_target.endswith("_ID"):
+ target_field = fk_target # e.g., 'TestTable_ID'
+ # Extract table name from FK field name (e.g., 'Equipment_model_ID' -> 'Equipment_model')
+ # This matches the capitalization used in the original data model design
+ target_table = fk_target[:-3] # Remove '_ID'
+ else:
+ # Non-ID FK (shouldn't happen in new format, but fallback)
+ return None
+
+ quote = db_config["quote"]
+ constraint_name = f"FK_{table_id}_{source_field}"
+
+ sql = f"""ALTER TABLE {quote(table_id)}
+ ADD CONSTRAINT {quote(constraint_name)}
+ FOREIGN KEY ({quote(source_field)})
+ REFERENCES {quote(target_table)} ({quote(target_field)});
+"""
+
+ return sql
+
+
+def main():
+ """Main entry point for script."""
+ if len(sys.argv) != 4:
+ print("Usage: python generate_sql.py ")
+ print(
+ "Example: python generate_sql.py dictionary.json sql_generation_scripts mssql"
+ )
+ sys.exit(1)
+
+ json_path = Path(sys.argv[1])
+ output_path = Path(sys.argv[2])
+ db_list = sys.argv[3].split(",") # Comma-separated list of databases
+
+ # Ensure output directory exists
+ output_path.mkdir(parents=True, exist_ok=True)
+
+ # Parse JSON
+ parts_data = parse_parts_json(json_path)
+
+ # Generate SQL schemas
+ generate_sql_schemas(parts_data, output_path, db_list)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/orchestrate_docs.py b/scripts/orchestrate_docs.py
new file mode 100644
index 0000000..5890685
--- /dev/null
+++ b/scripts/orchestrate_docs.py
@@ -0,0 +1,129 @@
+#!/usr/bin/env python3
+"""
+Documentation generation orchestrator.
+
+This script coordinates the generation of all documentation components:
+- Dictionary reference (tables and value sets)
+- ERD diagrams
+- SQL schemas
+- Asset copying
+
+Usage:
+ python generate_docs.py [target_dbs]
+"""
+
+import sys
+import subprocess
+from pathlib import Path
+
+# Add scripts to path to import our modules
+project_root = Path(__file__).parent
+sys.path.insert(0, str(project_root))
+
+from generate_dictionary_reference import (
+ parse_parts_json,
+ generate_tables_markdown,
+ generate_value_sets_markdown,
+)
+from generate_erd import parse_erd_json, generate_erd_files
+from generate_sql import parse_parts_json as parse_sql_json, generate_sql_schemas
+
+
+def copy_generated_assets(assets_dir):
+ """Copy generated HTML assets to be served by MkDocs."""
+
+ # Files to copy - specifically the generated ERD files
+ generated_files = ["erd_interactive.html"]
+
+ print(f"Checking for assets in {assets_dir.absolute()}")
+
+ for filename in generated_files:
+ source_path = assets_dir / filename
+ if source_path.exists():
+ # Target path in built site (assets/filename)
+ target_path = f"assets/{filename}"
+
+ print(f"Copying {filename} -> {target_path}")
+
+ # Use mkdocs_gen_files to register the file with MkDocs
+ try:
+ import mkdocs_gen_files
+
+ content = source_path.read_text(encoding="utf-8")
+ with mkdocs_gen_files.open(target_path, "w") as f:
+ f.write(content)
+ except ImportError:
+ print(
+ "Warning: mkdocs_gen_files not available, assets may not be included in build"
+ )
+ except Exception as e:
+ print(f"Error copying {filename}: {e}")
+ else:
+ print(f"Warning: Expected asset {filename} not found in {assets_dir}")
+
+
+def main():
+ """Main entry point for orchestrator."""
+ if len(sys.argv) < 5:
+ print(
+ "Usage: python generate_docs.py [target_dbs]"
+ )
+ print(
+ "Example: python generate_docs.py dictionary.json docs/reference sql_generation_scripts docs/assets mssql"
+ )
+ sys.exit(1)
+
+ json_path = Path(sys.argv[1])
+ docs_dir = Path(sys.argv[2])
+ sql_dir = Path(sys.argv[3])
+ assets_dir = Path(sys.argv[4])
+
+ # Default to mssql if no databases specified
+ target_dbs = sys.argv[5].split(",") if len(sys.argv) > 5 else ["mssql"]
+
+ # Ensure directories exist
+ docs_dir.mkdir(parents=True, exist_ok=True)
+ sql_dir.mkdir(parents=True, exist_ok=True)
+ assets_dir.mkdir(parents=True, exist_ok=True)
+
+ print(f"Generating documentation from {json_path}")
+ print(f"Output directories:")
+ print(f" Docs: {docs_dir}")
+ print(f" SQL: {sql_dir}")
+ print(f" Assets: {assets_dir}")
+ print(f" Target databases: {target_dbs}")
+
+ # Parse JSON once (each script can parse independently)
+ parts_data = parse_parts_json(json_path)
+
+ # Generate dictionary reference
+ print("\n=== Generating Dictionary Reference ===")
+ tables = generate_tables_markdown(parts_data)
+ value_sets = generate_value_sets_markdown(parts_data)
+
+ (docs_dir / "tables.md").write_text(tables, encoding="utf-8")
+ print(f"Generated tables documentation: {docs_dir / 'tables.md'}")
+
+ (docs_dir / "valuesets.md").write_text(value_sets, encoding="utf-8")
+ print(f"Generated value sets documentation: {docs_dir / 'valuesets.md'}")
+
+ # Generate ERD
+ print("\n=== Generating ERD ===")
+ erd_parts_data = parse_erd_json(json_path)
+ generate_erd_files(erd_parts_data, assets_dir, docs_dir)
+
+ # Generate SQL schemas
+ print("\n=== Generating SQL Schemas ===")
+ sql_parts_data = parse_sql_json(json_path)
+ generate_sql_schemas(sql_parts_data, sql_dir, target_dbs)
+
+ # Copy assets
+ print("\n=== Copying Assets ===")
+ copy_generated_assets(assets_dir)
+
+ print("\n=== Documentation Generation Complete ===")
+ print("All components generated successfully!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/sql_generation_scripts/v0.1.0_as-designed_mssql.sql b/sql_generation_scripts/v0.1.0_as-designed_mssql.sql
index 45eb0dd..fb7c6ff 100644
--- a/sql_generation_scripts/v0.1.0_as-designed_mssql.sql
+++ b/sql_generation_scripts/v0.1.0_as-designed_mssql.sql
@@ -1,10 +1,10 @@
--- Auto-generated SQL schema from Parts metadata table
+-- Auto-generated SQL schema from dictionary.json
-- Target database: MSSQL
--- Generated: 2025-10-28T20:58:28.454637
+-- Generated: 2025-12-10T18:46:32.837716
--- Table for Comments
+-- Stores any additional textual comments, notes, or observations related to a specific measured value
CREATE TABLE [comments] (
[Comment] nvarchar(1073741823) NULL,
[Comment_ID] int NULL,
@@ -12,7 +12,7 @@ CREATE TABLE [comments] (
);
--- Table for Contact
+-- Stores detailed personal and professional information for people involved in projects (e.g., name, affiliation, function, e-mail, phone)
CREATE TABLE [contact] (
[Company] nvarchar(1073741823) NULL,
[Contact_ID] int NULL,
@@ -35,7 +35,7 @@ CREATE TABLE [contact] (
);
--- Table for Equipment
+-- Stores information about a specific, physical piece of equipment (e.g., serial number, owner, purchase date, storage location)
CREATE TABLE [equipment] (
[Equipment_ID] int NULL,
[Equipment_identifier] nvarchar(100) NULL,
@@ -48,7 +48,7 @@ CREATE TABLE [equipment] (
);
--- Table for Equipment Model
+-- Stores detailed, non-redundant specifications for a specific sensor or instrument model (e.g., manufacturer, functions, method)
CREATE TABLE [equipment_model] (
[Equipment_model] nvarchar(100) NULL,
[Equipment_model_ID] int NULL,
@@ -60,7 +60,7 @@ CREATE TABLE [equipment_model] (
);
--- Table for Equipment Model Has Parameter
+-- Links equipment models to the parameters they can measure
CREATE TABLE [equipment_model_has_Parameter] (
[Equipment_model_ID] int NULL,
[Parameter_ID] int NULL,
@@ -68,7 +68,7 @@ CREATE TABLE [equipment_model_has_Parameter] (
);
--- Table for Equipment Model Has Procedures
+-- Links equipment models to the relevant maintenance procedures
CREATE TABLE [equipment_model_has_procedures] (
[Equipment_model_ID] int NULL,
[Procedure_ID] int NULL,
@@ -76,7 +76,7 @@ CREATE TABLE [equipment_model_has_procedures] (
);
--- Table for Hydrological Characteristics
+-- Stores the hydrological land use percentages (e.g., forest, wetlands, cropland, grassland) within the watershed
CREATE TABLE [hydrological_characteristics] (
[Cropland] real NULL,
[Forest] real NULL,
@@ -89,7 +89,7 @@ CREATE TABLE [hydrological_characteristics] (
);
--- Table for Metadata
+-- Contains a list of all existing unique metadata combinations (represented by a series of foreign keys/IDs) that describe a single measurement
CREATE TABLE [metadata] (
[Condition_ID] int NULL,
[Contact_ID] int NULL,
@@ -105,7 +105,7 @@ CREATE TABLE [metadata] (
);
--- Table for Parameter
+-- Stores the different water quality or quantity parameters that are measured (e.g., pH, TSS, N-components)
CREATE TABLE [parameter] (
[Parameter] nvarchar(100) NULL,
[Parameter_ID] int NULL,
@@ -115,7 +115,7 @@ CREATE TABLE [parameter] (
);
--- Table for Parameter Has Procedures
+-- Links parameters to the relevant measurement procedures
CREATE TABLE [parameter_has_procedures] (
[Parameter_ID] int NULL,
[Procedure_ID] int NULL,
@@ -123,7 +123,7 @@ CREATE TABLE [parameter_has_procedures] (
);
--- Table for Procedures
+-- Stores details for different measurement procedures (e.g., calibration, validation, standard operating procedures, ISO methods)
CREATE TABLE [procedures] (
[Procedure_ID] int NULL,
[Procedure_location] nvarchar(100) NULL,
@@ -134,7 +134,7 @@ CREATE TABLE [procedures] (
);
--- Table for Project
+-- Stores descriptive information about the research or monitoring project for which the data was collected
CREATE TABLE [project] (
[Project_ID] int NULL,
[Project_name] nvarchar(100) NULL,
@@ -143,7 +143,7 @@ CREATE TABLE [project] (
);
--- Table for Project Has Contact
+-- Links projects to the personnel involved in them
CREATE TABLE [project_has_contact] (
[Contact_ID] int NULL,
[Project_ID] int NULL,
@@ -151,7 +151,7 @@ CREATE TABLE [project_has_contact] (
);
--- Table for Project Has Equipment
+-- Links projects to the specific equipment used within them
CREATE TABLE [project_has_equipment] (
[Equipment_ID] int NULL,
[Project_ID] int NULL,
@@ -159,7 +159,7 @@ CREATE TABLE [project_has_equipment] (
);
--- Table for Project Has Sampling Points
+-- Links projects to the sampling points used within them
CREATE TABLE [project_has_sampling_points] (
[Project_ID] int NULL,
[Sampling_point_ID] int NULL,
@@ -167,7 +167,7 @@ CREATE TABLE [project_has_sampling_points] (
);
--- Table for Purpose
+-- Stores information about the aim of the measurement (e.g., on-line measurement, laboratory analysis, calibration, validation, cleaning)
CREATE TABLE [purpose] (
[Purpose] nvarchar(100) NULL,
[Purpose_ID] int NULL,
@@ -176,7 +176,7 @@ CREATE TABLE [purpose] (
);
--- Table for Sampling Points
+-- Stores the identification, specific geographical coordinates (Latitude/Longitude/GPS), and description of a particular spot where a sample or measurement is taken
CREATE TABLE [sampling_points] (
[Latitude_GPS] nvarchar(100) NULL,
[Longitude_GPS] nvarchar(100) NULL,
@@ -190,7 +190,7 @@ CREATE TABLE [sampling_points] (
);
--- Table for Site
+-- Stores general site information, including address, site type, and a link to the associated watershed
CREATE TABLE [site] (
[Picture] image(2147483647) NULL,
[Province] nvarchar(255) NULL,
@@ -208,7 +208,7 @@ CREATE TABLE [site] (
);
--- Table for Unit
+-- Stores the SI units of measurement (or other relevant units) corresponding to the parameters (e.g., mg/L, g/L, s)
CREATE TABLE [unit] (
[Unit] nvarchar(100) NULL,
[Unit_ID] int NULL,
@@ -216,7 +216,7 @@ CREATE TABLE [unit] (
);
--- Table for Urban Characteristics
+-- Stores the urban land use percentages (e.g., commercial, residential, green spaces) within the watershed
CREATE TABLE [urban_characteristics] (
[Agricultural] real NULL,
[Commercial] real NULL,
@@ -230,7 +230,7 @@ CREATE TABLE [urban_characteristics] (
);
--- Table for Value
+-- Stores each measured water quality or quantity value, its time stamp, replicate identification, and the link to its specific metadata set
CREATE TABLE [value] (
[Comment_ID] int NULL,
[Metadata_ID] int NULL,
@@ -242,7 +242,7 @@ CREATE TABLE [value] (
);
--- Table for Watershed
+-- Stores general information about the watershed area, including surface area, concentration time, and impervious surface percentage
CREATE TABLE [watershed] (
[Concentration_time] int NULL,
[Impervious_surface] real NULL,
@@ -254,7 +254,7 @@ CREATE TABLE [watershed] (
);
--- Table for Weather Condition
+-- Stores descriptive information about the prevailing weather conditions when the measurement was taken (e.g., dry weather, wet weather, snow melt)
CREATE TABLE [weather_condition] (
[Condition_ID] int NULL,
[Weather_condition] nvarchar(100) NULL,
@@ -270,6 +270,26 @@ ALTER TABLE [equipment]
FOREIGN KEY ([Equipment_model_ID])
REFERENCES [Equipment_model] ([Equipment_model_ID]);
+ALTER TABLE [equipment_model_has_Parameter]
+ ADD CONSTRAINT [FK_equipment_model_has_Parameter_Equipment_model_ID]
+ FOREIGN KEY ([Equipment_model_ID])
+ REFERENCES [Equipment_model] ([Equipment_model_ID]);
+
+ALTER TABLE [equipment_model_has_procedures]
+ ADD CONSTRAINT [FK_equipment_model_has_procedures_Equipment_model_ID]
+ FOREIGN KEY ([Equipment_model_ID])
+ REFERENCES [Equipment_model] ([Equipment_model_ID]);
+
+ALTER TABLE [equipment_model_has_procedures]
+ ADD CONSTRAINT [FK_equipment_model_has_procedures_Procedure_ID]
+ FOREIGN KEY ([Procedure_ID])
+ REFERENCES [Procedure] ([Procedure_ID]);
+
+ALTER TABLE [hydrological_characteristics]
+ ADD CONSTRAINT [FK_hydrological_characteristics_Watershed_ID]
+ FOREIGN KEY ([Watershed_ID])
+ REFERENCES [Watershed] ([Watershed_ID]);
+
ALTER TABLE [metadata]
ADD CONSTRAINT [FK_metadata_Condition_ID]
FOREIGN KEY ([Condition_ID])
@@ -320,6 +340,41 @@ ALTER TABLE [parameter]
FOREIGN KEY ([Unit_ID])
REFERENCES [Unit] ([Unit_ID]);
+ALTER TABLE [parameter_has_procedures]
+ ADD CONSTRAINT [FK_parameter_has_procedures_Procedure_ID]
+ FOREIGN KEY ([Procedure_ID])
+ REFERENCES [Procedure] ([Procedure_ID]);
+
+ALTER TABLE [project_has_contact]
+ ADD CONSTRAINT [FK_project_has_contact_Contact_ID]
+ FOREIGN KEY ([Contact_ID])
+ REFERENCES [Contact] ([Contact_ID]);
+
+ALTER TABLE [project_has_contact]
+ ADD CONSTRAINT [FK_project_has_contact_Project_ID]
+ FOREIGN KEY ([Project_ID])
+ REFERENCES [Project] ([Project_ID]);
+
+ALTER TABLE [project_has_equipment]
+ ADD CONSTRAINT [FK_project_has_equipment_Equipment_ID]
+ FOREIGN KEY ([Equipment_ID])
+ REFERENCES [Equipment] ([Equipment_ID]);
+
+ALTER TABLE [project_has_equipment]
+ ADD CONSTRAINT [FK_project_has_equipment_Project_ID]
+ FOREIGN KEY ([Project_ID])
+ REFERENCES [Project] ([Project_ID]);
+
+ALTER TABLE [project_has_sampling_points]
+ ADD CONSTRAINT [FK_project_has_sampling_points_Project_ID]
+ FOREIGN KEY ([Project_ID])
+ REFERENCES [Project] ([Project_ID]);
+
+ALTER TABLE [project_has_sampling_points]
+ ADD CONSTRAINT [FK_project_has_sampling_points_Sampling_point_ID]
+ FOREIGN KEY ([Sampling_point_ID])
+ REFERENCES [Sampling_point] ([Sampling_point_ID]);
+
ALTER TABLE [sampling_points]
ADD CONSTRAINT [FK_sampling_points_Site_ID]
FOREIGN KEY ([Site_ID])
@@ -330,6 +385,11 @@ ALTER TABLE [site]
FOREIGN KEY ([Watershed_ID])
REFERENCES [Watershed] ([Watershed_ID]);
+ALTER TABLE [urban_characteristics]
+ ADD CONSTRAINT [FK_urban_characteristics_Watershed_ID]
+ FOREIGN KEY ([Watershed_ID])
+ REFERENCES [Watershed] ([Watershed_ID]);
+
ALTER TABLE [value]
ADD CONSTRAINT [FK_value_Comment_ID]
FOREIGN KEY ([Comment_ID])
diff --git a/src/dictionary.csv b/src/dictionary.csv
deleted file mode 100644
index 2d3b6aa..0000000
--- a/src/dictionary.csv
+++ /dev/null
@@ -1,206 +0,0 @@
-Part_ID,Label,Description,Part_type,Value_set_part_ID,Member_of_set_part_ID,Ancestor_part_ID,SQL_data_type,Is_required,Default_value,Sort_order,Parts_present,comments_present,contact_present,equipment_present,equipment_model_present,equipment_model_has_Parameter_present,equipment_model_has_procedures_present,hydrological_characteristics_present,metadata_present,parameter_present,parameter_has_procedures_present,procedures_present,project_present,project_has_contact_present,project_has_equipment_present,project_has_sampling_points_present,purpose_present,sampling_points_present,site_present,unit_present,urban_characteristics_present,value_present,watershed_present,weather_condition_present,Parts_required,Parts_order,comments_required,comments_order,contact_required,contact_order,equipment_required,equipment_order,equipment_model_required,equipment_model_order,equipment_model_has_Parameter_required,equipment_model_has_Parameter_order,equipment_model_has_procedures_required,equipment_model_has_procedures_order,hydrological_characteristics_required,hydrological_characteristics_order,metadata_required,metadata_order,parameter_required,parameter_order,parameter_has_procedures_required,parameter_has_procedures_order,procedures_required,procedures_order,project_required,project_order,project_has_contact_required,project_has_contact_order,project_has_equipment_required,project_has_equipment_order,project_has_sampling_points_required,project_has_sampling_points_order,purpose_required,purpose_order,sampling_points_required,sampling_points_order,site_required,site_order,unit_required,unit_order,urban_characteristics_required,urban_characteristics_order,value_required,value_order,watershed_required,watershed_order,weather_condition_required,weather_condition_order
-Parts,Parts,Metadata table defining all components of the database model,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Part_ID,Part ID,Unique identifier for each part in the data model,key,,,,nvarchar(255),True,,1,key,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Label,Label,Human-readable label for the part,property,,,,nvarchar(255),True,,2,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Description,Description,Detailed description of what the part represents,property,,,,ntext,True,,3,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Part_type,Part Type,Classification of the database component,property,,,,nvarchar(50),True,,4,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Value_set_part_ID,Value Set Part ID,Which value set constrains this property,property,,,,nvarchar(255),False,,5,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Member_of_set_part_ID,Member Of Set Part ID,Which value set this part is a member of,property,,,,nvarchar(255),False,,6,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Ancestor_part_ID,Ancestor Part ID,"For parentKey type, the Part_ID of the ancestor being referenced",property,,,,nvarchar(255),False,,7,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-SQL_data_type,SQL Data Type,SQL data type of the column,property,,,,nvarchar(100),False,,8,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Is_required,Is Required,Whether this field is mandatory,property,,,,bit,False,,9,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Default_value,Default Value,Default value for the field,property,,,,ntext,False,,10,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Sort_order,Sort Order,Display order for documentation and UI,property,,,,int,False,,11,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-comments_present,Comments Present,Indicates whether and how a field appears in the comments table,property,,,,nvarchar(50),False,,11,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-comments_required,Comments Required,Whether this part is required in the comments table,property,,,,bit,False,,12,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-comments_order,Comments Order,Display order of this part in the comments table,property,,,,int,False,,13,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-contact_present,Contact Present,Indicates whether and how a field appears in the contact table,property,,,,nvarchar(50),False,,14,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-contact_required,Contact Required,Whether this part is required in the contact table,property,,,,bit,False,,15,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-contact_order,Contact Order,Display order of this part in the contact table,property,,,,int,False,,16,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-equipment_present,Equipment Present,Indicates whether and how a field appears in the equipment table,property,,,,nvarchar(50),False,,17,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-equipment_required,Equipment Required,Whether this part is required in the equipment table,property,,,,bit,False,,18,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-equipment_order,Equipment Order,Display order of this part in the equipment table,property,,,,int,False,,19,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-equipment_model_present,Equipment Model Present,Indicates whether and how a field appears in the equipment_model table,property,,,,nvarchar(50),False,,20,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-equipment_model_required,Equipment Model Required,Whether this part is required in the equipment_model table,property,,,,bit,False,,21,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-equipment_model_order,Equipment Model Order,Display order of this part in the equipment_model table,property,,,,int,False,,22,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-equipment_model_has_Parameter_present,Equipment Model Has Parameter Present,Indicates whether and how a field appears in the equipment_model_has_Parameter table,property,,,,nvarchar(50),False,,23,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-equipment_model_has_Parameter_required,Equipment Model Has Parameter Required,Whether this part is required in the equipment_model_has_Parameter table,property,,,,bit,False,,24,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-equipment_model_has_Parameter_order,Equipment Model Has Parameter Order,Display order of this part in the equipment_model_has_Parameter table,property,,,,int,False,,25,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-equipment_model_has_procedures_present,Equipment Model Has Procedures Present,Indicates whether and how a field appears in the equipment_model_has_procedures table,property,,,,nvarchar(50),False,,26,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-equipment_model_has_procedures_required,Equipment Model Has Procedures Required,Whether this part is required in the equipment_model_has_procedures table,property,,,,bit,False,,27,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-equipment_model_has_procedures_order,Equipment Model Has Procedures Order,Display order of this part in the equipment_model_has_procedures table,property,,,,int,False,,28,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-hydrological_characteristics_present,Hydrological Characteristics Present,Indicates whether and how a field appears in the hydrological_characteristics table,property,,,,nvarchar(50),False,,29,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-hydrological_characteristics_required,Hydrological Characteristics Required,Whether this part is required in the hydrological_characteristics table,property,,,,bit,False,,30,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-hydrological_characteristics_order,Hydrological Characteristics Order,Display order of this part in the hydrological_characteristics table,property,,,,int,False,,31,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-metadata_present,Metadata Present,Indicates whether and how a field appears in the metadata table,property,,,,nvarchar(50),False,,32,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-metadata_required,Metadata Required,Whether this part is required in the metadata table,property,,,,bit,False,,33,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-metadata_order,Metadata Order,Display order of this part in the metadata table,property,,,,int,False,,34,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-parameter_present,Parameter Present,Indicates whether and how a field appears in the parameter table,property,,,,nvarchar(50),False,,35,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-parameter_required,Parameter Required,Whether this part is required in the parameter table,property,,,,bit,False,,36,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-parameter_order,Parameter Order,Display order of this part in the parameter table,property,,,,int,False,,37,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-parameter_has_procedures_present,Parameter Has Procedures Present,Indicates whether and how a field appears in the parameter_has_procedures table,property,,,,nvarchar(50),False,,38,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-parameter_has_procedures_required,Parameter Has Procedures Required,Whether this part is required in the parameter_has_procedures table,property,,,,bit,False,,39,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-parameter_has_procedures_order,Parameter Has Procedures Order,Display order of this part in the parameter_has_procedures table,property,,,,int,False,,40,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-procedures_present,Procedures Present,Indicates whether and how a field appears in the procedures table,property,,,,nvarchar(50),False,,41,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-procedures_required,Procedures Required,Whether this part is required in the procedures table,property,,,,bit,False,,42,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-procedures_order,Procedures Order,Display order of this part in the procedures table,property,,,,int,False,,43,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-project_present,Project Present,Indicates whether and how a field appears in the project table,property,,,,nvarchar(50),False,,44,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-project_required,Project Required,Whether this part is required in the project table,property,,,,bit,False,,45,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-project_order,Project Order,Display order of this part in the project table,property,,,,int,False,,46,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-project_has_contact_present,Project Has Contact Present,Indicates whether and how a field appears in the project_has_contact table,property,,,,nvarchar(50),False,,47,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-project_has_contact_required,Project Has Contact Required,Whether this part is required in the project_has_contact table,property,,,,bit,False,,48,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-project_has_contact_order,Project Has Contact Order,Display order of this part in the project_has_contact table,property,,,,int,False,,49,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-project_has_equipment_present,Project Has Equipment Present,Indicates whether and how a field appears in the project_has_equipment table,property,,,,nvarchar(50),False,,50,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-project_has_equipment_required,Project Has Equipment Required,Whether this part is required in the project_has_equipment table,property,,,,bit,False,,51,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-project_has_equipment_order,Project Has Equipment Order,Display order of this part in the project_has_equipment table,property,,,,int,False,,52,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-project_has_sampling_points_present,Project Has Sampling Points Present,Indicates whether and how a field appears in the project_has_sampling_points table,property,,,,nvarchar(50),False,,53,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-project_has_sampling_points_required,Project Has Sampling Points Required,Whether this part is required in the project_has_sampling_points table,property,,,,bit,False,,54,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-project_has_sampling_points_order,Project Has Sampling Points Order,Display order of this part in the project_has_sampling_points table,property,,,,int,False,,55,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-purpose_present,Purpose Present,Indicates whether and how a field appears in the purpose table,property,,,,nvarchar(50),False,,56,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-purpose_required,Purpose Required,Whether this part is required in the purpose table,property,,,,bit,False,,57,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-purpose_order,Purpose Order,Display order of this part in the purpose table,property,,,,int,False,,58,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-sampling_points_present,Sampling Points Present,Indicates whether and how a field appears in the sampling_points table,property,,,,nvarchar(50),False,,59,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-sampling_points_required,Sampling Points Required,Whether this part is required in the sampling_points table,property,,,,bit,False,,60,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-sampling_points_order,Sampling Points Order,Display order of this part in the sampling_points table,property,,,,int,False,,61,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-site_present,Site Present,Indicates whether and how a field appears in the site table,property,,,,nvarchar(50),False,,62,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-site_required,Site Required,Whether this part is required in the site table,property,,,,bit,False,,63,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-site_order,Site Order,Display order of this part in the site table,property,,,,int,False,,64,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-unit_present,Unit Present,Indicates whether and how a field appears in the unit table,property,,,,nvarchar(50),False,,65,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-unit_required,Unit Required,Whether this part is required in the unit table,property,,,,bit,False,,66,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-unit_order,Unit Order,Display order of this part in the unit table,property,,,,int,False,,67,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-urban_characteristics_present,Urban Characteristics Present,Indicates whether and how a field appears in the urban_characteristics table,property,,,,nvarchar(50),False,,68,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-urban_characteristics_required,Urban Characteristics Required,Whether this part is required in the urban_characteristics table,property,,,,bit,False,,69,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-urban_characteristics_order,Urban Characteristics Order,Display order of this part in the urban_characteristics table,property,,,,int,False,,70,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-value_present,Value Present,Indicates whether and how a field appears in the value table,property,,,,nvarchar(50),False,,71,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-value_required,Value Required,Whether this part is required in the value table,property,,,,bit,False,,72,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-value_order,Value Order,Display order of this part in the value table,property,,,,int,False,,73,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-watershed_present,Watershed Present,Indicates whether and how a field appears in the watershed table,property,,,,nvarchar(50),False,,74,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-watershed_required,Watershed Required,Whether this part is required in the watershed table,property,,,,bit,False,,75,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-watershed_order,Watershed Order,Display order of this part in the watershed table,property,,,,int,False,,76,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-weather_condition_present,Weather Condition Present,Indicates whether and how a field appears in the weather_condition table,property,,,,nvarchar(50),False,,77,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-weather_condition_required,Weather Condition Required,Whether this part is required in the weather_condition table,property,,,,bit,False,,78,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-weather_condition_order,Weather Condition Order,Display order of this part in the weather_condition table,property,,,,int,False,,79,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Part_type_set,Part Type Set,Valid values for part types,valueSet,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-table,Table,Represents a database table,valueSetMember,,Part_type_set,,nvarchar(50),,,1,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-key,Key,Represents a primary key,valueSetMember,,Part_type_set,,nvarchar(50),,,2,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-property,Property,Represents a column/field in a table,valueSetMember,,Part_type_set,,nvarchar(50),,,3,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-compositeKeyFirst,Composite Key First,First component of a composite primary key,valueSetMember,,Part_type_set,,nvarchar(50),,,4,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-compositeKeySecond,Composite Key Second,Second component of a composite primary key,valueSetMember,,Part_type_set,,nvarchar(50),,,5,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-parentKey,Parent Key,Hierarchical reference to parent record in same table,valueSetMember,,Part_type_set,,nvarchar(50),,,6,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-valueSet,Value Set,Represents an enumeration or controlled vocabulary,valueSetMember,,Part_type_set,,nvarchar(50),,,7,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-valueSetMember,Value Set Member,Individual value within a value set,valueSetMember,,Part_type_set,,nvarchar(50),,,8,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-comments,Comments,Table for Comments,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-contact,Contact,Table for Contact,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-equipment,Equipment,Table for Equipment,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-equipment_model,Equipment Model,Table for Equipment Model,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-equipment_model_has_Parameter,Equipment Model Has Parameter,Table for Equipment Model Has Parameter,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-equipment_model_has_procedures,Equipment Model Has Procedures,Table for Equipment Model Has Procedures,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-hydrological_characteristics,Hydrological Characteristics,Table for Hydrological Characteristics,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-metadata,Metadata,Table for Metadata,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-parameter,Parameter,Table for Parameter,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-parameter_has_procedures,Parameter Has Procedures,Table for Parameter Has Procedures,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-procedures,Procedures,Table for Procedures,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-project,Project,Table for Project,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-project_has_contact,Project Has Contact,Table for Project Has Contact,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-project_has_equipment,Project Has Equipment,Table for Project Has Equipment,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-project_has_sampling_points,Project Has Sampling Points,Table for Project Has Sampling Points,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-purpose,Purpose,Table for Purpose,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-sampling_points,Sampling Points,Table for Sampling Points,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-site,Site,Table for Site,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-unit,Unit,Table for Unit,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-urban_characteristics,Urban Characteristics,Table for Urban Characteristics,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-value,Value,Table for Value,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-watershed,Watershed,Table for Watershed,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-weather_condition,Weather Condition,Table for Weather Condition,table,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Agricultural,Agricultural,Agricultural in urban_characteristics table,property,,,,real,False,,7,,,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Comment,Comment,Comment in comments table,property,,,,ntext(1073741823),False,,2,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Comment_ID,Comment ID,"Identifier for comments, also used in 1 other table(s)",key,,,,int,False,,1,,key,,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Commercial,Commercial,Commercial in urban_characteristics table,property,,,,real,False,,2,,,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Company,Company,Company in contact table,property,,,,ntext(1073741823),False,,4,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Concentration_time,Concentration Time,Concentration Time in watershed table,property,,,,int,False,,5,,,,,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Condition_ID,Condition ID,"Identifier for weather_condition, also used in 1 other table(s)",key,,,,int,False,,1,,,,,,,,,property,,,,,,,,,,,,,,,key,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Contact_ID,Contact ID,"Identifier for contact, also used in 2 other table(s)",key,,,,int,False,,1,,,key,,,,,,property,,,,,compositeKeySecond,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Cropland,Cropland,Cropland in hydrological_characteristics table,property,,,,real,False,,5,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Email,Email,Email in contact table,property,,,,nvarchar(100),False,,8,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Equipment_ID,Equipment ID,"Identifier for equipment, also used in 2 other table(s)",key,,,,int,False,,1,,,,key,,,,,property,,,,,,compositeKeySecond,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Equipment_identifier,Equipment IDentifier,Equipment IDentifier in equipment table,property,,,,nvarchar(100),False,,2,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Equipment_model,Equipment Model,Equipment Model in equipment_model table,property,,,,nvarchar(100),False,,2,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Equipment_model_ID,Equipment Model ID,"Identifier for equipment_model, also used in 3 other table(s)",key,,,,int,False,,1,,,,property,key,compositeKeyFirst,compositeKeyFirst,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-First_name,First Name,First Name in contact table,property,,,,nvarchar(255),False,,3,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Forest,Forest,Forest in hydrological_characteristics table,property,,,,real,False,,3,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Function,Function,Function in contact table,property,,,,ntext(1073741823),False,,6,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Functions,Functions,Functions in equipment_model table,property,,,,ntext(1073741823),False,,4,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Grassland,Grassland,Grassland in hydrological_characteristics table,property,,,,real,False,,7,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Green_spaces,Green Spaces,Green Spaces in urban_characteristics table,property,,,,real,False,,3,,,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Impervious_surface,Impervious Surface,Impervious Surface in watershed table,property,,,,real,False,,6,,,,,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Industrial,Industrial,Industrial in urban_characteristics table,property,,,,real,False,,4,,,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Institutional,Institutional,Institutional in urban_characteristics table,property,,,,real,False,,5,,,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Last_name,Last Name,Last Name in contact table,property,,,,nvarchar(100),False,,2,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Latitude_GPS,Latitude GPS,Latitude GPS in sampling_points table,property,,,,nvarchar(100),False,,5,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Linkedin,Linkedin,Linkedin in contact table,property,,,,nvarchar(100),False,,11,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Longitude_GPS,Longitude GPS,Longitude GPS in sampling_points table,property,,,,nvarchar(100),False,,6,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Manual_location,Manual Location,Manual Location in equipment_model table,property,,,,nvarchar(100),False,,6,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Manufacturer,Manufacturer,Manufacturer in equipment_model table,property,,,,nvarchar(100),False,,5,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Meadow,Meadow,Meadow in hydrological_characteristics table,property,,,,real,False,,6,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Metadata_ID,Metadata ID,"Identifier for metadata, also used in 1 other table(s)",key,,,,int,False,,1,,,,,,,,,key,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Method,Method,Method in equipment_model table,property,,,,nvarchar(100),False,,3,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Number_of_experiment,Number Of Experiment,Number Of Experiment in value table,property,,,,numeric,False,,3,,,,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Office_number,Office Number,Office Number in contact table,property,,,,nvarchar(100),False,,7,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Owner,Owner,Owner in equipment table,property,,,,ntext(1073741823),False,,4,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Parameter,Parameter,Parameter in parameter table,property,,,,nvarchar(100),False,,2,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Parameter_ID,Parameter ID,"Identifier for parameter, also used in 3 other table(s)",compositeKeySecond,,,,int,False,,2,,,,,,compositeKeySecond,,,property,key,compositeKeyFirst,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Phone,Phone,Phone in contact table,property,,,,nvarchar(100),False,,9,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Picture,Picture,Picture in site table,property,,,,image(2147483647),False,,6,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Pictures,Pictures,Pictures in sampling_points table,property,,,,BLOB,False,,8,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Procedure_ID,Procedure ID,"Identifier for procedures, also used in 3 other table(s)",key,,,,int,False,,1,,,,,,,compositeKeySecond,,property,,compositeKeySecond,key,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Procedure_location,Procedure Location,Procedure Location in procedures table,property,,,,nvarchar(100),False,,5,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Procedure_name,Procedure Name,Procedure Name in procedures table,property,,,,nvarchar(100),False,,2,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Procedure_type,Procedure Type,Procedure Type in procedures table,property,,,,nvarchar(255),False,,3,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Project_ID,Project ID,"Identifier for project, also used in 4 other table(s)",key,,,,int,False,,1,,,,,,,,,property,,,,key,compositeKeyFirst,compositeKeyFirst,compositeKeyFirst,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Project_name,Project Name,Project Name in project table,property,,,,nvarchar(100),False,,2,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Province,Province,Province in site table,property,,,,nvarchar(255),False,,11,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Purchase_date,Purchase Date,Purchase Date in equipment table,property,,,,date,False,,6,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Purpose,Purpose,Purpose in purpose table,property,,,,nvarchar(100),False,,2,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Purpose_ID,Purpose ID,"Identifier for purpose, also used in 1 other table(s)",key,,,,int,False,,1,,,,,,,,,property,,,,,,,,key,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Recreational,Recreational,Recreational in urban_characteristics table,property,,,,real,False,,8,,,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Residential,Residential,Residential in urban_characteristics table,property,,,,real,False,,6,,,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Sampling_location,Sampling Location,Sampling Location in sampling_points table,property,,,,nvarchar(100),False,,3,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Sampling_point,Sampling Point,Sampling Point in sampling_points table,property,,,,nvarchar(100),False,,2,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Sampling_point_ID,Sampling Point ID,"Identifier for sampling_points, also used in 2 other table(s)",key,,,,int,False,,1,,,,,,,,,property,,,,,,,compositeKeySecond,,key,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Serial_number,Serial Number,Serial Number in equipment table,property,,,,nvarchar(100),False,,3,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Site_ID,Site ID,"Identifier for site, also used in 1 other table(s)",key,,,,int,False,,1,,,,,,,,,,,,,,,,,,property,key,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Site_name,Site Name,Site Name in site table,property,,,,nvarchar(100),False,,2,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Site_type,Site Type,Site Type in site table,property,,,,nvarchar(255),False,,3,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Skype_name,Skype Name,Skype Name in contact table,property,,,,nvarchar(100),False,,10,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Status,Status,Status in contact table,property,,,,nvarchar(255),False,,5,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Storage_location,Storage Location,Storage Location in equipment table,property,,,,nvarchar(100),False,,5,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Surface_area,Surface Area,Surface Area in watershed table,property,,,,real,False,,4,,,,,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Timestamp,Timestamp,Timestamp in value table,property,,,,int,False,,6,,,,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Unit,Unit,Unit in unit table,property,,,,nvarchar(100),False,,2,,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Unit_ID,Unit ID,"Identifier for unit, also used in 2 other table(s)",key,,,,int,False,,1,,,,,,,,,property,property,,,,,,,,,,key,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Urban_area,Urban Area,Urban Area in hydrological_characteristics table,property,,,,real,False,,2,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Value,Value,Value in value table,property,,,,float,False,,2,,,,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Value_ID,Value ID,Unique identifier for value,key,,,,int,False,,1,,,,,,,,,,,,,,,,,,,,,,key,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Watershed_ID,Watershed ID,"Identifier for hydrological_characteristics, also used in 3 other table(s)",key,,,,int,False,,1,,,,,,,,key,,,,,,,,,,,property,,key,,key,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Watershed_name,Watershed Name,Watershed Name in watershed table,property,,,,nvarchar(100),False,,2,,,,,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Weather_condition,Weather Condition,Weather Condition in weather_condition table,property,,,,nvarchar(100),False,,2,,,,,,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Website,Website,Website in contact table,property,,,,nvarchar(60),False,,17,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-Wetlands,Wetlands,Wetlands in hydrological_characteristics table,property,,,,real,False,,4,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-contact_City,Contact City,Contact City in contact table,property,,,,nvarchar(255),False,,14,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-contact_Country,Contact Country,Contact Country in contact table,property,,,,nvarchar(255),False,,16,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-contact_Street_name,Contact Street Name,Contact Street Name in contact table,property,,,,nvarchar(100),False,,13,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-contact_Street_number,Contact Street Number,Contact Street Number in contact table,property,,,,nvarchar(100),False,,12,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-contact_Zip_code,Contact Zip Code,Contact Zip Code in contact table,property,,,,nvarchar(45),False,,15,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-parameter_Description,Parameter Description,Parameter Description in parameter table,property,,,,ntext(1073741823),False,,4,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-procedures_Description,Procedures Description,Procedures Description in procedures table,property,,,,ntext(1073741823),False,,4,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-project_Description,Project Description,Project Description in project table,property,,,,ntext(1073741823),False,,3,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-purpose_Description,Purpose Description,Purpose Description in purpose table,property,,,,ntext(1073741823),False,,3,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-sampling_points_Description,Sampling Points Description,Sampling Points Description in sampling_points table,property,,,,ntext(1073741823),False,,7,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-site_City,Site City,Site City in site table,property,,,,nvarchar(255),False,,9,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-site_Country,Site Country,Site Country in site table,property,,,,nvarchar(255),False,,12,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-site_Description,Site Description,Site Description in site table,property,,,,ntext(1073741823),False,,5,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-site_Street_name,Site Street Name,Site Street Name in site table,property,,,,nvarchar(100),False,,8,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-site_Street_number,Site Street Number,Site Street Number in site table,property,,,,nvarchar(100),False,,7,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-site_Zip_code,Site Zip Code,Site Zip Code in site table,property,,,,nvarchar(100),False,,10,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-watershed_Description,Watershed Description,Watershed Description in watershed table,property,,,,ntext(1073741823),False,,3,,,,,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-weather_condition_Description,Weather Condition Description,Weather Condition Description in weather_condition table,property,,,,ntext(1073741823),False,,3,,,,,,,,,,,,,,,,,,,,,,,,property,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
diff --git a/src/dictionary.json b/src/dictionary.json
new file mode 100644
index 0000000..6d69667
--- /dev/null
+++ b/src/dictionary.json
@@ -0,0 +1,2064 @@
+{
+ "parts": [
+ {
+ "Part_ID": "Part_type_set",
+ "Label": "Part Type Set",
+ "Description": "Valid values for part types",
+ "Part_type": "valueSet",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "table",
+ "Label": "Table",
+ "Description": "Represents a database table",
+ "Part_type": "valueSetMember",
+ "Member_of_set_part_ID": "Part_type_set",
+ "Sort_order": 1
+ },
+ {
+ "Part_ID": "key",
+ "Label": "Key",
+ "Description": "Represents a primary key",
+ "Part_type": "valueSetMember",
+ "Member_of_set_part_ID": "Part_type_set",
+ "Sort_order": 2
+ },
+ {
+ "Part_ID": "property",
+ "Label": "Property",
+ "Description": "Represents a column/field in a table",
+ "Part_type": "valueSetMember",
+ "Member_of_set_part_ID": "Part_type_set",
+ "Sort_order": 3
+ },
+ {
+ "Part_ID": "compositeKeyFirst",
+ "Label": "Composite Key First",
+ "Description": "First component of a composite primary key",
+ "Part_type": "valueSetMember",
+ "Member_of_set_part_ID": "Part_type_set",
+ "Sort_order": 4
+ },
+ {
+ "Part_ID": "compositeKeySecond",
+ "Label": "Composite Key Second",
+ "Description": "Second component of a composite primary key",
+ "Part_type": "valueSetMember",
+ "Member_of_set_part_ID": "Part_type_set",
+ "Sort_order": 5
+ },
+ {
+ "Part_ID": "parentKey",
+ "Label": "Parent Key",
+ "Description": "Hierarchical reference to parent record in same table",
+ "Part_type": "valueSetMember",
+ "Member_of_set_part_ID": "Part_type_set",
+ "Sort_order": 6
+ },
+ {
+ "Part_ID": "valueSet",
+ "Label": "Value Set",
+ "Description": "Represents an enumeration or controlled vocabulary",
+ "Part_type": "valueSetMember",
+ "Member_of_set_part_ID": "Part_type_set",
+ "Sort_order": 7
+ },
+ {
+ "Part_ID": "valueSetMember",
+ "Label": "Value Set Member",
+ "Description": "Individual value within a value set",
+ "Part_type": "valueSetMember",
+ "Member_of_set_part_ID": "Part_type_set",
+ "Sort_order": 8
+ },
+ {
+ "Part_ID": "comments",
+ "Label": "Comments",
+ "Description": "Stores any additional textual comments, notes, or observations related to a specific measured value",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "contact",
+ "Label": "Contact",
+ "Description": "Stores detailed personal and professional information for people involved in projects (e.g., name, affiliation, function, e-mail, phone)",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "equipment",
+ "Label": "Equipment",
+ "Description": "Stores information about a specific, physical piece of equipment (e.g., serial number, owner, purchase date, storage location)",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "equipment_model",
+ "Label": "Equipment Model",
+ "Description": "Stores detailed, non-redundant specifications for a specific sensor or instrument model (e.g., manufacturer, functions, method)",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "equipment_model_has_Parameter",
+ "Label": "Equipment Model Has Parameter",
+ "Description": "Links equipment models to the parameters they can measure",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "equipment_model_has_procedures",
+ "Label": "Equipment Model Has Procedures",
+ "Description": "Links equipment models to the relevant maintenance procedures",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "hydrological_characteristics",
+ "Label": "Hydrological Characteristics",
+ "Description": "Stores the hydrological land use percentages (e.g., forest, wetlands, cropland, grassland) within the watershed",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "metadata",
+ "Label": "Metadata",
+ "Description": "Contains a list of all existing unique metadata combinations (represented by a series of foreign keys/IDs) that describe a single measurement",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "parameter",
+ "Label": "Parameter",
+ "Description": "Stores the different water quality or quantity parameters that are measured (e.g., pH, TSS, N-components)",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "parameter_has_procedures",
+ "Label": "Parameter Has Procedures",
+ "Description": "Links parameters to the relevant measurement procedures",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "procedures",
+ "Label": "Procedures",
+ "Description": "Stores details for different measurement procedures (e.g., calibration, validation, standard operating procedures, ISO methods)",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "project",
+ "Label": "Project",
+ "Description": "Stores descriptive information about the research or monitoring project for which the data was collected",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "project_has_contact",
+ "Label": "Project Has Contact",
+ "Description": "Links projects to the personnel involved in them",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "project_has_equipment",
+ "Label": "Project Has Equipment",
+ "Description": "Links projects to the specific equipment used within them",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "project_has_sampling_points",
+ "Label": "Project Has Sampling Points",
+ "Description": "Links projects to the sampling points used within them",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "purpose",
+ "Label": "Purpose",
+ "Description": "Stores information about the aim of the measurement (e.g., on-line measurement, laboratory analysis, calibration, validation, cleaning)",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "sampling_points",
+ "Label": "Sampling Points",
+ "Description": "Stores the identification, specific geographical coordinates (Latitude/Longitude/GPS), and description of a particular spot where a sample or measurement is taken",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "site",
+ "Label": "Site",
+ "Description": "Stores general site information, including address, site type, and a link to the associated watershed",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "unit",
+ "Label": "Unit",
+ "Description": "Stores the SI units of measurement (or other relevant units) corresponding to the parameters (e.g., mg/L, g/L, s)",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "urban_characteristics",
+ "Label": "Urban Characteristics",
+ "Description": "Stores the urban land use percentages (e.g., commercial, residential, green spaces) within the watershed",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "value",
+ "Label": "Value",
+ "Description": "Stores each measured water quality or quantity value, its time stamp, replicate identification, and the link to its specific metadata set",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "watershed",
+ "Label": "Watershed",
+ "Description": "Stores general information about the watershed area, including surface area, concentration time, and impervious surface percentage",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "weather_condition",
+ "Label": "Weather Condition",
+ "Description": "Stores descriptive information about the prevailing weather conditions when the measurement was taken (e.g., dry weather, wet weather, snow melt)",
+ "Part_type": "table",
+ "Sort_order": null
+ },
+ {
+ "Part_ID": "Agricultural",
+ "Label": "Agricultural",
+ "Description": "Percentage [%] of agricultural land use. For example farm land",
+ "Part_type": "property",
+ "SQL_data_type": "real",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 7,
+ "table_presence": {
+ "urban_characteristics": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Comment",
+ "Label": "Comment",
+ "Description": "Comment on the data in the Value table",
+ "Part_type": "property",
+ "SQL_data_type": "ntext(1073741823)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 2,
+ "table_presence": {
+ "comments": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Comment_ID",
+ "Label": "Comment ID",
+ "Description": "A unique ID is generated automatically by MySQL",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 1,
+ "table_presence": {
+ "comments": {
+ "role": "key",
+ "required": false,
+ "order": 999
+ },
+ "value": {
+ "role": "property",
+ "required": false,
+ "order": 999,
+ "relationship_type": "one-to-many"
+ }
+ }
+ },
+ {
+ "Part_ID": "Commercial",
+ "Label": "Commercial",
+ "Description": "Percentage [%] of commercial areas. For example stores or bank areas",
+ "Part_type": "property",
+ "SQL_data_type": "real",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 2,
+ "table_presence": {
+ "urban_characteristics": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Company",
+ "Label": "Company",
+ "Description": "Company name",
+ "Part_type": "property",
+ "SQL_data_type": "ntext(1073741823)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 4,
+ "table_presence": {
+ "contact": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Concentration_time",
+ "Label": "Concentration Time",
+ "Description": "Concentration time in minutes [min]",
+ "Part_type": "property",
+ "SQL_data_type": "int",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 5,
+ "table_presence": {
+ "watershed": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Condition_ID",
+ "Label": "Condition ID",
+ "Description": "A unique ID is generated automatically by MySQL",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 1,
+ "table_presence": {
+ "metadata": {
+ "role": "property",
+ "required": false,
+ "order": 999,
+ "relationship_type": "one-to-many"
+ },
+ "weather_condition": {
+ "role": "key",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Contact_ID",
+ "Label": "Contact ID",
+ "Description": "Link to the Contact table",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 1,
+ "table_presence": {
+ "contact": {
+ "role": "key",
+ "required": false,
+ "order": 999
+ },
+ "metadata": {
+ "role": "property",
+ "required": false,
+ "order": 999,
+ "relationship_type": "one-to-many"
+ },
+ "project_has_contact": {
+ "role": "compositeKeySecond",
+ "required": false,
+ "order": 999,
+ "relationship_type": "many-to-many"
+ }
+ }
+ },
+ {
+ "Part_ID": "Cropland",
+ "Label": "Cropland",
+ "Description": "Percentage [%] of croplands",
+ "Part_type": "property",
+ "SQL_data_type": "real",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 5,
+ "table_presence": {
+ "hydrological_characteristics": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Email",
+ "Label": "Email",
+ "Description": "E-mail address",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 8,
+ "table_presence": {
+ "contact": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Equipment_ID",
+ "Label": "Equipment ID",
+ "Description": "Link to the Equipment table",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 1,
+ "table_presence": {
+ "equipment": {
+ "role": "key",
+ "required": false,
+ "order": 999
+ },
+ "metadata": {
+ "role": "property",
+ "required": false,
+ "order": 999,
+ "relationship_type": "one-to-many"
+ },
+ "project_has_equipment": {
+ "role": "compositeKeySecond",
+ "required": false,
+ "order": 999,
+ "relationship_type": "many-to-many"
+ }
+ }
+ },
+ {
+ "Part_ID": "Equipment_identifier",
+ "Label": "Equipment IDentifier",
+ "Description": "Identification name of the equipments",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 2,
+ "table_presence": {
+ "equipment": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Equipment_model",
+ "Label": "Equipment Model",
+ "Description": "Name of the equipment model. For example: ammo::lyser",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 2,
+ "table_presence": {
+ "equipment_model": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Equipment_model_ID",
+ "Label": "Equipment Model ID",
+ "Description": "Link to the Equipment model table",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 1,
+ "table_presence": {
+ "equipment": {
+ "role": "property",
+ "required": false,
+ "order": 999,
+ "relationship_type": "one-to-many"
+ },
+ "equipment_model": {
+ "role": "key",
+ "required": false,
+ "order": 999
+ },
+ "equipment_model_has_Parameter": {
+ "role": "compositeKeyFirst",
+ "required": false,
+ "order": 999,
+ "relationship_type": "many-to-many"
+ },
+ "equipment_model_has_procedures": {
+ "role": "compositeKeyFirst",
+ "required": false,
+ "order": 999,
+ "relationship_type": "many-to-many"
+ }
+ }
+ },
+ {
+ "Part_ID": "First_name",
+ "Label": "First Name",
+ "Description": "First name of the contact",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(255)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 3,
+ "table_presence": {
+ "contact": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Forest",
+ "Label": "Forest",
+ "Description": "Percentage [%] of forest areas",
+ "Part_type": "property",
+ "SQL_data_type": "real",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 3,
+ "table_presence": {
+ "hydrological_characteristics": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Function",
+ "Label": "Function",
+ "Description": "More detailed description about the functions",
+ "Part_type": "property",
+ "SQL_data_type": "ntext(1073741823)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 6,
+ "table_presence": {
+ "contact": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Functions",
+ "Label": "Functions",
+ "Description": "Description of the functions of the equipment",
+ "Part_type": "property",
+ "SQL_data_type": "ntext(1073741823)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 4,
+ "table_presence": {
+ "equipment_model": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Grassland",
+ "Label": "Grassland",
+ "Description": "Percentage [%] of grasslands",
+ "Part_type": "property",
+ "SQL_data_type": "real",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 7,
+ "table_presence": {
+ "hydrological_characteristics": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Green_spaces",
+ "Label": "Green Spaces",
+ "Description": "Percentage [%] of green spaces",
+ "Part_type": "property",
+ "SQL_data_type": "real",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 3,
+ "table_presence": {
+ "urban_characteristics": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Impervious_surface",
+ "Label": "Impervious Surface",
+ "Description": "Percentage of the impervious surface of the watershed in percentage [%]",
+ "Part_type": "property",
+ "SQL_data_type": "real",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 6,
+ "table_presence": {
+ "watershed": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Industrial",
+ "Label": "Industrial",
+ "Description": "Percentage [%] of industrial areas. For example factories",
+ "Part_type": "property",
+ "SQL_data_type": "real",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 4,
+ "table_presence": {
+ "urban_characteristics": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Institutional",
+ "Label": "Institutional",
+ "Description": "Percentage [%] of institutional areas. For example schools, police stations or city hall",
+ "Part_type": "property",
+ "SQL_data_type": "real",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 5,
+ "table_presence": {
+ "urban_characteristics": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Last_name",
+ "Label": "Last Name",
+ "Description": "Last name of the contact",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 2,
+ "table_presence": {
+ "contact": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Latitude_GPS",
+ "Label": "Latitude GPS",
+ "Description": "GPS coordinates. For example: 47°54′25.103\" ",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 5,
+ "table_presence": {
+ "sampling_points": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Linkedin",
+ "Label": "Linkedin",
+ "Description": "LinkedIn account",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 11,
+ "table_presence": {
+ "contact": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Longitude_GPS",
+ "Label": "Longitude GPS",
+ "Description": "GPS coordinates. For example: $73^{\\circ}47^{\\prime}00.024^{\\prime\\prime}$",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 6,
+ "table_presence": {
+ "sampling_points": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Manual_location",
+ "Label": "Manual Location",
+ "Description": "Location where the manual is stored",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 6,
+ "table_presence": {
+ "equipment_model": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Manufacturer",
+ "Label": "Manufacturer",
+ "Description": "Name of the manufacturer",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 5,
+ "table_presence": {
+ "equipment_model": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Meadow",
+ "Label": "Meadow",
+ "Description": "Percentage [%] of meadow areas",
+ "Part_type": "property",
+ "SQL_data_type": "real",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 6,
+ "table_presence": {
+ "hydrological_characteristics": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Metadata_ID",
+ "Label": "Metadata ID",
+ "Description": "A unique ID is generated automatically by MySQL",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 1,
+ "table_presence": {
+ "metadata": {
+ "role": "key",
+ "required": false,
+ "order": 999
+ },
+ "value": {
+ "role": "property",
+ "required": false,
+ "order": 999,
+ "relationship_type": "one-to-many"
+ }
+ }
+ },
+ {
+ "Part_ID": "Method",
+ "Label": "Method",
+ "Description": "Method behind the equipment",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 3,
+ "table_presence": {
+ "equipment_model": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Number_of_experiment",
+ "Label": "Number Of Experiment",
+ "Description": "Number of replica of an experiment",
+ "Part_type": "property",
+ "SQL_data_type": "numeric",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 3,
+ "table_presence": {
+ "value": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Office_number",
+ "Label": "Office Number",
+ "Description": "Number of the office",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 7,
+ "table_presence": {
+ "contact": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Owner",
+ "Label": "Owner",
+ "Description": "Name of the owner of the equipment",
+ "Part_type": "property",
+ "SQL_data_type": "ntext(1073741823)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 4,
+ "table_presence": {
+ "equipment": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Parameter",
+ "Label": "Parameter",
+ "Description": "Name of the parameter",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 2,
+ "table_presence": {
+ "parameter": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Parameter_ID",
+ "Label": "Parameter ID",
+ "Description": "Link to the Parameter table",
+ "Part_type": "compositeKeySecond",
+ "SQL_data_type": "int",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 2,
+ "table_presence": {
+ "equipment_model_has_Parameter": {
+ "role": "compositeKeySecond",
+ "required": false,
+ "order": 999
+ },
+ "metadata": {
+ "role": "property",
+ "required": false,
+ "order": 999,
+ "relationship_type": "one-to-many"
+ },
+ "parameter": {
+ "role": "key",
+ "required": false,
+ "order": 999
+ },
+ "parameter_has_procedures": {
+ "role": "compositeKeyFirst",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Phone",
+ "Label": "Phone",
+ "Description": "Phone number",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 9,
+ "table_presence": {
+ "contact": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Picture",
+ "Label": "Picture",
+ "Description": "Picture of the site",
+ "Part_type": "property",
+ "SQL_data_type": "image(2147483647)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 6,
+ "table_presence": {
+ "site": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Pictures",
+ "Label": "Pictures",
+ "Description": "Picture of the site",
+ "Part_type": "property",
+ "SQL_data_type": "BLOB",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 8,
+ "table_presence": {
+ "sampling_points": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Procedure_ID",
+ "Label": "Procedure ID",
+ "Description": "Link to the Procedures table",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 1,
+ "table_presence": {
+ "equipment_model_has_procedures": {
+ "role": "compositeKeySecond",
+ "required": false,
+ "order": 999,
+ "relationship_type": "many-to-many"
+ },
+ "metadata": {
+ "role": "property",
+ "required": false,
+ "order": 999,
+ "relationship_type": "one-to-many"
+ },
+ "parameter_has_procedures": {
+ "role": "compositeKeySecond",
+ "required": false,
+ "order": 999,
+ "relationship_type": "many-to-many"
+ },
+ "procedures": {
+ "role": "key",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Procedure_location",
+ "Label": "Procedure Location",
+ "Description": "Where is the procedure stored",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 5,
+ "table_presence": {
+ "procedures": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Procedure_name",
+ "Label": "Procedure Name",
+ "Description": "Title name of the procedure",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 2,
+ "table_presence": {
+ "procedures": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Procedure_type",
+ "Label": "Procedure Type",
+ "Description": "Type of the procedure. For example, SOP",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(255)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 3,
+ "table_presence": {
+ "procedures": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Project_ID",
+ "Label": "Project ID",
+ "Description": "Link to the Project table",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 1,
+ "table_presence": {
+ "metadata": {
+ "role": "property",
+ "required": false,
+ "order": 999,
+ "relationship_type": "one-to-many"
+ },
+ "project": {
+ "role": "key",
+ "required": false,
+ "order": 999
+ },
+ "project_has_contact": {
+ "role": "compositeKeyFirst",
+ "required": false,
+ "order": 999,
+ "relationship_type": "many-to-many"
+ },
+ "project_has_equipment": {
+ "role": "compositeKeyFirst",
+ "required": false,
+ "order": 999,
+ "relationship_type": "many-to-many"
+ },
+ "project_has_sampling_points": {
+ "role": "compositeKeyFirst",
+ "required": false,
+ "order": 999,
+ "relationship_type": "many-to-many"
+ }
+ }
+ },
+ {
+ "Part_ID": "Project_name",
+ "Label": "Project Name",
+ "Description": "Name of the project",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 2,
+ "table_presence": {
+ "project": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Province",
+ "Label": "Province",
+ "Description": "Address: name of the province",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(255)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 11,
+ "table_presence": {
+ "site": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Purchase_date",
+ "Label": "Purchase Date",
+ "Description": "Date when the equipment was bought: 'YYYY-MM-DD",
+ "Part_type": "property",
+ "SQL_data_type": "date",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 6,
+ "table_presence": {
+ "equipment": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Purpose",
+ "Label": "Purpose",
+ "Description": "Purpose of the data collection. For example, \"Measurement\", \"Lab_analysis\", \"Calibration\" and \"Cleaning\"",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 2,
+ "table_presence": {
+ "purpose": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Purpose_ID",
+ "Label": "Purpose ID",
+ "Description": "A unique ID is generated automatically by MySQL",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 1,
+ "table_presence": {
+ "metadata": {
+ "role": "property",
+ "required": false,
+ "order": 999,
+ "relationship_type": "one-to-many"
+ },
+ "purpose": {
+ "role": "key",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Recreational",
+ "Label": "Recreational",
+ "Description": "Percentage [%] of recreational areas. For example parks or sport fields",
+ "Part_type": "property",
+ "SQL_data_type": "real",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 8,
+ "table_presence": {
+ "urban_characteristics": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Residential",
+ "Label": "Residential",
+ "Description": "Percentage [%] of residential areas. For example houses or apartment buildings",
+ "Part_type": "property",
+ "SQL_data_type": "real",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 6,
+ "table_presence": {
+ "urban_characteristics": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Sampling_location",
+ "Label": "Sampling Location",
+ "Description": "Where the sample was taken. For example: \"Biofiltration\", \"Sewer 01\" or \"Retention Tank\"",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 3,
+ "table_presence": {
+ "sampling_points": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Sampling_point",
+ "Label": "Sampling Point",
+ "Description": "Where the sample was taken. For example: \"Inlet\", \"Outlet\" or \"Upstream\"",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 2,
+ "table_presence": {
+ "sampling_points": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Sampling_point_ID",
+ "Label": "Sampling Point ID",
+ "Description": "Link to the Sampling_point table",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 1,
+ "table_presence": {
+ "metadata": {
+ "role": "property",
+ "required": false,
+ "order": 999,
+ "relationship_type": "one-to-many"
+ },
+ "project_has_sampling_points": {
+ "role": "compositeKeySecond",
+ "required": false,
+ "order": 999,
+ "relationship_type": "many-to-many"
+ },
+ "sampling_points": {
+ "role": "key",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Serial_number",
+ "Label": "Serial Number",
+ "Description": "Serial number of the equipment",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 3,
+ "table_presence": {
+ "equipment": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Site_ID",
+ "Label": "Site ID",
+ "Description": "A unique ID is generated automatically by MySQL",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 1,
+ "table_presence": {
+ "sampling_points": {
+ "role": "property",
+ "required": false,
+ "order": 999,
+ "relationship_type": "one-to-many"
+ },
+ "site": {
+ "role": "key",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Site_name",
+ "Label": "Site Name",
+ "Description": "Name of the site",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 2,
+ "table_presence": {
+ "site": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Site_type",
+ "Label": "Site Type",
+ "Description": "For example: \"WWTP\", \"River\" or \"Sewer_system\"",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(255)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 3,
+ "table_presence": {
+ "site": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Skype_name",
+ "Label": "Skype Name",
+ "Description": "Skype name",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 10,
+ "table_presence": {
+ "contact": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Status",
+ "Label": "Status",
+ "Description": "Status of the person. For example: \"Master student\", \"Postdoc\" or \"Intern\"",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(255)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 5,
+ "table_presence": {
+ "contact": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Storage_location",
+ "Label": "Storage Location",
+ "Description": "Where is the procedure stored",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 5,
+ "table_presence": {
+ "equipment": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Surface_area",
+ "Label": "Surface Area",
+ "Description": "Surface area of the watershed [ha]",
+ "Part_type": "property",
+ "SQL_data_type": "real",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 4,
+ "table_presence": {
+ "watershed": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Timestamp",
+ "Label": "Timestamp",
+ "Description": "Unix timestamp combining date and time of collected data",
+ "Part_type": "property",
+ "SQL_data_type": "int",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 6,
+ "table_presence": {
+ "value": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Unit",
+ "Label": "Unit",
+ "Description": "SI-units only",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 2,
+ "table_presence": {
+ "unit": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Unit_ID",
+ "Label": "Unit ID",
+ "Description": "A unique ID is generated automatically by MySQL",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 1,
+ "table_presence": {
+ "metadata": {
+ "role": "property",
+ "required": false,
+ "order": 999,
+ "relationship_type": "one-to-many"
+ },
+ "parameter": {
+ "role": "property",
+ "required": false,
+ "order": 999,
+ "relationship_type": "one-to-many"
+ },
+ "unit": {
+ "role": "key",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Urban_area",
+ "Label": "Urban Area",
+ "Description": "Percentage [%] of urban areas",
+ "Part_type": "property",
+ "SQL_data_type": "real",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 2,
+ "table_presence": {
+ "hydrological_characteristics": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Value",
+ "Label": "Value",
+ "Description": "Value of collected data",
+ "Part_type": "property",
+ "SQL_data_type": "float",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 2,
+ "table_presence": {
+ "value": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Value_ID",
+ "Label": "Value ID",
+ "Description": "A unique ID is generated automatically by MySQL",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 1,
+ "table_presence": {
+ "value": {
+ "role": "key",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Watershed_ID",
+ "Label": "Watershed ID",
+ "Description": "Linked to the Watershed table",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 1,
+ "table_presence": {
+ "hydrological_characteristics": {
+ "role": "key",
+ "required": false,
+ "order": 999,
+ "relationship_type": "one-to-one"
+ },
+ "site": {
+ "role": "property",
+ "required": false,
+ "order": 999,
+ "relationship_type": "one-to-many"
+ },
+ "urban_characteristics": {
+ "role": "key",
+ "required": false,
+ "order": 999,
+ "relationship_type": "one-to-one"
+ },
+ "watershed": {
+ "role": "key",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Watershed_name",
+ "Label": "Watershed Name",
+ "Description": "Name of the watershed",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 2,
+ "table_presence": {
+ "watershed": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Weather_condition",
+ "Label": "Weather Condition",
+ "Description": "Type of weather condition",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 2,
+ "table_presence": {
+ "weather_condition": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Website",
+ "Label": "Website",
+ "Description": "Website URL of the contact or organization",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(60)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 17,
+ "table_presence": {
+ "contact": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "Wetlands",
+ "Label": "Wetlands",
+ "Description": "Percentage [%] of wetlands",
+ "Part_type": "property",
+ "SQL_data_type": "real",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 4,
+ "table_presence": {
+ "hydrological_characteristics": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "contact_City",
+ "Label": "Contact City",
+ "Description": "Address: name of the city",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(255)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 14,
+ "table_presence": {
+ "contact": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "contact_Country",
+ "Label": "Contact Country",
+ "Description": "Address: name of the country",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(255)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 16,
+ "table_presence": {
+ "contact": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "contact_Street_name",
+ "Label": "Contact Street Name",
+ "Description": "Address: name of the street",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 13,
+ "table_presence": {
+ "contact": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "contact_Street_number",
+ "Label": "Contact Street Number",
+ "Description": "Address: number of the street",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 12,
+ "table_presence": {
+ "contact": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "contact_Zip_code",
+ "Label": "Contact Zip Code",
+ "Description": "Address: zip code",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(45)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 15,
+ "table_presence": {
+ "contact": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "parameter_Description",
+ "Label": "Parameter Description",
+ "Description": "Description of the parameter",
+ "Part_type": "property",
+ "SQL_data_type": "ntext(1073741823)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 4,
+ "table_presence": {
+ "parameter": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "procedures_Description",
+ "Label": "Procedures Description",
+ "Description": "Description of the procedure",
+ "Part_type": "property",
+ "SQL_data_type": "ntext(1073741823)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 4,
+ "table_presence": {
+ "procedures": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "project_Description",
+ "Label": "Project Description",
+ "Description": "Description of the project",
+ "Part_type": "property",
+ "SQL_data_type": "ntext(1073741823)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 3,
+ "table_presence": {
+ "project": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "purpose_Description",
+ "Label": "Purpose Description",
+ "Description": "Description of the purpose",
+ "Part_type": "property",
+ "SQL_data_type": "ntext(1073741823)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 3,
+ "table_presence": {
+ "purpose": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "sampling_points_Description",
+ "Label": "Sampling Points Description",
+ "Description": "Description of the sampling point",
+ "Part_type": "property",
+ "SQL_data_type": "ntext(1073741823)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 7,
+ "table_presence": {
+ "sampling_points": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "site_City",
+ "Label": "Site City",
+ "Description": "Address: name of the city",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(255)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 9,
+ "table_presence": {
+ "site": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "site_Country",
+ "Label": "Site Country",
+ "Description": "Address: name of the country",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(255)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 12,
+ "table_presence": {
+ "site": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "site_Description",
+ "Label": "Site Description",
+ "Description": "Description of the site",
+ "Part_type": "property",
+ "SQL_data_type": "ntext(1073741823)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 5,
+ "table_presence": {
+ "site": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "site_Street_name",
+ "Label": "Site Street Name",
+ "Description": "Address: name of the street",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 8,
+ "table_presence": {
+ "site": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "site_Street_number",
+ "Label": "Site Street Number",
+ "Description": "Address: number of the street",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 7,
+ "table_presence": {
+ "site": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "site_Zip_code",
+ "Label": "Site Zip Code",
+ "Description": "Address: zip code",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(100)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 10,
+ "table_presence": {
+ "site": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "watershed_Description",
+ "Label": "Watershed Description",
+ "Description": "Description of the watershed",
+ "Part_type": "property",
+ "SQL_data_type": "ntext(1073741823)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 3,
+ "table_presence": {
+ "watershed": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ },
+ {
+ "Part_ID": "weather_condition_Description",
+ "Label": "Weather Condition Description",
+ "Description": "Description of the condition",
+ "Part_type": "property",
+ "SQL_data_type": "ntext(1073741823)",
+ "Is_required": false,
+ "Default_value": null,
+ "Value_set_part_ID": null,
+ "Sort_order": 3,
+ "table_presence": {
+ "weather_condition": {
+ "role": "property",
+ "required": false,
+ "order": 999
+ }
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/open_dateaubase/__init__.py b/src/open_dateaubase/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/open_dateaubase/data_model/__init__.py b/src/open_dateaubase/data_model/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/open_dateaubase/data_model/helpers.py b/src/open_dateaubase/data_model/helpers.py
new file mode 100644
index 0000000..450e0ac
--- /dev/null
+++ b/src/open_dateaubase/data_model/helpers.py
@@ -0,0 +1,431 @@
+"""
+Helper functions for manipulating the dictionary.
+
+Usage:
+ from open_dateaubase.helpers import DictionaryManager
+
+ mgr = DictionaryManager.load("src/dictionary.json")
+ mgr.create_value_set("Status_set", "Valid status values")
+ mgr.add_value_set_member("Status_set", "active", "Active status", order=1)
+ mgr.save()
+"""
+
+from pathlib import Path
+from typing import Optional, Literal
+import json
+from .models import (
+ Dictionary,
+ TablePart,
+ KeyPart,
+ PropertyPart,
+ CompositeKeyFirstPart,
+ CompositeKeySecondPart,
+ ParentKeyPart,
+ ValueSetPart,
+ ValueSetMemberPart,
+ TablePresence,
+ Part,
+)
+
+
+class DictionaryManager:
+ """Manages dictionary operations with validation."""
+
+ def __init__(self, dictionary: Dictionary, path: Path):
+ self.dictionary = dictionary
+ self.path = path
+
+ @classmethod
+ def load(cls, path: str | Path | None = None) -> "DictionaryManager":
+ """Load dictionary from JSON file with validation."""
+ if path is None:
+ # Default path when no path specified
+ from importlib.resources import files
+ path = files('open_dateaubase').joinpath('dictionary.json')
+
+ path = Path(path) if isinstance(path, str) else path
+ with open(path, "r", encoding="utf-8") as f:
+ raw_data = json.load(f)
+ dictionary = Dictionary.model_validate(raw_data)
+ return cls(dictionary, path)
+
+ def save(self, path: Optional[Path] = None) -> None:
+ """Save dictionary to JSON file."""
+ target = path or self.path
+ # Export as dict, convert to JSON with PascalCase keys
+ data = self.dictionary.model_dump(by_alias=True)
+ with open(target, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2, ensure_ascii=False)
+ print(f"Dictionary saved to {target}")
+
+ def _find_part(self, part_id: str) -> Optional[Part]:
+ """Find a part by Part_ID."""
+ for part in self.dictionary.parts:
+ if part.part_id == part_id:
+ return part
+ return None
+
+ def _part_exists(self, part_id: str) -> bool:
+ """Check if a part exists."""
+ return self._find_part(part_id) is not None
+
+ # ========================================================================
+ # Value Set Operations
+ # ========================================================================
+
+ def create_value_set(self, part_id: str, label: str, description: str) -> None:
+ """Create a new value set."""
+ if self._part_exists(part_id):
+ raise ValueError(f"Part '{part_id}' already exists")
+
+ value_set = ValueSetPart(
+ Part_ID=part_id, Label=label, Description=description, Part_type="valueSet"
+ )
+ self.dictionary.parts.append(value_set)
+ # Re-validate entire dictionary
+ self.dictionary = Dictionary.model_validate(
+ self.dictionary.model_dump(by_alias=True)
+ )
+ print(f"Created value set '{part_id}'")
+
+ def add_value_set_member(
+ self,
+ value_set_id: str,
+ member_id: str,
+ label: str,
+ description: str,
+ order: int = 999,
+ ) -> None:
+ """Add a member to a value set."""
+ if not self._part_exists(value_set_id):
+ raise ValueError(f"Value set '{value_set_id}' does not exist")
+
+ if self._part_exists(member_id):
+ raise ValueError(f"Part '{member_id}' already exists")
+
+ member = ValueSetMemberPart(
+ Part_ID=member_id,
+ Label=label,
+ Description=description,
+ Part_type="valueSetMember",
+ Member_of_set_part_ID=value_set_id,
+ Sort_order=order,
+ )
+ self.dictionary.parts.append(member)
+ # Re-validate
+ self.dictionary = Dictionary.model_validate(
+ self.dictionary.model_dump(by_alias=True)
+ )
+ print(f"Added member '{member_id}' to value set '{value_set_id}'")
+
+ # ========================================================================
+ # Table Operations
+ # ========================================================================
+
+ def create_table(self, table_id: str, label: str, description: str) -> None:
+ """Create a new table."""
+ if self._part_exists(table_id):
+ raise ValueError(f"Part '{table_id}' already exists")
+
+ table = TablePart(
+ Part_ID=table_id, Label=label, Description=description, Part_type="table"
+ )
+ self.dictionary.parts.append(table)
+ # Re-validate
+ self.dictionary = Dictionary.model_validate(
+ self.dictionary.model_dump(by_alias=True)
+ )
+ print(f"Created table '{table_id}'")
+
+ def add_field_to_table(
+ self,
+ table_id: str,
+ field_id: str,
+ label: str,
+ description: str,
+ role: Literal[
+ "key", "property", "compositeKeyFirst", "compositeKeySecond"
+ ] = "property",
+ sql_data_type: str = "nvarchar(255)",
+ required: bool = False,
+ order: int = 999,
+ value_set_id: Optional[str] = None,
+ default_value: Optional[str] = None,
+ ) -> None:
+ """Add a field to a table (or update existing field's table_presence)."""
+ if not self._part_exists(table_id):
+ raise ValueError(f"Table '{table_id}' does not exist")
+
+ existing_part = self._find_part(field_id)
+
+ if existing_part:
+ # Field exists - update its table_presence
+ if not hasattr(existing_part, "table_presence"):
+ raise ValueError(f"Part '{field_id}' exists but is not a field type")
+
+ # Add table presence
+ existing_part.table_presence[table_id] = TablePresence(
+ role=role, required=required, order=order
+ )
+ print(f"Added '{field_id}' to table '{table_id}' with role '{role}'")
+ else:
+ # Create new field
+ presence = {
+ table_id: TablePresence(role=role, required=required, order=order)
+ }
+
+ # Determine field class based on role
+ if role == "key":
+ field_class = KeyPart
+ elif role == "compositeKeyFirst":
+ field_class = CompositeKeyFirstPart
+ elif role == "compositeKeySecond":
+ field_class = CompositeKeySecondPart
+ else:
+ field_class = PropertyPart
+
+ field_kwargs = {
+ "Part_ID": field_id,
+ "Label": label,
+ "Description": description,
+ "Part_type": role if role != "property" else "property",
+ "SQL_data_type": sql_data_type,
+ "Is_required": required,
+ "Default_value": default_value,
+ "table_presence": presence,
+ }
+
+ if value_set_id:
+ field_kwargs["Value_set_part_ID"] = value_set_id
+
+ field = field_class(**field_kwargs)
+ self.dictionary.parts.append(field)
+ print(f"Created field '{field_id}' in table '{table_id}'")
+
+ # Re-validate entire dictionary
+ self.dictionary = Dictionary.model_validate(
+ self.dictionary.model_dump(by_alias=True)
+ )
+
+ def add_parent_key(
+ self,
+ table_id: str,
+ parent_key_id: str,
+ ancestor_key_id: str,
+ label: str,
+ description: str,
+ sql_data_type: str = "int",
+ required: bool = False,
+ order: int = 999,
+ ) -> None:
+ """Add a hierarchical parent key to a table."""
+ if not self._part_exists(table_id):
+ raise ValueError(f"Table '{table_id}' does not exist")
+
+ if not self._part_exists(ancestor_key_id):
+ raise ValueError(f"Ancestor key '{ancestor_key_id}' does not exist")
+
+ if self._part_exists(parent_key_id):
+ raise ValueError(f"Part '{parent_key_id}' already exists")
+
+ parent_key = ParentKeyPart(
+ Part_ID=parent_key_id,
+ Label=label,
+ Description=description,
+ Part_type="parentKey",
+ Ancestor_part_ID=ancestor_key_id,
+ SQL_data_type=sql_data_type,
+ Is_required=required,
+ table_presence={
+ table_id: TablePresence(role="property", required=required, order=order)
+ },
+ )
+ self.dictionary.parts.append(parent_key)
+ # Re-validate
+ self.dictionary = Dictionary.model_validate(
+ self.dictionary.model_dump(by_alias=True)
+ )
+ print(f"Added parent key '{parent_key_id}' to table '{table_id}'")
+
+ # ========================================================================
+ # Validation & Integrity
+ # ========================================================================
+
+ def validate(self) -> None:
+ """Explicitly validate the dictionary."""
+ try:
+ Dictionary.model_validate(self.dictionary.model_dump(by_alias=True))
+ print("Dictionary is valid!")
+ except Exception as e:
+ print(f"Validation failed: {e}")
+ raise
+
+ def list_tables(self) -> list[str]:
+ """List all table Part_IDs."""
+ return [
+ part.part_id for part in self.dictionary.parts if part.part_type == "table"
+ ]
+
+ def list_value_sets(self) -> list[str]:
+ """List all value set Part_IDs."""
+ return [
+ part.part_id
+ for part in self.dictionary.parts
+ if part.part_type == "valueSet"
+ ]
+
+ # ========================================================================
+ # Query Operations (replacing old SQL queries)
+ # ========================================================================
+
+ def get_value_set_members(self, field_id: str) -> list[dict]:
+ """Get all valid values for a field's value set constraint.
+
+ Args:
+ field_id: The Part_ID of the field to check
+
+ Returns:
+ List of dictionaries with Part_ID, Label, Description for each member
+ """
+ field = self._find_part(field_id)
+ if not field:
+ return []
+
+ # Check if field has a value set constraint
+ value_set_id = getattr(field, "value_set_part_id", None)
+ if not value_set_id:
+ return []
+
+ # Find all members of this value set
+ members = []
+ for part in self.dictionary.parts:
+ if (
+ hasattr(part, "member_of_set_part_id")
+ and part.member_of_set_part_id == value_set_id
+ ):
+ members.append(
+ {
+ "Part_ID": part.part_id,
+ "Label": part.label,
+ "Description": part.description,
+ "Sort_order": getattr(part, "sort_order", 999),
+ }
+ )
+
+ # Sort by sort_order
+ members.sort(key=lambda x: x["Sort_order"])
+ return members
+
+ def get_table_columns(self, table_id: str) -> list[dict]:
+ """Get all columns that appear in a specific table.
+
+ Args:
+ table_id: The Part_ID of the table
+
+ Returns:
+ List of dictionaries with column metadata
+ """
+ columns = []
+ for part in self.dictionary.parts:
+ # Only field parts have table_presence
+ if hasattr(part, "table_presence") and part.table_presence:
+ if table_id in part.table_presence:
+ presence = part.table_presence[table_id]
+ columns.append(
+ {
+ "Part_ID": part.part_id,
+ "Label": part.label,
+ "SQL_data_type": getattr(part, "sql_data_type", None),
+ "Is_required": presence.required,
+ "Role": presence.role,
+ "Order": presence.order,
+ }
+ )
+
+ # Sort by order
+ columns.sort(key=lambda x: x["Order"])
+ return columns
+
+ def get_field_tables(self, field_id: str) -> list[dict]:
+ """Find all tables where a specific field appears.
+
+ Args:
+ field_id: The Part_ID of the field
+
+ Returns:
+ List of dictionaries with table and role information
+ """
+ field = self._find_part(field_id)
+ if not field or not hasattr(field, "table_presence"):
+ return []
+
+ tables = []
+ for table_id, presence in field.table_presence.items():
+ tables.append(
+ {
+ "Table_ID": table_id,
+ "Role": presence.role,
+ "Required": presence.required,
+ "Order": presence.order,
+ }
+ )
+
+ return tables
+
+ def get_primary_keys(self) -> list[dict]:
+ """Find all primary keys in the database.
+
+ Returns:
+ List of dictionaries with primary key information
+ """
+ primary_keys = []
+ for part in self.dictionary.parts:
+ if part.part_type == "key":
+ # Find which table this key belongs to
+ tables = []
+ if hasattr(part, "table_presence") and part.table_presence:
+ for table_id, presence in part.table_presence.items():
+ if presence.role == "key":
+ tables.append(table_id)
+
+ primary_keys.append(
+ {
+ "Part_ID": part.part_id,
+ "Label": part.label,
+ "SQL_data_type": getattr(part, "sql_data_type", None),
+ "Primary_in_tables": tables,
+ }
+ )
+
+ return primary_keys
+
+ def get_shared_fields(self) -> list[dict]:
+ """Find fields that appear in multiple tables.
+
+ Returns:
+ List of dictionaries with shared field information
+ """
+ shared_fields = []
+ for part in self.dictionary.parts:
+ if (
+ hasattr(part, "table_presence")
+ and part.table_presence
+ and len(part.table_presence) > 1
+ ):
+ tables = []
+ for table_id, presence in part.table_presence.items():
+ tables.append({"Table_ID": table_id, "Role": presence.role})
+
+ shared_fields.append(
+ {
+ "Part_ID": part.part_id,
+ "Label": part.label,
+ "Part_type": part.part_type,
+ "Table_count": len(part.table_presence),
+ "Tables": tables,
+ }
+ )
+
+ # Sort by table count (most shared first)
+ shared_fields.sort(key=lambda x: x["Table_count"], reverse=True)
+ return shared_fields
diff --git a/src/open_dateaubase/data_model/models.py b/src/open_dateaubase/data_model/models.py
new file mode 100644
index 0000000..555d948
--- /dev/null
+++ b/src/open_dateaubase/data_model/models.py
@@ -0,0 +1,355 @@
+"""
+Pydantic models for the open_dateaubase dictionary.
+
+This module defines the type-safe schema for the dictionary using discriminated
+unions to enforce Part_type-specific validation rules.
+"""
+
+from typing import Literal, Union, Dict, Optional, List, Any, Annotated
+from pydantic import BaseModel, Field, field_validator, model_validator, ConfigDict
+
+
+# ============================================================================
+# Table Presence Metadata
+# ============================================================================
+
+
+class TablePresence(BaseModel):
+ """Metadata about how a field appears in a specific table."""
+
+ model_config = ConfigDict(frozen=True) # Immutable for safety
+
+ role: Literal["key", "property", "compositeKeyFirst", "compositeKeySecond"]
+ required: bool = False
+ order: int = Field(ge=1, description="Display order in table (1-indexed)")
+
+ # Foreign key relationship metadata
+ relationship_type: Optional[
+ Literal["one-to-one", "one-to-many", "many-to-many"]
+ ] = Field(
+ None,
+ description="Type of relationship this FK represents. Set when this field is a foreign key.",
+ )
+
+ @model_validator(mode="after")
+ def validate_fk_consistency(self):
+ """Ensure FK metadata is consistent."""
+ has_relationship = self.relationship_type is not None
+
+ # Validate relationship types match expected roles
+ if has_relationship:
+ # one-to-one: FK should typically be a key (though property is also valid)
+ # one-to-many: FK should be a property (regular column in child table)
+ # many-to-many: FK should be part of composite key in junction table
+
+ if self.relationship_type == "one-to-one" and self.role not in [
+ "key",
+ "property",
+ ]:
+ raise ValueError(
+ f"one-to-one relationships require role='key' or 'property', got '{self.role}'"
+ )
+
+ if self.relationship_type == "one-to-many" and self.role not in [
+ "property",
+ "compositeKeyFirst",
+ "compositeKeySecond",
+ ]:
+ raise ValueError(
+ f"one-to-many relationships typically require role='property', got '{self.role}'"
+ )
+
+ if self.relationship_type == "many-to-many" and self.role not in [
+ "compositeKeyFirst",
+ "compositeKeySecond",
+ ]:
+ raise ValueError(
+ f"many-to-many relationships require composite key roles, got '{self.role}'"
+ )
+
+ return self
+
+
+# ============================================================================
+# Base Part Model
+# ============================================================================
+
+
+class PartBase(BaseModel):
+ """Base model for all dictionary parts."""
+
+ model_config = ConfigDict(
+ populate_by_name=True
+ ) # Allow both snake_case and PascalCase
+
+ part_id: str = Field(..., alias="Part_ID", min_length=1)
+ label: str = Field(..., alias="Label", min_length=1)
+ description: str = Field(..., alias="Description", min_length=1)
+ sort_order: Optional[int] = Field(None, alias="Sort_order", ge=1)
+
+
+# ============================================================================
+# Table Part
+# ============================================================================
+
+
+class TablePart(PartBase):
+ """Represents a database table definition."""
+
+ part_type: Literal["table"] = Field(alias="Part_type")
+
+ @field_validator("part_id")
+ @classmethod
+ def validate_table_name(cls, v: str) -> str:
+ """Table names should be lowercase with underscores."""
+ if " " in v:
+ raise ValueError(f"Table name '{v}' should not contain spaces")
+ return v
+
+
+# ============================================================================
+# Field Parts (key, property, compositeKey*)
+# ============================================================================
+
+
+class FieldPartBase(PartBase):
+ """Base for parts that represent table columns."""
+
+ sql_data_type: Optional[str] = Field(None, alias="SQL_data_type")
+ is_required: bool = Field(default=False, alias="Is_required")
+ default_value: Optional[str] = Field(None, alias="Default_value")
+ value_set_part_id: Optional[str] = Field(None, alias="Value_set_part_ID")
+ table_presence: Dict[str, TablePresence] = Field(
+ default_factory=dict, description="Maps table_name -> TablePresence metadata"
+ )
+
+ @model_validator(mode="after")
+ def validate_table_presence_not_empty(self):
+ """Field parts must appear in at least one table."""
+ if not self.table_presence:
+ raise ValueError(
+ f"Field '{self.part_id}' must appear in at least one table"
+ )
+ return self
+
+
+class KeyPart(FieldPartBase):
+ """Primary key field."""
+
+ part_type: Literal["key"] = Field(alias="Part_type")
+
+ @field_validator("part_id")
+ @classmethod
+ def validate_key_naming(cls, v: str) -> str:
+ """Primary keys should end with '_ID'."""
+ if not v.endswith("_ID"):
+ raise ValueError(f"Key '{v}' should end with '_ID'")
+ return v
+
+ @model_validator(mode="after")
+ def validate_key_in_tables(self):
+ """A key must be 'key' in at least one table."""
+ has_key_role = any(
+ presence.role == "key" for presence in self.table_presence.values()
+ )
+ if not has_key_role:
+ raise ValueError(
+ f"Key '{self.part_id}' must have role='key' in at least one table"
+ )
+ return self
+
+
+class PropertyPart(FieldPartBase):
+ """Regular column/field."""
+
+ part_type: Literal["property"] = Field(alias="Part_type")
+
+
+class CompositeKeyFirstPart(FieldPartBase):
+ """First component of composite primary key."""
+
+ part_type: Literal["compositeKeyFirst"] = Field(alias="Part_type")
+
+ @field_validator("part_id")
+ @classmethod
+ def validate_composite_key_naming(cls, v: str) -> str:
+ """Composite keys should end with '_ID'."""
+ if not v.endswith("_ID"):
+ raise ValueError(f"Composite key '{v}' should end with '_ID'")
+ return v
+
+
+class CompositeKeySecondPart(FieldPartBase):
+ """Second component of composite primary key."""
+
+ part_type: Literal["compositeKeySecond"] = Field(alias="Part_type")
+
+ @field_validator("part_id")
+ @classmethod
+ def validate_composite_key_naming(cls, v: str) -> str:
+ """Composite keys should end with '_ID'."""
+ if not v.endswith("_ID"):
+ raise ValueError(f"Composite key '{v}' should end with '_ID'")
+ return v
+
+
+class ParentKeyPart(FieldPartBase):
+ """Hierarchical self-reference within same table."""
+
+ part_type: Literal["parentKey"] = Field(alias="Part_type")
+ ancestor_part_id: str = Field(..., alias="Ancestor_part_ID", min_length=1)
+
+ @field_validator("ancestor_part_id")
+ @classmethod
+ def validate_ancestor_is_key(cls, v: str) -> str:
+ """Ancestor should be a key field (end with _ID)."""
+ if not v.endswith("_ID"):
+ raise ValueError(f"Ancestor '{v}' should be a key field ending with '_ID'")
+ return v
+
+
+# ============================================================================
+# Value Set Parts
+# ============================================================================
+
+
+class ValueSetPart(PartBase):
+ """Enumeration/controlled vocabulary definition."""
+
+ part_type: Literal["valueSet"] = Field(alias="Part_type")
+
+ @field_validator("part_id")
+ @classmethod
+ def validate_value_set_naming(cls, v: str) -> str:
+ """Value sets should end with '_set' by convention."""
+ if not v.endswith("_set") and not v.endswith("Set"):
+ raise ValueError(f"Value set '{v}' should end with '_set' or 'Set'")
+ return v
+
+
+class ValueSetMemberPart(PartBase):
+ """Individual value within a value set."""
+
+ part_type: Literal["valueSetMember"] = Field(alias="Part_type")
+ member_of_set_part_id: str = Field(..., alias="Member_of_set_part_ID", min_length=1)
+
+ @field_validator("member_of_set_part_id")
+ @classmethod
+ def validate_member_of_set(cls, v: str) -> str:
+ """Should reference a value set."""
+ if not v.endswith("_set") and not v.endswith("Set"):
+ raise ValueError(
+ f"Member should belong to a value set ending with '_set' or 'Set', got '{v}'"
+ )
+ return v
+
+
+# ============================================================================
+# Discriminated Union
+# ============================================================================
+
+Part = Annotated[
+ Union[
+ TablePart,
+ KeyPart,
+ PropertyPart,
+ CompositeKeyFirstPart,
+ CompositeKeySecondPart,
+ ParentKeyPart,
+ ValueSetPart,
+ ValueSetMemberPart,
+ ],
+ Field(discriminator="part_type"),
+]
+
+
+# ============================================================================
+# Dictionary Root
+# ============================================================================
+
+
+class Dictionary(BaseModel):
+ """Root dictionary model."""
+
+ parts: List[Part]
+
+ @field_validator("parts")
+ @classmethod
+ def validate_unique_part_ids(cls, v: List[Part]) -> List[Part]:
+ """Ensure all Part_IDs are unique."""
+ part_ids = [part.part_id for part in v]
+ duplicates = [pid for pid in set(part_ids) if part_ids.count(pid) > 1]
+ if duplicates:
+ raise ValueError(f"Duplicate Part_IDs found: {duplicates}")
+ return v
+
+ @model_validator(mode="after")
+ def validate_cross_references(self):
+ """Validate that all cross-references point to existing parts."""
+ part_ids = {part.part_id for part in self.parts}
+
+ # Validate value_set_part_id references
+ for part in self.parts:
+ if isinstance(part, FieldPartBase) and part.value_set_part_id:
+ if part.value_set_part_id not in part_ids:
+ raise ValueError(
+ f"Field '{part.part_id}' references non-existent "
+ f"value set '{part.value_set_part_id}'"
+ )
+
+ # Validate member_of_set_part_id references
+ for part in self.parts:
+ if isinstance(part, ValueSetMemberPart):
+ if part.member_of_set_part_id not in part_ids:
+ raise ValueError(
+ f"Value set member '{part.part_id}' references "
+ f"non-existent set '{part.member_of_set_part_id}'"
+ )
+
+ # Validate ancestor_part_id references
+ for part in self.parts:
+ if isinstance(part, ParentKeyPart):
+ if part.ancestor_part_id not in part_ids:
+ raise ValueError(
+ f"Parent key '{part.part_id}' references non-existent "
+ f"ancestor '{part.ancestor_part_id}'"
+ )
+
+ # Validate table_presence references
+ table_names = {
+ part.part_id for part in self.parts if isinstance(part, TablePart)
+ }
+ for part in self.parts:
+ if isinstance(part, FieldPartBase):
+ for table_name in part.table_presence.keys():
+ if table_name not in table_names:
+ raise ValueError(
+ f"Field '{part.part_id}' references non-existent "
+ f"table '{table_name}' in table_presence"
+ )
+
+ # Validate foreign key relationships by inferring targets from field names
+ for part in self.parts:
+ if isinstance(part, FieldPartBase):
+ for table_name, presence in part.table_presence.items():
+ if presence.relationship_type:
+ # Infer FK target from field name (field name ending in _ID references same-named primary key)
+ if part.part_id.endswith("_ID"):
+ # Validate that the inferred target exists and is a key field
+ target_part = next(
+ (p for p in self.parts if p.part_id == part.part_id),
+ None,
+ )
+ if target_part and not isinstance(
+ target_part,
+ (
+ KeyPart,
+ CompositeKeyFirstPart,
+ CompositeKeySecondPart,
+ ),
+ ):
+ raise ValueError(
+ f"Field '{part.part_id}' appears to be a foreign key but is not defined as a key field"
+ )
+
+ return self
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..64ffd8f
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,93 @@
+"""Shared pytest configuration and fixtures for open_dateaubase tests."""
+
+import pytest
+import json
+import sys
+from pathlib import Path
+
+# Add project paths to sys.path so imports work consistently
+project_root = Path(__file__).parent.parent
+sys.path.insert(0, str(project_root / "src"))
+sys.path.insert(0, str(project_root / "scripts"))
+sys.path.insert(0, str(project_root / "tests"))
+
+
+# Import fixtures
+from fixtures.sample_dictionary import (
+ sample_dictionary_data,
+ complex_dictionary_data,
+ edge_case_dictionary_data,
+)
+
+
+@pytest.fixture
+def sample_json_dict():
+ """Return sample dictionary data as Python dict."""
+ return sample_dictionary_data()
+
+
+@pytest.fixture
+def complex_json_dict():
+ """Return complex dictionary data as Python dict."""
+ return complex_dictionary_data()
+
+
+@pytest.fixture
+def edge_case_json_dict():
+ """Return edge case dictionary data as Python dict."""
+ return edge_case_dictionary_data()
+
+
+@pytest.fixture
+def sample_json_file(tmp_path):
+ """Create temporary JSON file with sample dictionary data."""
+ json_file = tmp_path / "sample_dictionary.json"
+ json_file.write_text(json.dumps(sample_dictionary_data(), indent=2))
+ return json_file
+
+
+@pytest.fixture
+def complex_json_file(tmp_path):
+ """Create temporary JSON file with complex dictionary data."""
+ json_file = tmp_path / "complex_dictionary.json"
+ json_file.write_text(json.dumps(complex_dictionary_data(), indent=2))
+ return json_file
+
+
+@pytest.fixture
+def edge_case_json_file(tmp_path):
+ """Create temporary JSON file with edge case dictionary data."""
+ json_file = tmp_path / "edge_case_dictionary.json"
+ json_file.write_text(json.dumps(edge_case_dictionary_data(), indent=2))
+ return json_file
+
+
+@pytest.fixture
+def output_dirs(tmp_path):
+ """Create standard output directory structure for tests."""
+ dirs = {
+ "docs": tmp_path / "docs" / "reference",
+ "sql": tmp_path / "sql_generation_scripts",
+ "assets": tmp_path / "docs" / "assets",
+ "root": tmp_path,
+ }
+
+ for dir_path in dirs.values():
+ if isinstance(dir_path, Path):
+ dir_path.mkdir(parents=True, exist_ok=True)
+
+ return dirs
+
+
+# Configure pytest
+def pytest_configure(config):
+ """Configure pytest with custom markers."""
+ config.addinivalue_line(
+ "markers", "integration: mark test as integration test (slower, uses multiple modules)"
+ )
+ config.addinivalue_line(
+ "markers", "unit: mark test as unit test (fast, isolated)"
+ )
+ config.addinivalue_line(
+ "markers", "slow: mark test as slow running"
+ )
diff --git a/tests/fixtures.py b/tests/fixtures.py
deleted file mode 100644
index 8cb4cd0..0000000
--- a/tests/fixtures.py
+++ /dev/null
@@ -1,21 +0,0 @@
-import pytest
-
-@pytest.fixture
-def sample_csv_data():
- """Create sample CSV data for testing - NEW FORMAT with parentKey support."""
- # This fixture demonstrates the new dictionary structure:
- # - TestTable_ID is the primary key
- # - Name, Status are regular non-prefixed fields
- # - Parent_ID is a parentKey that references TestTable_ID
- # - StatusSet is a value set with two members
- csv_content = """Part_ID,Label,Description,Part_type,Value_set_part_ID,Member_of_set_part_ID,Ancestor_part_ID,SQL_data_type,Is_required,Default_value,Sort_order,TestTable_present,TestTable_required,TestTable_order
-TestTable,Test Table,A test table,table,,,,,,,,,,
-TestTable_ID,Test Table ID,Identifier for TestTable,key,,,,int,True,,1,key,True,1
-Name,Name,Name field,property,,,,nvarchar(255),True,,2,property,True,2
-Status,Status,Status field,property,StatusSet,,,nvarchar(50),False,,3,property,False,3
-Parent_ID,Parent ID,Hierarchical reference to parent TestTable,parentKey,,,TestTable_ID,int,False,,4,property,False,4
-StatusSet,Status Set,Valid status values,valueSet,,,,,,,,
-active,Active,Active status,valueSetMember,,StatusSet,,nvarchar(50),,,1,,
-inactive,Inactive,Inactive status,valueSetMember,,StatusSet,,nvarchar(50),,,2,,
-"""
- return csv_content
diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py
new file mode 100644
index 0000000..d1b3395
--- /dev/null
+++ b/tests/fixtures/__init__.py
@@ -0,0 +1 @@
+"""Test fixtures package."""
diff --git a/tests/fixtures/sample_dictionary.py b/tests/fixtures/sample_dictionary.py
new file mode 100644
index 0000000..07ec7a7
--- /dev/null
+++ b/tests/fixtures/sample_dictionary.py
@@ -0,0 +1,300 @@
+"""JSON-based test fixtures for open_dateaubase testing.
+
+This module provides sample dictionary data in the current JSON format
+used by Pydantic models, replacing the old CSV-based fixtures.
+"""
+
+from typing import Dict, Any
+
+
+def sample_dictionary_data() -> Dict[str, Any]:
+ """Sample dictionary with basic table, fields, and value set."""
+ return {
+ "parts": [
+ # Table definition
+ {
+ "Part_ID": "test_table",
+ "Label": "Test Table",
+ "Description": "A test table for demonstration",
+ "Part_type": "table",
+ },
+ # Primary key field
+ {
+ "Part_ID": "TestTable_ID",
+ "Label": "Test Table ID",
+ "Description": "Primary identifier for TestTable",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "Is_required": True,
+ "table_presence": {
+ "test_table": {"role": "key", "required": True, "order": 1}
+ },
+ },
+ # Regular field with value set constraint
+ {
+ "Part_ID": "Status",
+ "Label": "Status",
+ "Description": "Current status of record",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(50)",
+ "Is_required": False,
+ "Value_set_part_ID": "StatusSet",
+ "table_presence": {
+ "test_table": {"role": "property", "required": False, "order": 2}
+ },
+ },
+ # Regular field without constraints
+ {
+ "Part_ID": "Description",
+ "Label": "Description",
+ "Description": "Detailed description",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(255)",
+ "Is_required": True,
+ "table_presence": {
+ "test_table": {"role": "property", "required": True, "order": 3}
+ },
+ },
+ # Parent key (hierarchical self-reference)
+ {
+ "Part_ID": "Parent_ID",
+ "Label": "Parent ID",
+ "Description": "Hierarchical reference to parent record",
+ "Part_type": "parentKey",
+ "Ancestor_part_ID": "TestTable_ID",
+ "SQL_data_type": "int",
+ "Is_required": False,
+ "table_presence": {
+ "test_table": {"role": "property", "required": False, "order": 4}
+ },
+ },
+ # Value set definition
+ {
+ "Part_ID": "StatusSet",
+ "Label": "Status Set",
+ "Description": "Valid status values for records",
+ "Part_type": "valueSet",
+ },
+ # Value set members
+ {
+ "Part_ID": "active",
+ "Label": "Active",
+ "Description": "Record is currently active",
+ "Part_type": "valueSetMember",
+ "Member_of_set_part_ID": "StatusSet",
+ "Sort_order": 1,
+ },
+ {
+ "Part_ID": "inactive",
+ "Label": "Inactive",
+ "Description": "Record is currently inactive",
+ "Part_type": "valueSetMember",
+ "Member_of_set_part_ID": "StatusSet",
+ "Sort_order": 2,
+ },
+ {
+ "Part_ID": "pending",
+ "Label": "Pending",
+ "Description": "Record is pending review",
+ "Part_type": "valueSetMember",
+ "Member_of_set_part_ID": "StatusSet",
+ "Sort_order": 3,
+ },
+ ]
+ }
+
+
+def complex_dictionary_data() -> Dict[str, Any]:
+ """Complex dictionary with multiple tables, relationships, and edge cases."""
+ return {
+ "parts": [
+ # First table
+ {
+ "Part_ID": "contact",
+ "Label": "Contact",
+ "Description": "Contact information table",
+ "Part_type": "table",
+ },
+ {
+ "Part_ID": "Contact_ID",
+ "Label": "Contact ID",
+ "Description": "Primary key for contact",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "Is_required": True,
+ "table_presence": {
+ "contact": {"role": "key", "required": True, "order": 1}
+ },
+ },
+ {
+ "Part_ID": "contact_Name",
+ "Label": "Name",
+ "Description": "Full name of contact",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(255)",
+ "Is_required": True,
+ "table_presence": {
+ "contact": {"role": "property", "required": True, "order": 2}
+ },
+ },
+ # Second table with FK to first
+ {
+ "Part_ID": "project",
+ "Label": "Project",
+ "Description": "Project information",
+ "Part_type": "table",
+ },
+ {
+ "Part_ID": "Project_ID",
+ "Label": "Project ID",
+ "Description": "Primary key for project",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "Is_required": True,
+ "table_presence": {
+ "project": {"role": "key", "required": True, "order": 1}
+ },
+ },
+ {
+ "Part_ID": "Project_Contact_ID",
+ "Label": "Contact ID",
+ "Description": "Foreign key to contact",
+ "Part_type": "property",
+ "SQL_data_type": "int",
+ "Is_required": True,
+ "table_presence": {
+ "project": {"role": "property", "required": True, "order": 2}
+ },
+ },
+ # Junction table for many-to-many
+ {
+ "Part_ID": "project_has_contact",
+ "Label": "Project Has Contact",
+ "Description": "Junction table for project-contact relationships",
+ "Part_type": "table",
+ },
+ {
+ "Part_ID": "Junction_Project_ID",
+ "Label": "Project ID",
+ "Description": "Foreign key to project in junction",
+ "Part_type": "compositeKeyFirst",
+ "SQL_data_type": "int",
+ "Is_required": True,
+ "table_presence": {
+ "project_has_contact": {
+ "role": "compositeKeyFirst",
+ "required": True,
+ "order": 1,
+ }
+ },
+ },
+ {
+ "Part_ID": "Junction_Contact_ID",
+ "Label": "Contact ID",
+ "Description": "Foreign key to contact in junction",
+ "Part_type": "compositeKeySecond",
+ "SQL_data_type": "int",
+ "Is_required": True,
+ "table_presence": {
+ "project_has_contact": {
+ "role": "compositeKeySecond",
+ "required": True,
+ "order": 2,
+ }
+ }
+ },
+ ]
+ }
+
+
+def edge_case_dictionary_data() -> Dict[str, Any]:
+ """Dictionary with edge cases for testing error handling."""
+ return {
+ "parts": [
+ # Table with special characters in name
+ {
+ "Part_ID": "special_table",
+ "Label": "Special-Table!",
+ "Description": "Table with special characters: @#$%",
+ "Part_type": "table",
+ },
+ # Field with very long name
+ {
+ "Part_ID": "VeryLongFieldNameThatExceedsNormalDatabaseLimitsAndMightCauseIssues",
+ "Label": "Very Long Field Name That Exceeds Normal Database Limits",
+ "Description": "A field with an extremely long name for testing edge cases",
+ "Part_type": "property",
+ "SQL_data_type": "nvarchar(max)",
+ "Is_required": False,
+ "table_presence": {
+ "special_table": {"role": "property", "required": False, "order": 1}
+ },
+ },
+ # Field with default value
+ {
+ "Part_ID": "Created_Date",
+ "Label": "Created Date",
+ "Description": "Date when record was created",
+ "Part_type": "property",
+ "SQL_data_type": "datetime",
+ "Is_required": True,
+ "Default_value": "GETDATE()",
+ "table_presence": {
+ "special_table": {"role": "property", "required": True, "order": 2}
+ },
+ },
+ # Boolean field
+ {
+ "Part_ID": "Is_Active",
+ "Label": "Is Active",
+ "Description": "Whether record is active",
+ "Part_type": "property",
+ "SQL_data_type": "bit",
+ "Is_required": False,
+ "Default_value": "True",
+ "table_presence": {
+ "special_table": {"role": "property", "required": False, "order": 3}
+ },
+ },
+ ]
+ }
+
+
+def invalid_dictionary_data() -> Dict[str, Any]:
+ """Invalid dictionary data for testing validation errors."""
+ return {
+ "parts": [
+ # Missing required fields
+ {
+ "Part_ID": "incomplete_table",
+ # Missing Label and Description
+ "Part_type": "table",
+ },
+ # Invalid part type
+ {
+ "Part_ID": "invalid_part",
+ "Label": "Invalid Part",
+ "Description": "This has an invalid part type",
+ "Part_type": "invalid_type",
+ },
+ # Key without _ID suffix
+ {
+ "Part_ID": "InvalidKey",
+ "Label": "Invalid Key",
+ "Description": "This key doesn't end with _ID",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "Is_required": True,
+ "table_presence": {
+ "incomplete_table": {"role": "key", "required": True, "order": 1}
+ },
+ },
+ # Value set without proper suffix
+ {
+ "Part_ID": "InvalidValueSet",
+ "Label": "Invalid Value Set",
+ "Description": "This value set doesn't end with _set or Set",
+ "Part_type": "valueSet",
+ },
+ ]
+ }
diff --git a/tests/integration/test_erd_integrity.py b/tests/integration/test_erd_integrity.py
new file mode 100644
index 0000000..7d29c54
--- /dev/null
+++ b/tests/integration/test_erd_integrity.py
@@ -0,0 +1,233 @@
+"""
+Tests for ERD (Entity-Relationship Diagram) integrity.
+
+These tests verify that:
+1. All FK relationships are captured in the ERD
+2. All relationships point to valid tables
+3. The generated HTML contains all expected elements
+"""
+
+import sys
+from pathlib import Path
+import pytest
+import json
+
+# Add project paths
+project_root = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(project_root / 'src'))
+sys.path.insert(0, str(project_root / 'scripts'))
+
+from generate_erd import generate_erd_data, generate_erd_html, parse_erd_json
+
+
+@pytest.fixture
+def parts_data():
+ """Load and parse the dictionary.json file."""
+ json_path = project_root / 'src/dictionary.json'
+ return parse_erd_json(json_path)
+
+
+@pytest.fixture
+def erd_data(parts_data):
+ """Generate ERD data from parts data."""
+ return generate_erd_data(parts_data)
+
+
+def test_all_fk_relationships_captured(parts_data, erd_data):
+ """Test that all FK fields result in relationships in the ERD."""
+ # Count total FK fields in dictionary
+ total_fks = 0
+ fk_details = []
+
+ for table_id, table_info in parts_data['tables'].items():
+ for field in table_info['fields']:
+ if field.get('fk_to'):
+ total_fks += 1
+ fk_details.append({
+ 'table': table_id,
+ 'field': field['label'],
+ 'fk_to': field['fk_to']
+ })
+
+ # Count relationships in ERD
+ num_relationships = len(erd_data['relationships'])
+
+ # Assert they match
+ assert num_relationships == total_fks, (
+ f"Expected {total_fks} relationships but got {num_relationships}. "
+ f"FK fields: {fk_details}"
+ )
+
+
+def test_all_relationships_have_valid_tables(parts_data, erd_data):
+ """Test that all relationships point to existing tables."""
+ table_ids = set(parts_data['tables'].keys())
+
+ for rel in erd_data['relationships']:
+ assert rel['from_table'] in table_ids, (
+ f"Relationship from_table '{rel['from_table']}' does not exist"
+ )
+ assert rel['to_table'] in table_ids, (
+ f"Relationship to_table '{rel['to_table']}' does not exist"
+ )
+
+
+def test_all_tables_included_in_erd(parts_data, erd_data):
+ """Test that all tables from dictionary are included in ERD."""
+ dict_tables = set(parts_data['tables'].keys())
+ erd_tables = {table['id'] for table in erd_data['tables']}
+
+ assert dict_tables == erd_tables, (
+ f"Table mismatch. Missing from ERD: {dict_tables - erd_tables}, "
+ f"Extra in ERD: {erd_tables - dict_tables}"
+ )
+
+
+def test_relationship_field_names_exist(parts_data, erd_data):
+ """Test that relationship field names exist in their respective tables."""
+ for rel in erd_data['relationships']:
+ # Check from_field exists in from_table
+ from_table = parts_data['tables'][rel['from_table']]
+ from_field_names = [f['label'] for f in from_table['fields']]
+ assert rel['from_field'] in from_field_names, (
+ f"Field '{rel['from_field']}' not found in table '{rel['from_table']}'"
+ )
+
+ # Check to_field exists in to_table
+ to_table = parts_data['tables'][rel['to_table']]
+ to_field_names = [f['label'] for f in to_table['fields']]
+ assert rel['to_field'] in to_field_names, (
+ f"Field '{rel['to_field']}' not found in table '{rel['to_table']}'"
+ )
+
+
+def test_junction_tables_not_preferred_as_targets(erd_data):
+ """
+ Test that relationships prefer non-junction tables as targets when possible.
+
+ Junction tables (with '_has_' in name) should generally not be the target
+ of FK relationships unless they are the only table with that PK.
+ """
+ # Count how many relationships point to junction tables
+ junction_target_count = sum(
+ 1 for rel in erd_data['relationships']
+ if '_has_' in rel['to_table']
+ )
+
+ # We expect very few or no relationships to point to junction tables
+ # This is informational - junction tables are typically intermediate tables
+ total_relationships = len(erd_data['relationships'])
+
+ # Allow up to 10% of relationships to point to junction tables
+ # (in case there are legitimate cases)
+ assert junction_target_count / total_relationships < 0.1, (
+ f"Too many relationships ({junction_target_count}/{total_relationships}) "
+ f"point to junction tables. This may indicate incorrect FK resolution."
+ )
+
+
+def test_html_contains_clickable_arrows(erd_data, tmp_path):
+ """Test that generated HTML contains link click handlers for clickable arrows."""
+ output_path = tmp_path / 'test_erd.html'
+ generate_erd_html(erd_data, output_path, library='jointjs')
+
+ html_content = output_path.read_text()
+
+ # Check for link click handler
+ assert 'link:pointerdown' in html_content, (
+ "Generated HTML missing link click handler (link:pointerdown)"
+ )
+
+ # Check for relationship data storage
+ assert 'relationshipData' in html_content, (
+ "Generated HTML missing relationshipData attribute for storing FK info"
+ )
+
+
+def test_html_contains_drag_functionality(erd_data, tmp_path):
+ """Test that generated HTML contains table dragging functionality."""
+ output_path = tmp_path / 'test_erd.html'
+ generate_erd_html(erd_data, output_path, library='jointjs')
+
+ html_content = output_path.read_text()
+
+ # Check for drag handler
+ assert 'startDrag' in html_content, (
+ "Generated HTML missing startDrag function for table dragging"
+ )
+
+ # Check that drag is attached to header
+ assert 'onmousedown' in html_content, (
+ "Generated HTML missing onmousedown event for initiating drag"
+ )
+
+
+def test_html_has_all_tables(erd_data, tmp_path):
+ """Test that generated HTML will render all tables."""
+ output_path = tmp_path / 'test_erd.html'
+ generate_erd_html(erd_data, output_path, library='jointjs')
+
+ html_content = output_path.read_text()
+
+ # The ERD data should be embedded as JSON in the HTML
+ # Check that it contains table data
+ assert 'erdData.tables' in html_content, (
+ "Generated HTML missing erdData.tables reference"
+ )
+
+
+def test_relationship_count_matches_fk_count(parts_data, erd_data):
+ """
+ Test that the number of relationships equals the number of FK fields.
+ This is the main integrity check - every FK should have exactly one relationship.
+ """
+ # Count FK fields
+ fk_count = sum(
+ 1 for table in parts_data['tables'].values()
+ for field in table['fields']
+ if field.get('fk_to')
+ )
+
+ # Count relationships
+ rel_count = len(erd_data['relationships'])
+
+ assert rel_count == fk_count, (
+ f"Relationship count ({rel_count}) does not match FK count ({fk_count}). "
+ f"Every FK field should generate exactly one relationship."
+ )
+
+
+def test_no_duplicate_relationships(erd_data):
+ """Test that there are no duplicate relationships in the ERD."""
+ # Create a set of relationship signatures (from_table, to_table, from_field)
+ relationship_signatures = [
+ (rel['from_table'], rel['to_table'], rel['from_field'])
+ for rel in erd_data['relationships']
+ ]
+
+ # Check for duplicates
+ unique_signatures = set(relationship_signatures)
+
+ assert len(relationship_signatures) == len(unique_signatures), (
+ f"Found duplicate relationships. Total: {len(relationship_signatures)}, "
+ f"Unique: {len(unique_signatures)}"
+ )
+
+
+def test_relationships_have_required_fields(erd_data):
+ """Test that all relationships have the required fields."""
+ required_fields = ['from_table', 'to_table', 'from_field', 'to_field', 'relationship_type']
+
+ for i, rel in enumerate(erd_data['relationships']):
+ for field in required_fields:
+ assert field in rel, (
+ f"Relationship {i} missing required field '{field}': {rel}"
+ )
+ assert rel[field], (
+ f"Relationship {i} has empty value for required field '{field}': {rel}"
+ )
+
+
+if __name__ == '__main__':
+ # Run tests with pytest
+ pytest.main([__file__, '-v'])
diff --git a/tests/integration/test_orchestration.py b/tests/integration/test_orchestration.py
new file mode 100644
index 0000000..6eff2c2
--- /dev/null
+++ b/tests/integration/test_orchestration.py
@@ -0,0 +1,174 @@
+"""Integration tests for documentation orchestration workflow."""
+
+import pytest
+import json
+from pathlib import Path
+import sys
+
+# Add scripts directory to path
+sys.path.insert(0, str(Path(__file__).parent.parent / "tests"))
+sys.path.insert(0, str(Path(__file__).parent.parent.parent / "scripts"))
+
+from orchestrate_docs import main as orchestrate_main
+from generate_dictionary_reference import parse_parts_json, generate_tables_markdown, generate_value_sets_markdown
+from generate_erd import parse_erd_json, generate_erd_files
+from generate_sql import generate_sql_schemas
+from fixtures.sample_dictionary import sample_dictionary_data
+
+
+@pytest.fixture
+def sample_json_file(tmp_path):
+ """Create a temporary JSON file with sample dictionary data."""
+ json_file = tmp_path / "dictionary.json"
+ json_file.write_text(json.dumps(sample_dictionary_data(), indent=2))
+ return json_file
+
+
+@pytest.fixture
+def output_dirs(tmp_path):
+ """Create temporary output directories."""
+ docs_dir = tmp_path / "docs" / "reference"
+ sql_dir = tmp_path / "sql_generation_scripts"
+ assets_dir = tmp_path / "docs" / "assets"
+
+ docs_dir.mkdir(parents=True, exist_ok=True)
+ sql_dir.mkdir(parents=True, exist_ok=True)
+ assets_dir.mkdir(parents=True, exist_ok=True)
+
+ return {
+ "docs": docs_dir,
+ "sql": sql_dir,
+ "assets": assets_dir,
+ "root": tmp_path,
+ }
+
+
+class TestOrchestrationWorkflow:
+ """Test the complete documentation generation workflow."""
+
+ def test_generates_all_documentation_components(self, sample_json_file, output_dirs):
+ """Test that all documentation components are generated."""
+ parts_data = parse_parts_json(sample_json_file)
+
+ # Generate all components
+ tables = generate_tables_markdown(parts_data)
+ value_sets = generate_value_sets_markdown(parts_data)
+
+ # Write documentation
+ (output_dirs["docs"] / "tables.md").write_text(tables, encoding="utf-8")
+ (output_dirs["docs"] / "valuesets.md").write_text(value_sets, encoding="utf-8")
+
+ # Generate ERD
+ erd_parts_data = parse_erd_json(sample_json_file)
+ generate_erd_files(erd_parts_data, output_dirs["assets"], output_dirs["docs"])
+
+ # Generate SQL
+ generate_sql_schemas(parts_data, output_dirs["sql"], ["mssql"])
+
+ # Verify all files were created
+ assert (output_dirs["docs"] / "tables.md").exists()
+ assert (output_dirs["docs"] / "valuesets.md").exists()
+ assert (output_dirs["docs"] / "erd.md").exists()
+ assert (output_dirs["assets"] / "erd_interactive.html").exists()
+ assert len(list(output_dirs["sql"].glob("*.sql"))) == 1
+
+ def test_tables_markdown_contains_expected_content(self, sample_json_file, output_dirs):
+ """Test that generated tables.md has correct content."""
+ parts_data = parse_parts_json(sample_json_file)
+ tables = generate_tables_markdown(parts_data)
+ (output_dirs["docs"] / "tables.md").write_text(tables, encoding="utf-8")
+
+ content = (output_dirs["docs"] / "tables.md").read_text(encoding="utf-8")
+
+ assert "# Database Tables" in content
+ assert "### Test Table" in content
+ assert '' in content
+ assert '' in content
+
+ def test_valuesets_markdown_contains_expected_content(self, sample_json_file, output_dirs):
+ """Test that generated valuesets.md has correct content."""
+ parts_data = parse_parts_json(sample_json_file)
+ value_sets = generate_value_sets_markdown(parts_data)
+ (output_dirs["docs"] / "valuesets.md").write_text(value_sets, encoding="utf-8")
+
+ content = (output_dirs["docs"] / "valuesets.md").read_text(encoding="utf-8")
+
+ assert "# Value Sets" in content
+ assert "## Status Set" in content
+ assert '' in content
+
+ def test_erd_generation_creates_html(self, sample_json_file, output_dirs):
+ """Test that ERD generation creates the interactive HTML."""
+ erd_parts_data = parse_erd_json(sample_json_file)
+ generate_erd_files(erd_parts_data, output_dirs["assets"], output_dirs["docs"])
+
+ # Check interactive HTML exists
+ interactive_html = output_dirs["assets"] / "erd_interactive.html"
+ assert interactive_html.exists()
+
+ # Check content
+ content = interactive_html.read_text()
+ assert "JointJS" in content or "jointjs" in content
+ assert "Test Table" in content
+
+ def test_sql_generation_creates_schema_file(self, sample_json_file, output_dirs):
+ """Test that SQL generation creates schema files."""
+ parts_data = parse_parts_json(sample_json_file)
+ generate_sql_schemas(parts_data, output_dirs["sql"], ["mssql"])
+
+ sql_files = list(output_dirs["sql"].glob("*.sql"))
+ assert len(sql_files) == 1
+
+ # Check filename format
+ sql_file = sql_files[0]
+ assert "_as-designed_mssql.sql" in sql_file.name
+
+ # Check content
+ content = sql_file.read_text(encoding="utf-8")
+ assert "CREATE TABLE [test_table]" in content
+ assert "PRIMARY KEY" in content
+
+ def test_multiple_database_targets(self, sample_json_file, output_dirs):
+ """Test that multiple database schemas can be generated."""
+ parts_data = parse_parts_json(sample_json_file)
+
+ # Currently only mssql is supported, but test the structure
+ generate_sql_schemas(parts_data, output_dirs["sql"], ["mssql"])
+
+ sql_files = list(output_dirs["sql"].glob("*_mssql.sql"))
+ assert len(sql_files) == 1
+
+ def test_workflow_handles_empty_value_sets(self, tmp_path, output_dirs):
+ """Test that workflow handles dictionaries with no value sets."""
+ json_data = {
+ "parts": [
+ {
+ "Part_ID": "test_table",
+ "Label": "Test Table",
+ "Description": "A test table",
+ "Part_type": "table",
+ }
+ ]
+ }
+ json_file = tmp_path / "no_valuesets.json"
+ json_file.write_text(json.dumps(json_data))
+
+ parts_data = parse_parts_json(json_file)
+ value_sets = generate_value_sets_markdown(parts_data)
+ (output_dirs["docs"] / "valuesets.md").write_text(value_sets, encoding="utf-8")
+
+ content = (output_dirs["docs"] / "valuesets.md").read_text(encoding="utf-8")
+ assert "No value sets currently appear in dictionary" in content
+
+ def test_cross_references_between_components(self, sample_json_file, output_dirs):
+ """Test that cross-references between components work correctly."""
+ parts_data = parse_parts_json(sample_json_file)
+
+ tables = generate_tables_markdown(parts_data)
+ value_sets = generate_value_sets_markdown(parts_data)
+
+ # Tables should link to value sets
+ assert "[StatusSet](valuesets.md#StatusSet)" in tables
+
+ # Value sets should have anchors that tables link to
+ assert '' in value_sets
diff --git a/tests/test_docs_gen.py b/tests/test_docs_gen.py
deleted file mode 100644
index 4ec9ee9..0000000
--- a/tests/test_docs_gen.py
+++ /dev/null
@@ -1,449 +0,0 @@
-import pytest
-import csv
-from pathlib import Path
-from io import StringIO
-import sys
-import os
-
-# Add hooks directory to path
-sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'docs/hooks'))
-
-from generate_docs import (
- parse_parts_table,
- generate_tables_markdown,
- generate_value_sets_markdown,
- on_pre_build,
- generate_sql_schemas
-)
-from fixtures import sample_csv_data
-
-@pytest.fixture
-def sample_csv_file(tmp_path, sample_csv_data):
- """Create a temporary CSV file."""
- csv_file = tmp_path / "test_parts.csv"
- csv_file.write_text(sample_csv_data)
- return csv_file
-
-
-class TestParsePartsTable:
- """Tests for parse_parts_table function."""
-
- def test_parse_identifies_tables(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
-
- assert 'TestTable' in data['tables']
- assert data['tables']['TestTable']['label'] == 'Test Table'
- assert data['tables']['TestTable']['description'] == 'A test table'
-
- def test_parse_identifies_fields(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
-
- fields = data['tables']['TestTable']['fields']
- assert len(fields) == 4
-
- # Check primary key
- pk_field = next(f for f in fields if f['part_id'] == 'TestTable_ID')
- assert pk_field['part_type'] == 'key'
- assert pk_field['is_required'] == True
- assert pk_field['sql_data_type'] == 'int'
-
- def test_parse_identifies_foreign_keys(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
-
- # Parent_ID is now a parentKey type with Ancestor_part_ID set
- fk_field = next(f for f in data['tables']['TestTable']['fields']
- if f['part_id'] == 'Parent_ID')
- assert fk_field['fk_to'] == 'TestTable_ID'
-
- def test_parse_identifies_value_sets(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
-
- assert 'StatusSet' in data['value_sets']
- assert data['value_sets']['StatusSet']['label'] == 'Status Set'
- assert len(data['value_sets']['StatusSet']['members']) == 2
-
- def test_parse_sorts_fields_by_sort_order(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
-
- fields = data['tables']['TestTable']['fields']
- sort_orders = [f['sort_order'] for f in fields]
- assert sort_orders == sorted(sort_orders)
-
- def test_parse_sorts_value_set_members(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
-
- members = data['value_sets']['StatusSet']['members']
- sort_orders = [m['sort_order'] for m in members]
- assert sort_orders == sorted(sort_orders)
-
- def test_parse_links_value_sets_to_fields(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
-
- status_field = next(f for f in data['tables']['TestTable']['fields']
- if f['part_id'] == 'Status')
- assert status_field['value_set'] == 'StatusSet'
-
-
-class TestGenerateTablesMarkdown:
- """Tests for generate_tables_markdown function."""
-
- def test_generates_valid_markdown(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- markdown = generate_tables_markdown(data)
-
- assert '# Database Tables' in markdown
- assert '## Tables' in markdown
-
- def test_includes_table_headings(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- markdown = generate_tables_markdown(data)
-
- assert '### Test Table' in markdown
- assert 'A test table' in markdown
-
- def test_includes_table_anchors(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- markdown = generate_tables_markdown(data)
-
- # Check for invisible anchor span
- assert '' in markdown
-
- def test_includes_field_anchors(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- markdown = generate_tables_markdown(data)
-
- # Check for field anchors in description column
- assert '' in markdown
- assert '' in markdown
-
- def test_generates_fields_table(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- markdown = generate_tables_markdown(data)
-
- # Check table header
- assert '| Field | SQL Type | Value Set | Required | Description | Constraints |' in markdown
- assert '|-------|----------|-----------|----------|-------------|-------------|' in markdown
-
- def test_marks_primary_keys(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- markdown = generate_tables_markdown(data)
-
- # Primary key should have PK marker
- assert 'int **(PK)**' in markdown
-
- def test_marks_required_fields(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- markdown = generate_tables_markdown(data)
-
- # Should have checkmarks for required fields
- assert '✓' in markdown
-
- def test_links_to_value_sets(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- markdown = generate_tables_markdown(data)
-
- # Should link to value set
- assert '[StatusSet](valuesets.md#StatusSet)' in markdown
-
- def test_links_foreign_keys(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- markdown = generate_tables_markdown(data)
-
- # Should link FK to target field
- assert 'FK → [TestTable_ID](#TestTable_ID)' in markdown
-
- def test_shows_dash_when_no_value_set(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- markdown = generate_tables_markdown(data)
-
- # Fields without value sets should show '-'
- lines = markdown.split('\n')
- name_field_line = [l for l in lines if 'Name field' in l][0]
- assert '| -' in name_field_line or '- |' in name_field_line
-
-
-class TestGenerateValueSetsMarkdown:
- """Tests for generate_value_sets_markdown function."""
-
- def test_generates_valid_markdown(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- markdown = generate_value_sets_markdown(data)
-
- assert '# Value Sets' in markdown
-
- def test_includes_value_set_headings(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- markdown = generate_value_sets_markdown(data)
-
- assert '## Status Set' in markdown
- assert 'Valid status values' in markdown
-
- def test_includes_value_set_anchors(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- markdown = generate_value_sets_markdown(data)
-
- # Check for invisible anchor span
- assert '' in markdown
-
- def test_includes_member_anchors(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- markdown = generate_value_sets_markdown(data)
-
- # Check for member anchors
- assert '' in markdown
- assert '' in markdown
-
- def test_generates_members_table(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- markdown = generate_value_sets_markdown(data)
-
- # Check table header
- assert '| Value | Description |' in markdown
- assert '|-------|-------------|' in markdown
-
- def test_lists_all_members(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- markdown = generate_value_sets_markdown(data)
-
- assert '`active`' in markdown
- assert 'Active status' in markdown
- assert '`inactive`' in markdown
- assert 'Inactive status' in markdown
-
- def test_handles_empty_value_sets(self, tmp_path):
- # CSV with no value sets
- csv_content = """Part_ID,Label,Description,Part_type,Table_part_ID,Value_set_part_ID,Member_of_set_part_ID,FK_to_part_ID,SQL_data_type,Is_required,Default_value,Sort_order
-TestTable,Test Table,A test table,table,,,,,,,,
-"""
- csv_file = tmp_path / "empty_valuesets.csv"
- csv_file.write_text(csv_content)
-
- data = parse_parts_table(csv_file)
- markdown = generate_value_sets_markdown(data)
-
- assert 'No value sets currently appear in the dictionary' in markdown
-
-
-class TestIntegration:
- """Integration tests checking cross-referencing between tables and value sets."""
-
- def test_value_set_links_are_bidirectional(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- tables_md = generate_tables_markdown(data)
- valuesets_md = generate_value_sets_markdown(data)
-
- # Table should link to value set
- assert '[StatusSet](valuesets.md#StatusSet)' in tables_md
-
- # Value set should have anchor that table links to
- assert '' in valuesets_md
-
- def test_foreign_key_links_point_to_existing_anchors(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- markdown = generate_tables_markdown(data)
-
- # FK link
- assert 'FK → [TestTable_ID](#TestTable_ID)' in markdown
-
- # Target anchor exists
- assert '' in markdown
-
-
-class TestGenerateSQLSchemas:
- """Tests for generate_sql_schemas function."""
-
- def test_generates_sql_file_with_timestamp_and_version(self, sample_csv_file, tmp_path):
- """Test that SQL files are generated with timestamp and version in filename."""
- data = parse_parts_table(sample_csv_file)
- sql_path = tmp_path / "sql_output"
- sql_path.mkdir()
-
- generate_sql_schemas(data, sql_path, ['mssql'])
-
- # Check that a file was created
- sql_files = list(sql_path.glob('*.sql'))
- assert len(sql_files) == 1
-
- # Check filename format: YYYY-MM-DDTHH:MM:SS.ffffff_version_mssql.sql
- sql_file = sql_files[0]
- filename = sql_file.name
-
- # Should contain version
- assert '0.1.0' in filename # Current package version
-
- # Should contain database type
- assert 'mssql.sql' in filename
-
- # Should contain ISO timestamp format (just check for basic structure)
- assert filename.count('_') >= 2 # timestamp_version_mssql.sql
-
- def test_generates_multiple_database_schemas(self, sample_csv_file, tmp_path):
- """Test that multiple database schemas can be generated."""
- data = parse_parts_table(sample_csv_file)
- sql_path = tmp_path / "sql_output"
- sql_path.mkdir()
-
- # Currently only mssql is supported, but test the loop structure
- generate_sql_schemas(data, sql_path, ['mssql'])
-
- sql_files = list(sql_path.glob('*_mssql.sql'))
- assert len(sql_files) == 1
-
- def test_generated_sql_contains_valid_schema(self, sample_csv_file, tmp_path):
- """Test that generated SQL contains expected schema elements."""
- data = parse_parts_table(sample_csv_file)
- sql_path = tmp_path / "sql_output"
- sql_path.mkdir()
-
- generate_sql_schemas(data, sql_path, ['mssql'])
-
- sql_file = list(sql_path.glob('*.sql'))[0]
- sql_content = sql_file.read_text(encoding='utf-8')
-
- # Should contain basic SQL elements
- assert 'CREATE TABLE [TestTable]' in sql_content
- assert 'PRIMARY KEY' in sql_content
- assert 'FOREIGN KEY' in sql_content
-
- def test_writes_to_correct_path(self, sample_csv_file, tmp_path):
- """Test that SQL files are written to the specified path."""
- data = parse_parts_table(sample_csv_file)
- sql_path = tmp_path / "custom_sql_dir"
- sql_path.mkdir()
-
- generate_sql_schemas(data, sql_path, ['mssql'])
-
- # Should write to custom directory
- assert any(sql_path.glob('*.sql'))
- assert len(list(sql_path.glob('*.sql'))) == 1
-
-
-class TestOnPreBuild:
- """Tests for on_pre_build MkDocs hook."""
-
- @pytest.fixture
- def mock_config(self, tmp_path, sample_csv_data):
- """Create a mock MkDocs config."""
- # Setup directory structure
- project_root = tmp_path / "project"
- project_root.mkdir()
- docs_dir = project_root / "docs"
- docs_dir.mkdir()
- reference_dir = docs_dir / "reference"
- reference_dir.mkdir()
- sql_dir = project_root / "sql_generation_scripts"
- sql_dir.mkdir()
- src_dir = project_root / "src"
- src_dir.mkdir()
-
- # Create dictionary CSV
- csv_file = src_dir / "dictionary.csv"
- csv_file.write_text(sample_csv_data)
-
- # Create mock config object
- config = {
- 'config_file_path': str(project_root / "mkdocs.yml"),
- 'docs_dir': str(docs_dir)
- }
-
- return config, project_root, docs_dir, reference_dir, sql_dir
-
- def test_generates_all_output_files(self, mock_config):
- """Test that on_pre_build generates all expected output files."""
- config, project_root, docs_dir, reference_dir, sql_dir = mock_config
-
- on_pre_build(config)
-
- # Check that markdown files were created
- assert (reference_dir / "tables.md").exists()
- assert (reference_dir / "valuesets.md").exists()
-
- # Check that SQL files were created
- sql_files = list(sql_dir.glob("*.sql"))
- assert len(sql_files) > 0
- assert any('mssql.sql' in f.name for f in sql_files)
-
- def test_tables_md_contains_expected_content(self, mock_config):
- """Test that generated tables.md has correct content."""
- config, project_root, docs_dir, reference_dir, sql_dir = mock_config
-
- on_pre_build(config)
-
- tables_content = (reference_dir / "tables.md").read_text(encoding='utf-8')
-
- # Should have standard elements
- assert '# Database Tables' in tables_content
- assert '### Test Table' in tables_content
- assert 'A test table' in tables_content
- assert '' in tables_content
-
- def test_valuesets_md_contains_expected_content(self, mock_config):
- """Test that generated valuesets.md has correct content."""
- config, project_root, docs_dir, reference_dir, sql_dir = mock_config
-
- on_pre_build(config)
-
- valuesets_content = (reference_dir / "valuesets.md").read_text(encoding='utf-8')
-
- # Should have standard elements
- assert '# Value Sets' in valuesets_content
- assert '## Status Set' in valuesets_content
- assert 'Valid status values' in valuesets_content
-
- def test_sql_schemas_generated_with_correct_format(self, mock_config):
- """Test that SQL schemas have correct filename format."""
- config, project_root, docs_dir, reference_dir, sql_dir = mock_config
-
- on_pre_build(config)
-
- sql_files = list(sql_dir.glob("*.sql"))
- assert len(sql_files) > 0
-
- # Check filename format
- for sql_file in sql_files:
- filename = sql_file.name
- # Should have format: timestamp_version_dbtype.sql
- assert '_mssql.sql' in filename
- assert '0.1.0' in filename # Version should be included
-
- def test_handles_missing_reference_directory(self, mock_config):
- """Test that on_pre_build works even if reference directory doesn't exist initially."""
- config, project_root, docs_dir, reference_dir, sql_dir = mock_config
-
- # Remove reference directory
- import shutil
- shutil.rmtree(reference_dir)
-
- # Create it fresh
- reference_dir.mkdir()
-
- # Should work without errors
- on_pre_build(config)
-
- assert (reference_dir / "tables.md").exists()
- assert (reference_dir / "valuesets.md").exists()
-
- def test_uses_target_dbs_constant(self, mock_config):
- """Test that on_pre_build respects TARGET_DBS constant."""
- config, project_root, docs_dir, reference_dir, sql_dir = mock_config
-
- on_pre_build(config)
-
- # Should generate MSSQL schema (per TARGET_DBS = ["mssql"])
- sql_files = list(sql_dir.glob("*_mssql.sql"))
- assert len(sql_files) == 1
-
- def test_reads_csv_from_project_root(self, mock_config):
- """Test that on_pre_build correctly locates dictionary.csv in src directory."""
- config, project_root, docs_dir, reference_dir, sql_dir = mock_config
-
- # CSV was created in src directory by fixture
- csv_path = project_root / "src" / "dictionary.csv"
- assert csv_path.exists()
-
- # Should read and process without errors
- on_pre_build(config)
-
- # Verify it actually read the CSV by checking output
- tables_content = (reference_dir / "tables.md").read_text(encoding='utf-8')
- assert 'TestTable' in tables_content
\ No newline at end of file
diff --git a/tests/test_sql_gen.py b/tests/test_sql_gen.py
deleted file mode 100644
index 274619a..0000000
--- a/tests/test_sql_gen.py
+++ /dev/null
@@ -1,390 +0,0 @@
-import pytest
-import csv
-from pathlib import Path
-from io import StringIO
-import sys
-import os
-
-# Add hooks directory to path
-sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'docs/hooks'))
-
-from generate_docs import (
- parse_parts_table,
- generate_sql_schema,
- generate_field_definition,
- generate_foreign_key_constraint,
- validate_no_circular_fks,
- get_db_config,
- extract_field_name
-)
-from fixtures import sample_csv_data
-
-@pytest.fixture
-def sample_csv_file(tmp_path, sample_csv_data):
- """Create a temporary CSV file."""
- csv_file = tmp_path / "test_parts.csv"
- csv_file.write_text(sample_csv_data)
- return csv_file
-
-
-class TestGenerateSQLSchema:
- """Tests for SQL schema generation."""
-
- def test_generates_create_table_statement(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- sql = generate_sql_schema(data)
-
- assert 'CREATE TABLE [TestTable]' in sql
-
- def test_includes_all_fields(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- sql = generate_sql_schema(data)
-
- # Field names (no table prefixes since TestTable is mixed-case)
- assert '[TestTable_ID]' in sql # ID field keeps full name
- assert '[Name]' in sql # Regular field
- assert '[Status]' in sql # Regular field
-
- def test_generates_primary_key_constraint(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- sql = generate_sql_schema(data)
-
- assert 'CONSTRAINT [PK_TestTable] PRIMARY KEY' in sql
- assert '[TestTable_ID]' in sql
-
- def test_marks_required_fields_not_null(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- sql = generate_sql_schema(data)
-
- # Required field should have NOT NULL
- lines = [l for l in sql.split('\n') if '[TestTable_ID]' in l and 'PRIMARY KEY' not in l]
- assert any('NOT NULL' in l for l in lines)
-
- def test_marks_optional_fields_null(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- sql = generate_sql_schema(data)
-
- # Optional field should have NULL
- lines = [l for l in sql.split('\n') if '[Status]' in l]
- assert any('NULL' in l for l in lines)
-
- def test_generates_foreign_key_constraints(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- sql = generate_sql_schema(data)
-
- assert 'ALTER TABLE [TestTable]' in sql
- assert 'FOREIGN KEY ([Parent_ID])' in sql # Field name is Parent_ID (no prefix)
- assert 'REFERENCES [TestTable] ([TestTable_ID])' in sql # References TestTable_ID
-
- def test_handles_composite_keys(self, tmp_path):
- csv_content = """Part_ID,Label,Description,Part_type,Value_set_part_ID,Member_of_set_part_ID,SQL_data_type,Is_required,Default_value,Sort_order,JunctionTable_present,JunctionTable_required,JunctionTable_order
-JunctionTable,Junction Table,Many-to-many junction,table,,,,,,,,,
-Table1_ID,Table1 ID,"Identifier for Table1, used in junction",key,,,int,True,,1,compositeKeyFirst,True,1
-Table2_ID,Table2 ID,"Identifier for Table2, used in junction",key,,,int,True,,2,compositeKeySecond,True,2
-"""
- csv_file = tmp_path / "composite_key.csv"
- csv_file.write_text(csv_content)
-
- data = parse_parts_table(csv_file)
- sql = generate_sql_schema(data)
-
- # Should have composite primary key
- assert 'PRIMARY KEY ([Table1_ID], [Table2_ID])' in sql
-
- def test_sql_is_executable(self, sample_csv_file):
- """Basic syntax check - should not have obvious SQL errors."""
- data = parse_parts_table(sample_csv_file)
- sql = generate_sql_schema(data)
-
- # Check balanced brackets
- assert sql.count('[') == sql.count(']')
-
- # Check balanced parentheses
- assert sql.count('(') == sql.count(')')
-
- # Should end statements with semicolons
- assert 'CREATE TABLE' in sql
- create_statements = [s for s in sql.split(';') if 'CREATE TABLE' in s]
- assert all(');' in s or s.strip().endswith(')') for s in create_statements)
-
- def test_includes_target_db_in_header(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- sql = generate_sql_schema(data, target_db='mssql')
-
- assert 'Target database: MSSQL' in sql
-
-
-class TestGenerateFieldDefinition:
- """Tests for individual field SQL generation."""
-
- def test_generates_simple_field(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- db_config = get_db_config('mssql')
- field = data['tables']['TestTable']['fields'][1] # Name field (TestTable_Name)
-
- field_sql = generate_field_definition(field, data, db_config)
-
- # TestTable_Name -> Name (table prefix removed)
- assert '[Name]' in field_sql or '[TestTable_Name]' in field_sql # Accept both
- assert 'nvarchar(255)' in field_sql
- assert 'NOT NULL' in field_sql
-
- def test_handles_default_values(self, tmp_path):
- csv_content = """Part_ID,Label,Description,Part_type,Value_set_part_ID,Member_of_set_part_ID,SQL_data_type,Is_required,Default_value,Sort_order,TestTable_present,TestTable_order
-TestTable,Test,Test,table,,,,,,,,
-Active,Active,Is active,property,,,bit,False,False,1,property,1
-"""
- csv_file = tmp_path / "default.csv"
- csv_file.write_text(csv_content)
-
- data = parse_parts_table(csv_file)
- db_config = get_db_config('mssql')
- field = data['tables']['TestTable']['fields'][0]
-
- field_sql = generate_field_definition(field, data, db_config)
-
- assert 'DEFAULT 0' in field_sql
-
- def test_applies_type_mappings(self, tmp_path):
- csv_content = """Part_ID,Label,Description,Part_type,Value_set_part_ID,Member_of_set_part_ID,SQL_data_type,Is_required,Default_value,Sort_order,TestTable_present,TestTable_order
-TestTable,Test,Test,table,,,,,,,,
-Notes,Notes,Long text,property,,,ntext,False,,1,property,1
-"""
- csv_file = tmp_path / "ntext.csv"
- csv_file.write_text(csv_content)
-
- data = parse_parts_table(csv_file)
- db_config = get_db_config('mssql')
- field = data['tables']['TestTable']['fields'][0]
-
- field_sql = generate_field_definition(field, data, db_config)
-
- # ntext should be mapped to nvarchar(max) for MSSQL
- assert 'nvarchar(max)' in field_sql
- assert 'ntext' not in field_sql
-
-
-class TestGenerateForeignKeyConstraint:
- """Tests for foreign key constraint generation."""
-
- def test_generates_fk_constraint(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- db_config = get_db_config('mssql')
- field = next(f for f in data['tables']['TestTable']['fields']
- if f['part_id'] == 'Parent_ID')
-
- fk_sql = generate_foreign_key_constraint('TestTable', field, db_config)
-
- assert 'ALTER TABLE [TestTable]' in fk_sql
- assert 'ADD CONSTRAINT' in fk_sql
- assert 'FOREIGN KEY ([Parent_ID])' in fk_sql
- assert 'REFERENCES [TestTable] ([TestTable_ID])' in fk_sql
-
- def test_returns_none_for_non_fk_field(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- db_config = get_db_config('mssql')
- field = data['tables']['TestTable']['fields'][0] # ID field, no FK
-
- fk_sql = generate_foreign_key_constraint('TestTable', field, db_config)
-
- assert fk_sql is None
-
-
-class TestCircularDependencyDetection:
- """Tests for circular FK validation."""
-
- def test_detects_simple_circular_dependency(self, tmp_path):
- csv_content = """Part_ID,Label,Description,Part_type,Value_set_part_ID,Member_of_set_part_ID,SQL_data_type,Is_required,Default_value,Sort_order,TableA_present,TableA_order,TableB_present,TableB_order
-TableA,Table A,First table,table,,,,,,,,,
-TableB,Table B,Second table,table,,,,,,,,,
-TableA_ID,Table A ID,Identifier for TableA,key,,,int,True,,1,key,1,property,2
-TableB_ID,Table B ID,Identifier for TableB,key,,,int,True,,1,property,2,key,1
-"""
- csv_file = tmp_path / "circular.csv"
- csv_file.write_text(csv_content)
-
- data = parse_parts_table(csv_file)
-
- with pytest.raises(ValueError, match="Circular foreign key dependencies"):
- generate_sql_schema(data)
-
- def test_allows_self_referential_fks(self, tmp_path):
- csv_content = """Part_ID,Label,Description,Part_type,Value_set_part_ID,Member_of_set_part_ID,SQL_data_type,Is_required,Default_value,Sort_order,TableA_present,TableA_order
-TableA,Table A,Hierarchical table,table,,,,,,,,
-TableA_ID,Table A ID,Identifier for TableA,key,,,int,True,,1,key,1
-TableA_Parent_ID,Parent ID,FK to parent TableA,property,,,int,False,,2,property,2
-"""
- csv_file = tmp_path / "self_ref.csv"
- csv_file.write_text(csv_content)
-
- data = parse_parts_table(csv_file)
-
- # Should NOT raise - self-referential is OK
- sql = generate_sql_schema(data)
- assert 'CREATE TABLE' in sql
-
- def test_allows_chain_dependencies(self, tmp_path):
- csv_content = """Part_ID,Label,Description,Part_type,Value_set_part_ID,Member_of_set_part_ID,SQL_data_type,Is_required,Default_value,Sort_order,TableA_present,TableA_order,TableB_present,TableB_order,TableC_present,TableC_order
-TableA,Table A,First,table,,,,,,,,,,,,
-TableA_ID,Table A ID,PK,key,,,int,True,,1,key,1,,,,
-TableB,Table B,Second,table,,,,,,,,,,,,
-TableB_ID,Table B ID,PK,key,,,int,True,,1,,,key,1,,
-TableA_ID,Table A ID,A ref,key,,,int,False,,2,,,property,2,,
-TableC,Table C,Third,table,,,,,,,,,,,,
-TableC_ID,Table C ID,PK,key,,,int,True,,1,,,,,key,1
-TableB_ID,Table B ID,B ref,key,,,int,False,,2,,,,,property,2
-"""
- csv_file = tmp_path / "chain.csv"
- csv_file.write_text(csv_content)
-
- data = parse_parts_table(csv_file)
-
- # Should NOT raise - A->B->C is fine
- sql = generate_sql_schema(data)
- assert 'CREATE TABLE' in sql
-
- def test_validation_function_directly(self, tmp_path):
- csv_content = """Part_ID,Label,Description,Part_type,Value_set_part_ID,Member_of_set_part_ID,SQL_data_type,Is_required,Default_value,Sort_order,TableA_present,TableA_order,TableB_present,TableB_order
-TableA,Table A,First table,table,,,,,,,,,
-TableB,Table B,Second table,table,,,,,,,,,
-TableA_ID,Table A ID,Identifier for TableA,key,,,int,True,,1,key,1,property,2
-TableB_ID,Table B ID,Identifier for TableB,key,,,int,True,,1,property,2,key,1
-"""
- csv_file = tmp_path / "circular.csv"
- csv_file.write_text(csv_content)
-
- data = parse_parts_table(csv_file)
-
- with pytest.raises(ValueError) as exc_info:
- validate_no_circular_fks(data)
-
- error_msg = str(exc_info.value)
- assert 'TableA' in error_msg
- assert 'TableB' in error_msg
- assert '↔' in error_msg
-
-
-class TestDatabaseTargeting:
- """Tests for multi-database support."""
-
- def test_uses_mssql_bracket_quoting(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- sql = generate_sql_schema(data, target_db='mssql')
-
- assert '[TestTable]' in sql
- assert '[TestTable_ID]' in sql
-
- def test_converts_ntext_to_nvarchar_max(self, tmp_path):
- csv_content = """Part_ID,Label,Description,Part_type,Value_set_part_ID,Member_of_set_part_ID,SQL_data_type,Is_required,Default_value,Sort_order,TableA_present,TableA_order
-TableA,Table A,Test,table,,,,,,,,
-TableA_ID,Table A ID,PK,key,,,int,True,,1,key,1
-Notes,Notes,Long text,property,,,ntext,False,,2,property,2
-"""
- csv_file = tmp_path / "ntext.csv"
- csv_file.write_text(csv_content)
-
- data = parse_parts_table(csv_file)
- sql = generate_sql_schema(data, target_db='mssql')
-
- # ntext should be converted to nvarchar(max)
- assert 'nvarchar(max)' in sql
- # Check that ntext doesn't appear in actual SQL (only in comments is OK)
- sql_lines = [line for line in sql.split('\n') if not line.strip().startswith('--')]
- sql_without_comments = '\n'.join(sql_lines)
- assert 'ntext' not in sql_without_comments.lower()
-
- def test_rejects_unsupported_database(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
-
- with pytest.raises(ValueError, match="Unsupported database"):
- generate_sql_schema(data, target_db='oracle')
-
- def test_get_db_config_returns_correct_structure(self):
- config = get_db_config('mssql')
-
- assert 'quote_char' in config
- assert 'type_mappings' in config
- assert 'supports_check_constraints' in config
- assert callable(config['quote'])
-
- def test_db_config_quote_function(self):
- config = get_db_config('mssql')
-
- quoted = config['quote']('TableName')
- assert quoted == '[TableName]'
-
-
-class TestExtractFieldName:
- """Tests for field name extraction helper - NEW FORMAT."""
-
- def test_extracts_field_from_part_id(self):
- # ID fields are kept as-is (these are the actual SQL field names)
- assert extract_field_name('TestTable_ID') == 'TestTable_ID'
- assert extract_field_name('Equipment_ID') == 'Equipment_ID'
- assert extract_field_name('Contact_ID') == 'Contact_ID'
-
- # Table-prefixed non-ID fields: remove lowercase table prefix
- assert extract_field_name('site_City') == 'City'
- assert extract_field_name('contact_City') == 'City'
- assert extract_field_name('purpose_Description') == 'Description'
-
- def test_handles_part_id_without_underscore(self):
- # Non-prefixed fields stay as-is
- assert extract_field_name('SimpleField') == 'SimpleField'
- assert extract_field_name('Forest') == 'Forest'
-
- def test_handles_multiple_underscores(self):
- # Mixed case fields with underscores (not table-prefixed)
- assert extract_field_name('Street_number') == 'Street_number'
- assert extract_field_name('Latitude_GPS') == 'Latitude_GPS'
-
-
-class TestSQLIntegration:
- """Integration tests for complete SQL generation."""
-
- def test_generates_complete_valid_schema(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- sql = generate_sql_schema(data)
-
- # Should have all major components
- assert 'CREATE TABLE' in sql
- assert 'PRIMARY KEY' in sql
- assert 'ALTER TABLE' in sql
- assert 'FOREIGN KEY' in sql
- assert 'REFERENCES' in sql
-
- def test_table_order_is_deterministic(self, sample_csv_file):
- data = parse_parts_table(sample_csv_file)
- sql1 = generate_sql_schema(data, include_timestamp=False)
- sql2 = generate_sql_schema(data, include_timestamp=False)
-
- # Should generate identical SQL on repeated calls
- assert sql1 == sql2
-
- def test_handles_complex_schema(self, tmp_path):
- csv_content = """Part_ID,Label,Description,Part_type,Value_set_part_ID,Member_of_set_part_ID,SQL_data_type,Is_required,Default_value,Sort_order,Parent_present,Parent_order,Child_present,Child_order
-Parent,Parent,Parent table,table,,,,,,,,
-Parent_ID,Parent ID,Identifier for Parent,key,,,int,True,,1,key,1,,
-Name,Name,Name,property,,,nvarchar(100),True,,2,property,2,,
-Child,Child,Child table,table,,,,,,,,
-Child_ID,Child ID,Identifier for Child,key,,,int,True,,1,,,key,1
-Parent_ID,Parent ID,Identifier for Parent,key,,,int,True,,2,,,property,2
-Status,Status,Status,property,StatusSet,,nvarchar(50),False,pending,3,,,property,3
-StatusSet,Status Set,Valid statuses,valueSet,,,,,,,,,
-pending,Pending,Pending status,valueSetMember,,StatusSet,nvarchar(50),,,1,,
-active,Active,Active status,valueSetMember,,StatusSet,nvarchar(50),,,2,,
-"""
- csv_file = tmp_path / "complex.csv"
- csv_file.write_text(csv_content)
-
- data = parse_parts_table(csv_file)
- sql = generate_sql_schema(data)
-
- # Parent table should be created
- assert 'CREATE TABLE [Parent]' in sql
- # Child table should be created
- assert 'CREATE TABLE [Child]' in sql
- # FK relationship should exist
- assert 'REFERENCES [Parent]' in sql
- # Default value should be present
- assert "DEFAULT 'pending'" in sql
\ No newline at end of file
diff --git a/tests/unit/test_dictionary_reference.py b/tests/unit/test_dictionary_reference.py
new file mode 100644
index 0000000..58cc192
--- /dev/null
+++ b/tests/unit/test_dictionary_reference.py
@@ -0,0 +1,336 @@
+"""Tests for dictionary reference documentation generation."""
+
+import pytest
+import json
+from pathlib import Path
+import sys
+
+# Add scripts directory to path
+sys.path.insert(0, str(Path(__file__).parent.parent / "tests"))
+sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
+
+from generate_dictionary_reference import (
+ parse_parts_json,
+ generate_tables_markdown,
+ generate_value_sets_markdown,
+)
+from fixtures.sample_dictionary import sample_dictionary_data
+
+
+@pytest.fixture
+def sample_json_file(tmp_path):
+ """Create a temporary JSON file with sample dictionary data."""
+ json_file = tmp_path / "test_dictionary.json"
+ json_file.write_text(json.dumps(sample_dictionary_data(), indent=2))
+ return json_file
+
+
+class TestParsePartsJson:
+ """Tests for JSON parsing."""
+
+ def test_parse_identifies_tables(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+
+ assert "test_table" in data["tables"]
+ assert data["tables"]["test_table"]["label"] == "Test Table"
+ assert data["tables"]["test_table"]["description"] == "A test table for demonstration"
+
+ def test_parse_identifies_fields(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+
+ fields = data["tables"]["test_table"]["fields"]
+ assert len(fields) >= 3
+
+ # Check primary key
+ pk_field = next(f for f in fields if f["part_id"] == "TestTable_ID")
+ assert pk_field["part_type"] == "key"
+ assert pk_field["is_required"] is True
+ assert pk_field["sql_data_type"] == "int"
+
+ def test_parse_identifies_value_sets(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+
+ assert "StatusSet" in data["value_sets"]
+ assert data["value_sets"]["StatusSet"]["label"] == "Status Set"
+ assert len(data["value_sets"]["StatusSet"]["members"]) == 3
+
+ def test_parse_sorts_fields_by_sort_order(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+
+ fields = data["tables"]["test_table"]["fields"]
+ sort_orders = [f["sort_order"] for f in fields]
+ assert sort_orders == sorted(sort_orders)
+
+ def test_parse_sorts_value_set_members(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+
+ members = data["value_sets"]["StatusSet"]["members"]
+ sort_orders = [m["sort_order"] for m in members]
+ assert sort_orders == sorted(sort_orders)
+
+
+class TestGenerateTablesMarkdown:
+ """Tests for tables markdown generation."""
+
+ def test_generates_valid_markdown(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ markdown = generate_tables_markdown(data)
+
+ assert "# Database Tables" in markdown
+ assert "## Tables" in markdown
+
+ def test_includes_table_headings(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ markdown = generate_tables_markdown(data)
+
+ assert "### Test Table" in markdown
+ assert "A test table for demonstration" in markdown
+
+ def test_includes_table_anchors(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ markdown = generate_tables_markdown(data)
+
+ # Check for invisible anchor span
+ assert '' in markdown
+
+ def test_includes_field_anchors(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ markdown = generate_tables_markdown(data)
+
+ # Check for field anchors in description column
+ assert '' in markdown
+ assert '' in markdown
+
+ def test_generates_fields_table(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ markdown = generate_tables_markdown(data)
+
+ # Check table header
+ assert "| Field | SQL Type | Value Set | Required | Description | Constraints |" in markdown
+ assert "|-------|----------|-----------|----------|-------------|-------------|" in markdown
+
+ def test_marks_primary_keys(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ markdown = generate_tables_markdown(data)
+
+ # Primary key should have PK marker
+ assert "int **(PK)**" in markdown
+
+ def test_marks_required_fields(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ markdown = generate_tables_markdown(data)
+
+ # Should have checkmarks for required fields
+ assert "✓" in markdown
+
+ def test_links_to_value_sets(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ markdown = generate_tables_markdown(data)
+
+ # Should link to value set
+ assert "[StatusSet](valuesets.md#StatusSet)" in markdown
+
+
+class TestGenerateValueSetsMarkdown:
+ """Tests for value sets markdown generation."""
+
+ def test_generates_valid_markdown(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ markdown = generate_value_sets_markdown(data)
+
+ assert "# Value Sets" in markdown
+
+ def test_includes_value_set_headings(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ markdown = generate_value_sets_markdown(data)
+
+ assert "## Status Set" in markdown
+ assert "Valid status values for records" in markdown
+
+ def test_includes_value_set_anchors(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ markdown = generate_value_sets_markdown(data)
+
+ # Check for invisible anchor span
+ assert '' in markdown
+
+ def test_includes_member_anchors(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ markdown = generate_value_sets_markdown(data)
+
+ # Check for member anchors
+ assert '' in markdown
+ assert '' in markdown
+
+ def test_generates_members_table(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ markdown = generate_value_sets_markdown(data)
+
+ # Check table header
+ assert "| Value | Description |" in markdown
+ assert "|-------|-------------|" in markdown
+
+ def test_lists_all_members(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ markdown = generate_value_sets_markdown(data)
+
+ assert "`active`" in markdown
+ assert "Record is currently active" in markdown
+ assert "`inactive`" in markdown
+ assert "Record is currently inactive" in markdown
+
+ def test_handles_empty_value_sets(self, tmp_path):
+ # JSON with no value sets
+ json_content = {
+ "parts": [
+ {
+ "Part_ID": "test_table",
+ "Label": "Test Table",
+ "Description": "A test table",
+ "Part_type": "table",
+ }
+ ]
+ }
+ json_file = tmp_path / "empty_valuesets.json"
+ json_file.write_text(json.dumps(json_content))
+
+ data = parse_parts_json(json_file)
+ markdown = generate_value_sets_markdown(data)
+
+ assert "No value sets currently appear in dictionary" in markdown
+
+
+class TestIntegration:
+ """Integration tests checking cross-referencing."""
+
+ def test_value_set_links_are_bidirectional(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ tables_md = generate_tables_markdown(data)
+ valuesets_md = generate_value_sets_markdown(data)
+
+ # Table should link to value set
+ assert "[StatusSet](valuesets.md#StatusSet)" in tables_md
+
+ # Value set should have anchor that table links to
+ assert '' in valuesets_md
+
+
+class TestEdgeCases:
+ """Test edge cases and error handling for dictionary reference generation."""
+
+ def test_empty_description_handling(self, tmp_path):
+ """Test handling of empty descriptions."""
+ json_data = {
+ "parts": [
+ {"Part_ID": "test_table", "Label": "Test Table", "Description": "", "Part_type": "table"}
+ ]
+ }
+ json_file = tmp_path / "empty_desc.json"
+ json_file.write_text(json.dumps(json_data))
+
+ # Empty descriptions should not be allowed by validation
+ from pydantic import ValidationError
+ with pytest.raises(ValidationError):
+ data = parse_parts_json(json_file)
+
+ def test_table_with_no_fields(self, tmp_path):
+ """Test handling of table with no fields."""
+ json_data = {
+ "parts": [
+ {"Part_ID": "empty_table", "Label": "Empty Table", "Description": "No fields", "Part_type": "table"}
+ ]
+ }
+ json_file = tmp_path / "no_fields.json"
+ json_file.write_text(json.dumps(json_data))
+
+ data = parse_parts_json(json_file)
+ markdown = generate_tables_markdown(data)
+
+ # Should handle tables with no fields
+ assert "### Empty Table" in markdown
+ assert "No fields" in markdown
+
+ def test_value_set_with_no_members(self, tmp_path):
+ """Test handling of value set with no members."""
+ json_data = {
+ "parts": [
+ {"Part_ID": "EmptySet", "Label": "Empty Set", "Description": "No members", "Part_type": "valueSet"}
+ ]
+ }
+ json_file = tmp_path / "empty_set.json"
+ json_file.write_text(json.dumps(json_data))
+
+ data = parse_parts_json(json_file)
+ markdown = generate_value_sets_markdown(data)
+
+ # Should handle empty value sets
+ assert "## Empty Set" in markdown
+ assert "No members" in markdown
+
+ def test_special_characters_in_descriptions(self, tmp_path):
+ """Test that special characters in descriptions are handled properly."""
+ json_data = {
+ "parts": [
+ {
+ "Part_ID": "test_table",
+ "Label": "Test Table",
+ "Description": "Table with special chars: < > & \" '",
+ "Part_type": "table",
+ }
+ ]
+ }
+ json_file = tmp_path / "special_chars.json"
+ json_file.write_text(json.dumps(json_data))
+
+ data = parse_parts_json(json_file)
+ markdown = generate_tables_markdown(data)
+
+ # Should include the description with special characters
+ assert "special chars" in markdown
+
+ def test_very_long_table_names(self, tmp_path):
+ """Test handling of very long table names."""
+ long_name = "VeryLongTableNameThatExceedsNormalConventionsButIsStillValid"
+ json_data = {
+ "parts": [
+ {
+ "Part_ID": long_name,
+ "Label": "Very Long Table Name",
+ "Description": "Test long names",
+ "Part_type": "table",
+ }
+ ]
+ }
+ json_file = tmp_path / "long_name.json"
+ json_file.write_text(json.dumps(json_data))
+
+ data = parse_parts_json(json_file)
+ markdown = generate_tables_markdown(data)
+
+ # Should handle long table names
+ assert "Very Long Table Name" in markdown
+ assert f'' in markdown
+
+ def test_missing_file_raises_error(self, tmp_path):
+ """Test that missing JSON file raises appropriate error."""
+ non_existent = tmp_path / "nonexistent.json"
+
+ with pytest.raises(FileNotFoundError):
+ parse_parts_json(non_existent)
+
+ def test_malformed_json_raises_error(self, tmp_path):
+ """Test that malformed JSON raises appropriate error."""
+ json_file = tmp_path / "malformed.json"
+ json_file.write_text("{ this is not valid json }")
+
+ with pytest.raises(json.JSONDecodeError):
+ parse_parts_json(json_file)
+
+ def test_foreign_key_display(self, sample_json_file):
+ """Test that foreign keys are properly displayed in markdown."""
+ data = parse_parts_json(sample_json_file)
+ markdown = generate_tables_markdown(data)
+
+ # Parent_ID FK should be displayed with link
+ assert "FK →" in markdown
+ assert "[TestTable_ID]" in markdown
diff --git a/tests/unit/test_erd_generator.py b/tests/unit/test_erd_generator.py
new file mode 100644
index 0000000..1cb689f
--- /dev/null
+++ b/tests/unit/test_erd_generator.py
@@ -0,0 +1,228 @@
+"""
+Tests for ERD generator module.
+"""
+
+import pytest
+from pathlib import Path
+import json
+import sys
+
+# Add src and scripts to path
+project_root = Path(__file__).parent.parent
+sys.path.insert(0, str(project_root / 'src'))
+sys.path.insert(0, str(project_root / 'scripts'))
+
+from generate_erd import (
+ generate_erd_data,
+ generate_erd_html,
+ ERDTable,
+ ERDField,
+ ERDRelationship
+)
+
+
+@pytest.fixture
+def sample_parts_data():
+ """Sample parsed parts data for testing."""
+ return {
+ 'tables': {
+ 'contact': {
+ 'label': 'Contact',
+ 'description': 'Stores contact information',
+ 'fields': [
+ {
+ 'part_id': 'Contact_ID',
+ 'label': 'Contact ID',
+ 'description': 'Primary key',
+ 'part_type': 'key',
+ 'sql_data_type': 'int',
+ 'is_required': True,
+ 'default_value': '',
+ 'fk_to': '',
+ 'value_set': '',
+ 'sort_order': 1
+ },
+ {
+ 'part_id': 'First_name',
+ 'label': 'First Name',
+ 'description': 'Contact first name',
+ 'part_type': 'property',
+ 'sql_data_type': 'nvarchar(255)',
+ 'is_required': False,
+ 'default_value': '',
+ 'fk_to': '',
+ 'value_set': '',
+ 'sort_order': 2
+ }
+ ]
+ },
+ 'metadata': {
+ 'label': 'Metadata',
+ 'description': 'Stores metadata',
+ 'fields': [
+ {
+ 'part_id': 'Metadata_ID',
+ 'label': 'Metadata ID',
+ 'description': 'Primary key',
+ 'part_type': 'key',
+ 'sql_data_type': 'int',
+ 'is_required': True,
+ 'default_value': '',
+ 'fk_to': '',
+ 'value_set': '',
+ 'sort_order': 1
+ },
+ {
+ 'part_id': 'Contact_ID',
+ 'label': 'Contact ID',
+ 'description': 'Foreign key to contact',
+ 'part_type': 'property',
+ 'sql_data_type': 'int',
+ 'is_required': False,
+ 'default_value': '',
+ 'fk_to': 'Contact_ID',
+ 'value_set': '',
+ 'sort_order': 2
+ }
+ ]
+ }
+ },
+ 'value_sets': {},
+ 'metadata': {},
+ 'id_field_locations': {}
+ }
+
+
+def test_generate_erd_data(sample_parts_data):
+ """Test ERD data generation from parts data."""
+ erd_data = generate_erd_data(sample_parts_data)
+
+ # Check structure
+ assert 'tables' in erd_data
+ assert 'relationships' in erd_data
+
+ # Check tables
+ assert len(erd_data['tables']) == 2
+ table_ids = [t['id'] for t in erd_data['tables']]
+ assert 'contact' in table_ids
+ assert 'metadata' in table_ids
+
+ # Check relationships
+ assert len(erd_data['relationships']) == 1
+ rel = erd_data['relationships'][0]
+ assert rel['from_table'] == 'metadata'
+ assert rel['to_table'] == 'contact'
+ assert rel['from_field'] == 'Contact ID'
+
+
+def test_erd_field_detection(sample_parts_data):
+ """Test that primary and foreign keys are correctly identified."""
+ erd_data = generate_erd_data(sample_parts_data)
+
+ # Find contact table
+ contact_table = next(t for t in erd_data['tables'] if t['id'] == 'contact')
+
+ # Check PK field
+ pk_field = next(f for f in contact_table['fields'] if f['name'] == 'Contact ID')
+ assert pk_field['is_pk'] is True
+ assert pk_field['is_fk'] is False
+
+ # Find metadata table
+ metadata_table = next(t for t in erd_data['tables'] if t['id'] == 'metadata')
+
+ # Check FK field
+ fk_field = next(f for f in metadata_table['fields'] if f['name'] == 'Contact ID')
+ assert fk_field['is_fk'] is True
+ assert fk_field['fk_target'] == 'contact.Contact_ID'
+
+
+def test_generate_jointjs_html(sample_parts_data, tmp_path):
+ """Test JointJS HTML generation."""
+ erd_data = generate_erd_data(sample_parts_data)
+ output_path = tmp_path / 'erd_test.html'
+
+ generate_erd_html(erd_data, output_path, library='jointjs')
+
+ # Check file was created
+ assert output_path.exists()
+
+ # Check content
+ content = output_path.read_text()
+ assert 'JointJS' in content or 'jointjs' in content
+ assert 'Contact' in content
+ assert 'Metadata' in content
+ assert 'const erdData' in content
+ assert 'dagre' in content # Ensure layout engine is included
+
+
+def test_invalid_library(sample_parts_data, tmp_path):
+ """Test that invalid library raises error."""
+ erd_data = generate_erd_data(sample_parts_data)
+ output_path = tmp_path / 'erd_invalid.html'
+
+ with pytest.raises(ValueError, match="Unsupported library"):
+ generate_erd_html(erd_data, output_path, library='invalid')
+
+
+def test_empty_tables():
+ """Test ERD generation with no tables."""
+ empty_data = {
+ 'tables': {},
+ 'value_sets': {},
+ 'metadata': {},
+ 'id_field_locations': {}
+ }
+
+ erd_data = generate_erd_data(empty_data)
+
+ assert erd_data['tables'] == []
+ assert erd_data['relationships'] == []
+
+
+def test_self_referential_relationship():
+ """Test handling of self-referential foreign keys (parent keys)."""
+ data = {
+ 'tables': {
+ 'category': {
+ 'label': 'Category',
+ 'description': 'Hierarchical categories',
+ 'fields': [
+ {
+ 'part_id': 'Category_ID',
+ 'label': 'Category ID',
+ 'description': 'Primary key',
+ 'part_type': 'key',
+ 'sql_data_type': 'int',
+ 'is_required': True,
+ 'default_value': '',
+ 'fk_to': '',
+ 'value_set': '',
+ 'sort_order': 1
+ },
+ {
+ 'part_id': 'Parent_Category_ID',
+ 'label': 'Parent Category ID',
+ 'description': 'Parent category',
+ 'part_type': 'property',
+ 'sql_data_type': 'int',
+ 'is_required': False,
+ 'default_value': '',
+ 'fk_to': 'Category_ID',
+ 'value_set': '',
+ 'sort_order': 2
+ }
+ ]
+ }
+ },
+ 'value_sets': {},
+ 'metadata': {},
+ 'id_field_locations': {}
+ }
+
+ erd_data = generate_erd_data(data)
+
+ # Should have one self-referential relationship
+ assert len(erd_data['relationships']) == 1
+ rel = erd_data['relationships'][0]
+ assert rel['from_table'] == 'category'
+ assert rel['to_table'] == 'category'
diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py
new file mode 100644
index 0000000..d4572c8
--- /dev/null
+++ b/tests/unit/test_helpers.py
@@ -0,0 +1,547 @@
+"""Tests for DictionaryManager class in helpers.py."""
+
+import pytest
+import json
+import tempfile
+from pathlib import Path
+import sys
+
+# Add src and fixtures to path
+sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
+sys.path.insert(0, str(Path(__file__).parent.parent / "fixtures"))
+
+from open_dateaubase.data_model.helpers import DictionaryManager
+from open_dateaubase.data_model.models import Dictionary
+from sample_dictionary import (
+ sample_dictionary_data,
+ complex_dictionary_data,
+ edge_case_dictionary_data,
+ invalid_dictionary_data,
+)
+
+
+class TestDictionaryManager:
+ """Test basic DictionaryManager functionality."""
+
+ def test_load_valid_dictionary(self, tmp_path):
+ """Test loading a valid dictionary JSON file."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+
+ assert manager.dictionary is not None
+ assert (
+ len(manager.dictionary.parts) == 9
+ ) # Count parts in sample data (including value set members)
+ assert manager.path == dict_file
+
+ def test_load_invalid_json(self, tmp_path):
+ """Test loading invalid JSON raises error."""
+ dict_file = tmp_path / "invalid.json"
+ dict_file.write_text("{ invalid json }")
+
+ with pytest.raises(json.JSONDecodeError):
+ DictionaryManager.load(dict_file)
+
+ def test_save_dictionary(self, tmp_path):
+ """Test saving dictionary to JSON file."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+
+ # Modify and save
+ save_file = tmp_path / "saved_dict.json"
+ manager.save(save_file)
+
+ assert save_file.exists()
+ saved_data = json.loads(save_file.read_text())
+
+ # Should have PascalCase keys
+ assert "Part_ID" in saved_data["parts"][0]
+ assert "Part_type" in saved_data["parts"][0]
+
+ def test_save_to_original_path(self, tmp_path):
+ """Test saving dictionary to original path when no path specified."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+ manager.save() # Save to original path
+
+ # File should be updated
+ modified_time = dict_file.stat().st_mtime
+ assert modified_time > 0
+
+
+class TestValueSetOperations:
+ """Test value set creation and management."""
+
+ def test_create_value_set(self, tmp_path):
+ """Test creating a new value set."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+ manager.create_value_set("NewStatusSet", "New Status Values", "Test status set")
+
+ # Check value set was created
+ new_vs = manager._find_part("NewStatusSet")
+ assert new_vs is not None
+ assert new_vs.part_id == "NewStatusSet"
+ assert new_vs.label == "New Status Values"
+ assert hasattr(new_vs, "part_type") and new_vs.part_type == "valueSet"
+
+ def test_create_duplicate_value_set_fails(self, tmp_path):
+ """Test creating duplicate value set raises error."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+
+ with pytest.raises(ValueError, match="Part 'StatusSet' already exists"):
+ manager.create_value_set("StatusSet", "Duplicate", "Should fail")
+
+ def test_add_value_set_member(self, tmp_path):
+ """Test adding a member to existing value set."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+ manager.add_value_set_member(
+ "StatusSet", "suspended", "Suspended", "Suspended status", order=4
+ )
+
+ # Check member was added
+ new_member = manager._find_part("suspended")
+ assert new_member is not None
+ assert new_member.part_id == "suspended"
+ assert new_member.label == "Suspended"
+ assert (
+ hasattr(new_member, "member_of_set_part_id")
+ and new_member.member_of_set_part_id == "StatusSet"
+ )
+ assert new_member.sort_order == 4
+
+ def test_add_member_to_nonexistent_value_set_fails(self, tmp_path):
+ """Test adding member to non-existent value set raises error."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+
+ with pytest.raises(
+ ValueError, match="Value set 'NonExistentSet' does not exist"
+ ):
+ manager.add_value_set_member(
+ "NonExistentSet", "test", "Test", "Test member"
+ )
+
+ def test_add_duplicate_member_fails(self, tmp_path):
+ """Test adding duplicate member raises error."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+
+ with pytest.raises(ValueError, match="Part 'active' already exists"):
+ manager.add_value_set_member(
+ "StatusSet", "active", "Duplicate Active", "Should fail"
+ )
+
+
+class TestTableOperations:
+ """Test table creation and field management."""
+
+ def test_create_table(self, tmp_path):
+ """Test creating a new table."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+ manager.create_table("new_table", "New Table", "A new test table")
+
+ # Check table was created
+ new_table = manager._find_part("new_table")
+ assert new_table is not None
+ assert new_table.part_id == "new_table"
+ assert new_table.label == "New Table"
+ assert hasattr(new_table, "part_type") and new_table.part_type == "table"
+
+ def test_create_duplicate_table_fails(self, tmp_path):
+ """Test creating duplicate table raises error."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+
+ with pytest.raises(ValueError, match="Part 'test_table' already exists"):
+ manager.create_table("test_table", "Duplicate", "Should fail")
+
+ def test_add_field_to_table(self, tmp_path):
+ """Test adding a new field to existing table."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+ manager.add_field_to_table(
+ table_id="test_table",
+ field_id="NewField",
+ label="New Field",
+ description="A new test field",
+ role="property",
+ sql_data_type="nvarchar(100)",
+ required=True,
+ order=5,
+ )
+
+ # Check field was created
+ new_field = manager._find_part("NewField")
+ assert new_field is not None
+ assert new_field.part_id == "NewField"
+ assert new_field.label == "New Field"
+ assert hasattr(new_field, "part_type") and new_field.part_type == "property"
+ assert (
+ hasattr(new_field, "table_presence")
+ and "test_table" in new_field.table_presence
+ )
+
+ def test_add_field_to_nonexistent_table_fails(self, tmp_path):
+ """Test adding field to non-existent table raises error."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+
+ with pytest.raises(ValueError, match="Table 'NonExistentTable' does not exist"):
+ manager.add_field_to_table(
+ "NonExistentTable", "TestField", "Test Field", "Test description"
+ )
+
+ def test_add_key_field(self, tmp_path):
+ """Test adding a primary key field."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+ manager.add_field_to_table(
+ table_id="test_table",
+ field_id="NewTable_ID",
+ label="New Table ID",
+ description="Primary key for new table",
+ role="key",
+ sql_data_type="int",
+ required=True,
+ order=6,
+ )
+
+ # Check key field was created
+ new_key = manager._find_part("NewTable_ID")
+ assert new_key is not None
+ assert new_key.part_id == "NewTable_ID"
+ assert hasattr(new_key, "part_type") and new_key.part_type == "key"
+ assert (
+ hasattr(new_key, "table_presence")
+ and "test_table" in new_key.table_presence
+ )
+
+ def test_add_parent_key(self, tmp_path):
+ """Test adding a parent key for hierarchical relationships."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+ manager.add_parent_key(
+ table_id="test_table",
+ parent_key_id="Parent_TestTable_ID",
+ ancestor_key_id="TestTable_ID",
+ label="Parent TestTable ID",
+ description="Parent reference",
+ sql_data_type="int",
+ required=False,
+ order=5,
+ )
+
+ # Check parent key was created
+ parent_key = manager._find_part("Parent_TestTable_ID")
+ assert parent_key is not None
+ assert parent_key.part_id == "Parent_TestTable_ID"
+ assert hasattr(parent_key, "part_type") and parent_key.part_type == "parentKey"
+ assert (
+ hasattr(parent_key, "ancestor_part_id")
+ and parent_key.ancestor_part_id == "TestTable_ID"
+ )
+
+
+class TestQueryOperations:
+ """Test query methods for retrieving dictionary information."""
+
+ def test_get_value_set_members(self, tmp_path):
+ """Test retrieving members of a value set."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+ members = manager.get_value_set_members("Status")
+
+ assert len(members) == 3
+ member_ids = [m["Part_ID"] for m in members]
+ assert "active" in member_ids
+ assert "inactive" in member_ids
+ assert "pending" in member_ids
+
+ # Check sorting by sort_order
+ sort_orders = [m["Sort_order"] for m in members]
+ assert sort_orders == sorted(sort_orders)
+
+ def test_get_value_set_members_nonexistent_field(self, tmp_path):
+ """Test getting members for non-existent field returns empty list."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+ members = manager.get_value_set_members("NonExistentField")
+
+ assert members == []
+
+ def test_get_value_set_members_field_without_value_set(self, tmp_path):
+ """Test getting members for field without value set returns empty list."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+ members = manager.get_value_set_members(
+ "Description"
+ ) # This field has no value set
+
+ assert members == []
+
+ def test_get_table_columns(self, tmp_path):
+ """Test retrieving all columns for a table."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+ columns = manager.get_table_columns("test_table")
+
+ assert len(columns) == 4 # TestTable_ID, Status, Description, Parent_ID
+ column_ids = [c["Part_ID"] for c in columns]
+ assert "TestTable_ID" in column_ids
+ assert "Status" in column_ids
+ assert "Description" in column_ids
+ assert "Parent_ID" in column_ids
+
+ # Check sorting by order
+ orders = [c["Order"] for c in columns]
+ assert orders == sorted(orders)
+
+ def test_get_table_columns_nonexistent_table(self, tmp_path):
+ """Test getting columns for non-existent table returns empty list."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+ columns = manager.get_table_columns("NonExistentTable")
+
+ assert columns == []
+
+ def test_get_primary_keys(self, tmp_path):
+ """Test retrieving all primary keys in dictionary."""
+ dict_data = complex_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+ primary_keys = manager.get_primary_keys()
+
+ assert len(primary_keys) == 2 # Contact_ID, Project_ID
+ key_ids = [k["Part_ID"] for k in primary_keys]
+ assert "Contact_ID" in key_ids
+ assert "Project_ID" in key_ids
+
+ def test_list_tables(self, tmp_path):
+ """Test listing all tables in dictionary."""
+ dict_data = complex_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+ tables = manager.list_tables()
+
+ assert len(tables) == 3 # contact, project, project_has_contact
+ assert "contact" in tables
+ assert "project" in tables
+ assert "project_has_contact" in tables
+
+ def test_list_value_sets(self, tmp_path):
+ """Test listing all value sets in dictionary."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+ value_sets = manager.list_value_sets()
+
+ assert len(value_sets) == 1
+ assert "StatusSet" in value_sets
+
+
+class TestEdgeCasesAndErrorHandling:
+ """Test edge cases and error handling in DictionaryManager."""
+
+ def test_handle_very_long_field_names(self, tmp_path):
+ """Test handling of fields with very long names."""
+ dict_data = edge_case_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+
+ # Should be able to load and work with long field names
+ long_field = manager._find_part(
+ "VeryLongFieldNameThatExceedsNormalDatabaseLimitsAndMightCauseIssues"
+ )
+ assert long_field is not None
+ assert len(long_field.part_id) > 50
+
+ def test_handle_special_characters_in_names(self, tmp_path):
+ """Test handling of special characters in table/field names."""
+ dict_data = edge_case_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+
+ # Should handle special characters in labels but not in part IDs
+ special_table = manager._find_part("special_table")
+ assert special_table is not None
+ assert "Special-Table!" in special_table.label
+ assert "@" in special_table.description
+
+ def test_validate_method(self, tmp_path):
+ """Test explicit validation method."""
+ dict_data = sample_dictionary_data()
+ dict_file = tmp_path / "test_dict.json"
+ dict_file.write_text(json.dumps(dict_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+
+ # Should not raise for valid data
+ manager.validate()
+
+ # Corrupt the data in a way that will fail validation
+ original_part_id = manager.dictionary.parts[0].part_id
+ manager.dictionary.parts[0].part_id = "" # Invalid empty ID
+ with pytest.raises(ValueError):
+ manager.validate()
+
+ # Restore for cleanup
+ manager.dictionary.parts[0].part_id = original_part_id
+
+ def test_build_complete_dictionary_workflow(self, tmp_path):
+ """Test building a complete dictionary from scratch."""
+ # Start with empty dictionary
+ empty_data = {"parts": []}
+ dict_file = tmp_path / "empty_dict.json"
+ dict_file.write_text(json.dumps(empty_data, indent=2))
+
+ manager = DictionaryManager.load(dict_file)
+
+ # Create value set
+ manager.create_value_set(
+ "PrioritySet", "Priority Levels", "Task priority levels"
+ )
+ manager.add_value_set_member(
+ "PrioritySet", "high", "High", "High priority", order=1
+ )
+ manager.add_value_set_member(
+ "PrioritySet", "medium", "Medium", "Medium priority", order=2
+ )
+ manager.add_value_set_member(
+ "PrioritySet", "low", "Low", "Low priority", order=3
+ )
+
+ # Create table
+ manager.create_table("task", "Task", "Task management table")
+
+ # Add fields to table
+ manager.add_field_to_table(
+ "task",
+ "Task_ID",
+ "Task ID",
+ "Primary key",
+ role="key",
+ sql_data_type="int",
+ required=True,
+ order=1,
+ )
+ manager.add_field_to_table(
+ "task",
+ "Title",
+ "Title",
+ "Task title",
+ role="property",
+ sql_data_type="nvarchar(255)",
+ required=True,
+ order=2,
+ )
+ manager.add_field_to_table(
+ "task",
+ "Priority",
+ "Priority",
+ "Task priority",
+ role="property",
+ sql_data_type="nvarchar(20)",
+ required=False,
+ value_set_id="PrioritySet",
+ order=3,
+ )
+ manager.add_field_to_table(
+ "task",
+ "Created_Date",
+ "Created Date",
+ "Creation timestamp",
+ role="property",
+ sql_data_type="datetime",
+ required=True,
+ default_value="GETDATE()",
+ order=4,
+ )
+
+ # Verify the complete structure
+ tables = manager.list_tables()
+ value_sets = manager.list_value_sets()
+ task_columns = manager.get_table_columns("task")
+ priority_members = manager.get_value_set_members("Priority")
+
+ assert len(tables) == 1
+ assert len(value_sets) == 1
+ assert len(task_columns) == 4 # Task_ID, Title, Priority, Created_Date
+ assert len(priority_members) == 3
+
+ # Verify specific relationships
+ priority_field = manager._find_part("Priority")
+ assert (
+ hasattr(priority_field, "value_set_part_id")
+ and priority_field.value_set_part_id == "PrioritySet"
+ )
diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py
new file mode 100644
index 0000000..73a0514
--- /dev/null
+++ b/tests/unit/test_models.py
@@ -0,0 +1,145 @@
+"""Tests for Pydantic models."""
+
+import pytest
+import sys
+from pathlib import Path
+
+# Add src to path
+sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
+
+from open_dateaubase.data_model.models import (
+ Dictionary,
+ TablePart,
+ KeyPart,
+ PropertyPart,
+ TablePresence,
+ ValueSetPart,
+ ValueSetMemberPart,
+ ParentKeyPart,
+)
+
+
+class TestTablePresence:
+ def test_valid_table_presence(self):
+ presence = TablePresence(
+ role="key", required=True, order=1, relationship_type=None
+ )
+ assert presence.role == "key"
+ assert presence.required is True
+ assert presence.order == 1
+
+
+class TestTablePart:
+ def test_valid_table(self):
+ table = TablePart(
+ Part_ID="test_table",
+ Label="Test Table",
+ Description="A test table",
+ Part_type="table",
+ Sort_order=None,
+ )
+ assert table.part_id == "test_table"
+
+
+class TestKeyPart:
+ def test_valid_key(self):
+ key = KeyPart(
+ Part_ID="Test_ID",
+ Label="Test ID",
+ Description="Test identifier",
+ Part_type="key",
+ SQL_data_type=None,
+ Is_required=False,
+ Default_value=None,
+ Value_set_part_ID=None,
+ table_presence={
+ "test_table": TablePresence(
+ role="key", required=True, order=1, relationship_type=None
+ )
+ },
+ )
+ assert key.part_id == "Test_ID"
+
+ def test_key_without_id_suffix(self):
+ with pytest.raises(ValueError, match="should end with '_ID'"):
+ KeyPart(
+ Part_ID="TestKey",
+ Label="Test",
+ Description="Test",
+ Part_type="key",
+ SQL_data_type=None,
+ Is_required=False,
+ Default_value=None,
+ Value_set_part_ID=None,
+ table_presence={
+ "test": TablePresence(
+ role="key", required=True, order=1, relationship_type=None
+ )
+ },
+ )
+
+
+class TestParentKeyPart:
+ def test_valid_parent_key(self):
+ parent = ParentKeyPart(
+ Part_ID="Parent_ID",
+ Label="Parent",
+ Description="Hierarchical parent",
+ Part_type="parentKey",
+ Ancestor_part_ID="Test_ID",
+ SQL_data_type=None,
+ Is_required=False,
+ Default_value=None,
+ Value_set_part_ID=None,
+ table_presence={
+ "test": TablePresence(
+ role="property", required=False, order=2, relationship_type=None
+ )
+ },
+ )
+ assert parent.ancestor_part_id == "Test_ID"
+
+
+class TestDictionary:
+ def test_valid_dictionary(self):
+ data = {
+ "parts": [
+ {
+ "Part_ID": "test_table",
+ "Label": "Test",
+ "Description": "Test table",
+ "Part_type": "table",
+ },
+ {
+ "Part_ID": "Test_ID",
+ "Label": "Test ID",
+ "Description": "Test key",
+ "Part_type": "key",
+ "table_presence": {
+ "test_table": {"role": "key", "required": True, "order": 1}
+ },
+ },
+ ]
+ }
+ dictionary = Dictionary.model_validate(data)
+ assert len(dictionary.parts) == 2
+
+ def test_duplicate_part_ids(self):
+ data = {
+ "parts": [
+ {
+ "Part_ID": "duplicate",
+ "Label": "Dup 1",
+ "Description": "First",
+ "Part_type": "table",
+ },
+ {
+ "Part_ID": "duplicate",
+ "Label": "Dup 2",
+ "Description": "Second",
+ "Part_type": "table",
+ },
+ ]
+ }
+ with pytest.raises(ValueError, match="Duplicate Part_IDs"):
+ Dictionary.model_validate(data)
diff --git a/tests/unit/test_sql_generator.py b/tests/unit/test_sql_generator.py
new file mode 100644
index 0000000..b3d15d5
--- /dev/null
+++ b/tests/unit/test_sql_generator.py
@@ -0,0 +1,323 @@
+"""Tests for SQL schema generation from dictionary."""
+
+import pytest
+import json
+from pathlib import Path
+import sys
+
+# Add scripts directory to path
+sys.path.insert(0, str(Path(__file__).parent.parent / "tests"))
+sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
+
+from generate_sql import (
+ parse_parts_json,
+ generate_sql_schema,
+ generate_field_definition,
+ generate_foreign_key_constraint,
+ validate_no_circular_fks,
+ get_db_config,
+ extract_field_name,
+ generate_sql_schemas,
+)
+from fixtures.sample_dictionary import sample_dictionary_data
+
+
+@pytest.fixture
+def sample_json_file(tmp_path):
+ """Create a temporary JSON file with sample dictionary data."""
+ json_file = tmp_path / "test_dictionary.json"
+ json_file.write_text(json.dumps(sample_dictionary_data(), indent=2))
+ return json_file
+
+
+class TestParsePartsJson:
+ """Tests for JSON parsing function."""
+
+ def test_parse_identifies_tables(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ assert "test_table" in data["tables"]
+ assert data["tables"]["test_table"]["label"] == "Test Table"
+
+ def test_parse_identifies_fields(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ fields = data["tables"]["test_table"]["fields"]
+
+ # Should have: ID, Status, Description, Parent_ID
+ assert len(fields) >= 3
+
+ # Check primary key field exists
+ pk_field = next((f for f in fields if f["part_id"] == "TestTable_ID"), None)
+ assert pk_field is not None
+ assert pk_field["part_type"] == "key"
+ assert pk_field["is_required"] is True
+
+ def test_parse_identifies_value_sets(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ assert "StatusSet" in data["value_sets"]
+ assert len(data["value_sets"]["StatusSet"]["members"]) == 3
+
+
+class TestGenerateSQLSchema:
+ """Tests for SQL schema generation."""
+
+ def test_generates_create_table_statement(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ sql = generate_sql_schema(data)
+ assert "CREATE TABLE [test_table]" in sql
+
+ def test_includes_all_fields(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ sql = generate_sql_schema(data)
+
+ assert "[TestTable_ID]" in sql
+ assert "[Status]" in sql
+ assert "[Description]" in sql
+
+ def test_generates_primary_key_constraint(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ sql = generate_sql_schema(data)
+
+ assert "CONSTRAINT [PK_test_table] PRIMARY KEY" in sql
+ assert "[TestTable_ID]" in sql
+
+ def test_marks_required_fields_not_null(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ sql = generate_sql_schema(data)
+
+ lines = sql.split("\n")
+ testtable_id_line = next((l for l in lines if "[TestTable_ID]" in l and "PRIMARY KEY" not in l), None)
+ assert testtable_id_line is not None
+ assert "NOT NULL" in testtable_id_line
+
+ def test_generates_foreign_key_constraints(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ sql = generate_sql_schema(data)
+
+ assert "ALTER TABLE [test_table]" in sql
+ assert "FOREIGN KEY ([Parent_ID])" in sql
+ assert "REFERENCES [TestTable] ([TestTable_ID])" in sql
+
+
+class TestExtractFieldName:
+ """Tests for field name extraction helper."""
+
+ def test_extracts_field_from_part_id(self):
+ # ID fields are kept as-is
+ assert extract_field_name("TestTable_ID") == "TestTable_ID"
+ assert extract_field_name("Equipment_ID") == "Equipment_ID"
+
+ # Table-prefixed non-ID fields: remove lowercase table prefix
+ assert extract_field_name("site_City") == "City"
+ assert extract_field_name("contact_City") == "City"
+
+ def test_handles_part_id_without_underscore(self):
+ assert extract_field_name("SimpleField") == "SimpleField"
+
+ def test_handles_multiple_underscores(self):
+ assert extract_field_name("Street_number") == "Street_number"
+
+
+class TestDatabaseTargeting:
+ """Tests for multi-database support."""
+
+ def test_uses_mssql_bracket_quoting(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+ sql = generate_sql_schema(data, target_db="mssql")
+
+ assert "[test_table]" in sql
+ assert "[TestTable_ID]" in sql
+
+ def test_rejects_unsupported_database(self, sample_json_file):
+ data = parse_parts_json(sample_json_file)
+
+ with pytest.raises(ValueError, match="Unsupported database"):
+ generate_sql_schema(data, target_db="oracle")
+
+ def test_get_db_config_returns_correct_structure(self):
+ config = get_db_config("mssql")
+
+ assert "quote_char" in config
+ assert "type_mappings" in config
+ assert "supports_check_constraints" in config
+ assert callable(config["quote"])
+
+
+class TestEdgeCases:
+ """Test edge cases and error handling for SQL generation."""
+
+ def test_deprecated_ntext_type_mapping(self, tmp_path):
+ """Test that deprecated ntext type is mapped to nvarchar(max)."""
+ json_data = {
+ "parts": [
+ {"Part_ID": "test_table", "Label": "Test", "Description": "Test", "Part_type": "table"},
+ {
+ "Part_ID": "Notes",
+ "Label": "Notes",
+ "Description": "Long text",
+ "Part_type": "property",
+ "SQL_data_type": "ntext",
+ "Is_required": False,
+ "table_presence": {"test_table": {"role": "property", "required": False, "order": 1}},
+ },
+ ]
+ }
+ json_file = tmp_path / "ntext.json"
+ json_file.write_text(json.dumps(json_data))
+
+ data = parse_parts_json(json_file)
+ sql = generate_sql_schema(data)
+
+ # ntext should be converted to nvarchar(max)
+ assert "nvarchar(max)" in sql
+ # ntext should not appear in actual SQL (only in comments is OK)
+ sql_lines = [line for line in sql.split("\n") if not line.strip().startswith("--")]
+ sql_without_comments = "\n".join(sql_lines)
+ assert "ntext" not in sql_without_comments.lower()
+
+ def test_boolean_default_value_conversion(self, tmp_path):
+ """Test that boolean default values are converted to 0/1."""
+ json_data = {
+ "parts": [
+ {"Part_ID": "test_table", "Label": "Test", "Description": "Test", "Part_type": "table"},
+ {
+ "Part_ID": "IsActive",
+ "Label": "Is Active",
+ "Description": "Boolean field",
+ "Part_type": "property",
+ "SQL_data_type": "bit",
+ "Default_value": "True",
+ "Is_required": False,
+ "table_presence": {"test_table": {"role": "property", "required": False, "order": 1}},
+ },
+ ]
+ }
+ json_file = tmp_path / "bool.json"
+ json_file.write_text(json.dumps(json_data))
+
+ data = parse_parts_json(json_file)
+ sql = generate_sql_schema(data)
+
+ # Boolean default should be converted to 1
+ assert "DEFAULT 1" in sql
+
+ def test_very_long_field_names(self):
+ """Test that very long field names are handled correctly."""
+ long_name = "VeryLongFieldNameThatExceedsNormalDatabaseLimitsAndMightCauseIssues"
+ assert extract_field_name(long_name) == long_name
+
+ def test_field_names_with_numbers(self):
+ """Test field names containing numbers."""
+ assert extract_field_name("Field_1") == "Field_1"
+ assert extract_field_name("table_Field123") == "Field123"
+ assert extract_field_name("Field123_ID") == "Field123_ID"
+
+ def test_empty_string_field_name(self):
+ """Test handling of empty string field name."""
+ assert extract_field_name("") == ""
+
+ def test_single_character_field_names(self):
+ """Test single character field names."""
+ assert extract_field_name("A") == "A"
+ assert extract_field_name("X") == "X"
+
+ def test_empty_tables_list(self, tmp_path):
+ """Test handling of dictionary with no tables."""
+ json_data = {"parts": []}
+ json_file = tmp_path / "empty.json"
+ json_file.write_text(json.dumps(json_data))
+
+ data = parse_parts_json(json_file)
+ sql = generate_sql_schema(data)
+
+ # Should generate valid SQL header even with no tables
+ assert "Auto-generated SQL schema" in sql
+
+ def test_missing_json_file_raises_error(self, tmp_path):
+ """Test that missing JSON file raises appropriate error."""
+ non_existent = tmp_path / "nonexistent.json"
+
+ with pytest.raises(FileNotFoundError):
+ parse_parts_json(non_existent)
+
+ def test_malformed_json_raises_error(self, tmp_path):
+ """Test that malformed JSON raises appropriate error."""
+ json_file = tmp_path / "malformed.json"
+ json_file.write_text("{ this is not valid json }")
+
+ with pytest.raises(json.JSONDecodeError):
+ parse_parts_json(json_file)
+
+ def test_self_referential_fk_allowed(self, tmp_path):
+ """Test that self-referential FKs (hierarchical) are allowed."""
+ json_data = {
+ "parts": [
+ {"Part_ID": "category", "Label": "Category", "Description": "Hierarchical", "Part_type": "table"},
+ {
+ "Part_ID": "Category_ID",
+ "Label": "Category ID",
+ "Description": "PK",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "Is_required": True,
+ "table_presence": {"category": {"role": "key", "required": True, "order": 1}},
+ },
+ {
+ "Part_ID": "Parent_Category_ID",
+ "Label": "Parent Category ID",
+ "Description": "Parent",
+ "Part_type": "parentKey",
+ "Ancestor_part_ID": "Category_ID",
+ "SQL_data_type": "int",
+ "Is_required": False,
+ "table_presence": {"category": {"role": "property", "required": False, "order": 2}},
+ },
+ ]
+ }
+ json_file = tmp_path / "self_ref.json"
+ json_file.write_text(json.dumps(json_data))
+
+ data = parse_parts_json(json_file)
+
+ # Should NOT raise - self-referential FKs are allowed
+ sql = generate_sql_schema(data)
+ assert "CREATE TABLE" in sql
+
+ def test_table_with_many_fields(self, tmp_path):
+ """Test handling of tables with many fields."""
+ parts = [
+ {"Part_ID": "big_table", "Label": "Big Table", "Description": "Table with many fields", "Part_type": "table"},
+ # Add primary key
+ {
+ "Part_ID": "BigTable_ID",
+ "Label": "Big Table ID",
+ "Description": "Primary key",
+ "Part_type": "key",
+ "SQL_data_type": "int",
+ "Is_required": True,
+ "table_presence": {"big_table": {"role": "key", "required": True, "order": 1}},
+ }
+ ]
+
+ # Add 50 fields
+ for i in range(50):
+ parts.append({
+ "Part_ID": f"Field_{i}",
+ "Label": f"Field {i}",
+ "Description": f"Field number {i}",
+ "Part_type": "property",
+ "SQL_data_type": "int",
+ "Is_required": False,
+ "table_presence": {"big_table": {"role": "property", "required": False, "order": i + 2}},
+ })
+
+ json_data = {"parts": parts}
+ json_file = tmp_path / "big_table.json"
+ json_file.write_text(json.dumps(json_data))
+
+ data = parse_parts_json(json_file)
+ sql = generate_sql_schema(data)
+
+ # Should generate SQL without errors
+ assert "CREATE TABLE [big_table]" in sql
+ assert "[Field_0]" in sql
+ assert "[Field_49]" in sql
diff --git a/uv.lock b/uv.lock
deleted file mode 100644
index b55939d..0000000
--- a/uv.lock
+++ /dev/null
@@ -1,538 +0,0 @@
-version = 1
-revision = 3
-requires-python = ">=3.12"
-
-[[package]]
-name = "babel"
-version = "2.17.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" },
-]
-
-[[package]]
-name = "backrefs"
-version = "5.9"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/eb/a7/312f673df6a79003279e1f55619abbe7daebbb87c17c976ddc0345c04c7b/backrefs-5.9.tar.gz", hash = "sha256:808548cb708d66b82ee231f962cb36faaf4f2baab032f2fbb783e9c2fdddaa59", size = 5765857, upload-time = "2025-06-22T19:34:13.97Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/19/4d/798dc1f30468134906575156c089c492cf79b5a5fd373f07fe26c4d046bf/backrefs-5.9-py310-none-any.whl", hash = "sha256:db8e8ba0e9de81fcd635f440deab5ae5f2591b54ac1ebe0550a2ca063488cd9f", size = 380267, upload-time = "2025-06-22T19:34:05.252Z" },
- { url = "https://files.pythonhosted.org/packages/55/07/f0b3375bf0d06014e9787797e6b7cc02b38ac9ff9726ccfe834d94e9991e/backrefs-5.9-py311-none-any.whl", hash = "sha256:6907635edebbe9b2dc3de3a2befff44d74f30a4562adbb8b36f21252ea19c5cf", size = 392072, upload-time = "2025-06-22T19:34:06.743Z" },
- { url = "https://files.pythonhosted.org/packages/9d/12/4f345407259dd60a0997107758ba3f221cf89a9b5a0f8ed5b961aef97253/backrefs-5.9-py312-none-any.whl", hash = "sha256:7fdf9771f63e6028d7fee7e0c497c81abda597ea45d6b8f89e8ad76994f5befa", size = 397947, upload-time = "2025-06-22T19:34:08.172Z" },
- { url = "https://files.pythonhosted.org/packages/10/bf/fa31834dc27a7f05e5290eae47c82690edc3a7b37d58f7fb35a1bdbf355b/backrefs-5.9-py313-none-any.whl", hash = "sha256:cc37b19fa219e93ff825ed1fed8879e47b4d89aa7a1884860e2db64ccd7c676b", size = 399843, upload-time = "2025-06-22T19:34:09.68Z" },
- { url = "https://files.pythonhosted.org/packages/fc/24/b29af34b2c9c41645a9f4ff117bae860291780d73880f449e0b5d948c070/backrefs-5.9-py314-none-any.whl", hash = "sha256:df5e169836cc8acb5e440ebae9aad4bf9d15e226d3bad049cf3f6a5c20cc8dc9", size = 411762, upload-time = "2025-06-22T19:34:11.037Z" },
- { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" },
-]
-
-[[package]]
-name = "certifi"
-version = "2025.10.5"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
-]
-
-[[package]]
-name = "charset-normalizer"
-version = "3.4.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
- { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
- { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
- { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
- { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
- { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
- { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
- { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
- { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
- { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
- { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
- { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
- { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
- { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
- { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
- { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
- { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
- { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
- { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
- { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
- { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
- { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
- { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
- { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
- { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
- { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
- { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
- { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
- { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
- { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
- { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
- { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
- { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
- { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
- { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
- { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
- { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
- { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
- { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
- { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
- { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
- { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
- { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
- { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
- { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
- { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
- { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
- { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
- { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
-]
-
-[[package]]
-name = "click"
-version = "8.3.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
-]
-
-[[package]]
-name = "colorama"
-version = "0.4.6"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
-]
-
-[[package]]
-name = "ghp-import"
-version = "2.1.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "python-dateutil" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" },
-]
-
-[[package]]
-name = "idna"
-version = "3.11"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
-]
-
-[[package]]
-name = "iniconfig"
-version = "2.3.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
-]
-
-[[package]]
-name = "jinja2"
-version = "3.1.6"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "markupsafe" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
-]
-
-[[package]]
-name = "markdown"
-version = "3.9"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/8d/37/02347f6d6d8279247a5837082ebc26fc0d5aaeaf75aa013fcbb433c777ab/markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a", size = 364585, upload-time = "2025-09-04T20:25:22.885Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/70/ae/44c4a6a4cbb496d93c6257954260fe3a6e91b7bed2240e5dad2a717f5111/markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280", size = 107441, upload-time = "2025-09-04T20:25:21.784Z" },
-]
-
-[[package]]
-name = "markupsafe"
-version = "3.0.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
- { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
- { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
- { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
- { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
- { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
- { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
- { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
- { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
- { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
- { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
- { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
- { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
- { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
- { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
- { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
- { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
- { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
- { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
- { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
- { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
- { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
- { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
- { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
- { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
- { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
- { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
- { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
- { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
- { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
- { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
- { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
- { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
- { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
- { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
- { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
- { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
- { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
- { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
- { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
- { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
- { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
- { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
- { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
- { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
- { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
- { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
- { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
- { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
- { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
- { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
- { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
- { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
- { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
- { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
-]
-
-[[package]]
-name = "mergedeep"
-version = "1.3.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" },
-]
-
-[[package]]
-name = "mkdocs"
-version = "1.6.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "click" },
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "ghp-import" },
- { name = "jinja2" },
- { name = "markdown" },
- { name = "markupsafe" },
- { name = "mergedeep" },
- { name = "mkdocs-get-deps" },
- { name = "packaging" },
- { name = "pathspec" },
- { name = "pyyaml" },
- { name = "pyyaml-env-tag" },
- { name = "watchdog" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" },
-]
-
-[[package]]
-name = "mkdocs-get-deps"
-version = "0.2.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "mergedeep" },
- { name = "platformdirs" },
- { name = "pyyaml" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" },
-]
-
-[[package]]
-name = "mkdocs-material"
-version = "9.6.22"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "babel" },
- { name = "backrefs" },
- { name = "colorama" },
- { name = "jinja2" },
- { name = "markdown" },
- { name = "mkdocs" },
- { name = "mkdocs-material-extensions" },
- { name = "paginate" },
- { name = "pygments" },
- { name = "pymdown-extensions" },
- { name = "requests" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/5f/5d/317e37b6c43325cb376a1d6439df9cc743b8ee41c84603c2faf7286afc82/mkdocs_material-9.6.22.tar.gz", hash = "sha256:87c158b0642e1ada6da0cbd798a3389b0bc5516b90e5ece4a0fb939f00bacd1c", size = 4044968, upload-time = "2025-10-15T09:21:15.409Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/cc/82/6fdb9a7a04fb222f4849ffec1006f891a0280825a20314d11f3ccdee14eb/mkdocs_material-9.6.22-py3-none-any.whl", hash = "sha256:14ac5f72d38898b2f98ac75a5531aaca9366eaa427b0f49fc2ecf04d99b7ad84", size = 9206252, upload-time = "2025-10-15T09:21:12.175Z" },
-]
-
-[[package]]
-name = "mkdocs-material-extensions"
-version = "1.3.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" },
-]
-
-[[package]]
-name = "open-dateaubase"
-version = "0.1.0"
-source = { virtual = "." }
-dependencies = [
- { name = "mkdocs" },
- { name = "mkdocs-material" },
-]
-
-[package.dev-dependencies]
-dev = [
- { name = "pytest" },
-]
-
-[package.metadata]
-requires-dist = [
- { name = "mkdocs", specifier = ">=1.6.1" },
- { name = "mkdocs-material", specifier = ">=9.6.22" },
-]
-
-[package.metadata.requires-dev]
-dev = [{ name = "pytest", specifier = ">=8.4.2" }]
-
-[[package]]
-name = "packaging"
-version = "25.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
-]
-
-[[package]]
-name = "paginate"
-version = "0.5.7"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" },
-]
-
-[[package]]
-name = "pathspec"
-version = "0.12.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
-]
-
-[[package]]
-name = "platformdirs"
-version = "4.5.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
-]
-
-[[package]]
-name = "pluggy"
-version = "1.6.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
-]
-
-[[package]]
-name = "pygments"
-version = "2.19.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
-]
-
-[[package]]
-name = "pymdown-extensions"
-version = "10.16.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "markdown" },
- { name = "pyyaml" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/55/b3/6d2b3f149bc5413b0a29761c2c5832d8ce904a1d7f621e86616d96f505cc/pymdown_extensions-10.16.1.tar.gz", hash = "sha256:aace82bcccba3efc03e25d584e6a22d27a8e17caa3f4dd9f207e49b787aa9a91", size = 853277, upload-time = "2025-07-28T16:19:34.167Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e4/06/43084e6cbd4b3bc0e80f6be743b2e79fbc6eed8de9ad8c629939fa55d972/pymdown_extensions-10.16.1-py3-none-any.whl", hash = "sha256:d6ba157a6c03146a7fb122b2b9a121300056384eafeec9c9f9e584adfdb2a32d", size = 266178, upload-time = "2025-07-28T16:19:31.401Z" },
-]
-
-[[package]]
-name = "pytest"
-version = "8.4.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "iniconfig" },
- { name = "packaging" },
- { name = "pluggy" },
- { name = "pygments" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
-]
-
-[[package]]
-name = "python-dateutil"
-version = "2.9.0.post0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "six" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
-]
-
-[[package]]
-name = "pyyaml"
-version = "6.0.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
- { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
- { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
- { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
- { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
- { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
- { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
- { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
- { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
- { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
- { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
- { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
- { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
- { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
- { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
- { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
- { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
- { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
- { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
- { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
- { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
- { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
- { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
- { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
- { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
- { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
- { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
- { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
- { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
- { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
- { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
- { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
- { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
- { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
- { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
- { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
- { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
- { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
-]
-
-[[package]]
-name = "pyyaml-env-tag"
-version = "1.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pyyaml" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" },
-]
-
-[[package]]
-name = "requests"
-version = "2.32.5"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "certifi" },
- { name = "charset-normalizer" },
- { name = "idna" },
- { name = "urllib3" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
-]
-
-[[package]]
-name = "six"
-version = "1.17.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
-]
-
-[[package]]
-name = "urllib3"
-version = "2.5.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
-]
-
-[[package]]
-name = "watchdog"
-version = "6.0.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" },
- { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" },
- { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" },
- { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
- { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
- { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
- { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
- { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
- { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
- { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
- { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
- { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
- { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
- { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
- { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
- { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
-]