Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/new api for model database #89

Open
wants to merge 36 commits into
base: main
Choose a base branch
from

Conversation

ryan-kipawa
Copy link
Collaborator

@ryan-kipawa ryan-kipawa commented Mar 20, 2025

A new fluent-like API is introduced in this PR for working with the MIKE+ database.

Main changes:

  • DataTableAccess will be replaced by Database. It's a cleaner implementation with more thorough testing. DataTableAccess would become legacy, but kept around for a while since others are using it.
  • Table classes are autogenerated for each table in the database, which are accessed though a table collection on Database.tables
  • Each table class has a flexible, fluent-like API with select, insert, update, and delete (with various conditions).
  • Each table class has a columns attribute for get an enum of possible column values
  • Conversion logic between .NET pythonnet objects and python has been centralized a bit more

I think this structure will also allow for future extension in an easy way, since it loosely follows the MIKE+ design.

Closes:
#90
#35
#44
#27
#17

Here's a brief comparison of the old vs new API:

Opening database

Old API

data_access = DataTableAccess("model.sqlite")
data_access.open_database()

# Close when done
data_access.close_database()

New API

# Initialize with auto_open=True (the default)
db = Database("model.sqlite")
# Close when done
db.close()

# Or use context manager for automatic open/close
with Database("model.sqlite") as db:
    # Work with database
    pass

Querying Data

Old API

# Get all MUIDs in a table
muids = data_access.get_muid_where("msm_Link")

# Get specific fields for a single record
fields = ["Diameter", "Length", "FromNode"]
values = data_access.get_field_values("msm_Link", "Link_2", fields)

# Get values for all records with a condition
result = data_access.get_muid_field_values("msm_Link", fields, "Diameter > 1.0")

New API

# Access a table through the tables collection
link_table = db.tables.msm_Link

# Get all MUIDs with optional ordering
muids = link_table.get_muids(order_by="Diameter", descending=False)

# Query with chainable methods
result = link_table.select(["Diameter", "Length", "FromNode"]) \
                  .where("Diameter > 1.0") \
                  .order_by("MUID") \
                  .execute()

# Convert to pandas DataFrame
df = link_table.select(["Diameter", "Length"]) \
              .to_pandas()

# Parameterized where statements
min_diameter = 0.5
result = link_table.select() \
                  .where("Diameter > :min_diameter", {"min_dimaeter" : min_diameter}) \
                  .to_pandas()

Inserting Data

Old API

# Insert a new record
values = {
    'Diameter': 2.0, 
    'Description': 'New pipe', 
    'geometry': "LINESTRING (3 4, 10 50, 20 25)"
}
data_access.insert("msm_Link", "new_link_1", values)

New API

# Insert using chainable methods
db.tables.msm_Link.insert({
    "MUID" : "new_link_1",
   "Diameter" : 2.0,
    "Description" : "New pipe",
    "geometry" : "LINESTRING (3 4, 10 50, 20 25)"
})

# Or auto-generate the MUID
db.tables.msm_Link.insert({
    "Diameter" : 2.0,
    "Description" : "New pipe",
    "geometry" : "LINESTRING (3 4, 10 50, 20 25)"
})

Deleting Data

Old API

# Delete a record
data_access.delete("msm_Link", "Link_2")

New API

# Delete using chainable methods
db.tables.msm_Link.delete() \
    .where("MUID = 'Link_2'") \
    .execute()

# Safety feature - to delete all records, must explicitly call all()
db.tables.msm_Link.delete() \
    .all() \
    .execute()

@ryan-kipawa ryan-kipawa marked this pull request as ready for review March 20, 2025 13:39
@ryan-kipawa ryan-kipawa requested a review from wuwwen March 20, 2025 21:09
Copy link
Member

@ecomodeller ecomodeller left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like a very nice API.

Two comments:

  • Please make sure that the tests can run
  • Consider adding mypy to CI to verify that the type hints are correct and stays correct.

class Database:
"""Represents a MIKE+ model database."""

def __init__(self, model_path: str | Path, *, auto_open: bool = True):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When is it useful to not open the database automatically?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only if you wanted to delay making the connection for some reason. I somehow feel it might be useful for testing or some future use case, but you're right ... I can't think of a strong need for it based on how the code exists now.

Returns:
List of scenario names
"""
return list(self._scenario_manager.GetScenarios())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This operation (and related) will fail if self._scenario_manager is None, i.e. if the database is not opened.

As reported by mypy.
error: Item "None" of "Any | None" has no attribute "GetScenarios" [union-attr]

Copy link
Collaborator Author

@ryan-kipawa ryan-kipawa Mar 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can certainly clean this up. I'd prefer to wait until the overall PR looks okay in design, then go back to fix these up.

Might need to ignore or remove a lot of the type hints related to pythonnet objects. I think this would require stub files for them which could be a lot of work to generate. I like having them cause it facilitates mapping it to C# code. Maybe some practical typing file for these that let us still use them as hints but ignore them?

@ryan-kipawa
Copy link
Collaborator Author

Looks like a very nice API.

Two comments:

  • Please make sure that the tests can run
  • Consider adding mypy to CI to verify that the type hints are correct and stays correct.

Brief comment on the tests: they do run and pass, just not on CI. This can (partially) be fixed, although still needs to skip licensed tests which have issues I can't resolve in this PR.

if geometry:
if isinstance(geometry, str):
geometry = DotNetConverter.to_dotnet_geometry(geometry)
else:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens here? geometry is not None, but it is not being used🤔

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's used a few lines below by:

_, inserted_muid = net_table.InsertByCommand(
            muid,
            geometry,
            net_values,
        )

The geometry stuff hasn't been fully tested enough. There's also a need to differentiate between standard tables and geometry tables, something I hope to incorporate, but maybe as a different PR since this one is quite big already.

class UpdateQuery(BaseQuery[List[str]]):
"""Query class for UPDATE operations."""

def __init__(self, table: BaseTable, values: dict[str, any]):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps you meant "typing.Any" instead of "any" (output from mypy)

@gedaskir
Copy link
Collaborator

@ryan-kipawa Should it always be

from mikeplus.database import Database

or

from mikeplus import Database

could be preferred?

Additionally, would it make sense to have something similar like mikeplus.open(...) method similar to mikeio and mikeio1d?

if projection_string and srid != -1:
raise ValueError("Projection string and SRID cannot be specified together.")

try:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this make the following script https://github.com/DHI/mikepluspy/blob/main/test_utils/database_creators.py. redundant?

"""Represents a MIKE+ model database."""

def __init__(self, model_path: str | Path, *, auto_open: bool = True):
"""Initialize a new Database.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we should settle for Google docstring style instead of NumPy?

self.open()

@classmethod
def create(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to have an overwrite flag?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants