refactor(db)!: finalize pooled WNDB v2 migration
This commit is contained in:
@@ -7,7 +7,7 @@ from ..model.reservoirs import unset_reservoir_by_pattern
|
||||
from ..model.tanks import unset_tank_by_curve
|
||||
from ..model.pumps import unset_pump_by_curve, unset_pump_by_pattern
|
||||
from ..model.tags import delete_tag_by_node, delete_tag_by_link
|
||||
from ..model.demands import delete_demand_by_junction, unset_demand_by_pattern
|
||||
from ..model.demands import delete_demand_by_junction
|
||||
from ..model.status import delete_status_by_link
|
||||
from ..model.energy import delete_pump_energy_by_pump, unset_pump_energy_by_pattern, unset_pump_energy_by_curve
|
||||
from ..model.emitters import delete_emitter_by_junction
|
||||
@@ -167,7 +167,6 @@ def expand_pattern_delete(name: str, cs: ChangeSet) -> ChangeSet:
|
||||
|
||||
result.merge(unset_reservoir_by_pattern(name, id))
|
||||
result.merge(unset_pump_by_pattern(name, id))
|
||||
result.merge(unset_demand_by_pattern(name, id))
|
||||
result.merge(unset_pump_energy_by_pattern(name, id))
|
||||
result.merge(unset_source_by_pattern(name, id))
|
||||
result.merge(cs)
|
||||
@@ -191,7 +190,7 @@ def expand_curve_delete(name: str, cs: ChangeSet) -> ChangeSet:
|
||||
return result
|
||||
|
||||
|
||||
def expand_legacy_options_update(cs: ChangeSet) -> ChangeSet:
|
||||
def expand_v2_options_update(cs: ChangeSet) -> ChangeSet:
|
||||
cs.operations[0]['operation'] = API_UPDATE
|
||||
cs.operations[0]['type'] = 'option'
|
||||
new_cs = cs
|
||||
@@ -239,6 +238,6 @@ _DELETE_REWRITERS: dict[str, DeleteRewriter] = {
|
||||
}
|
||||
|
||||
_UPDATE_REWRITERS: dict[str, UpdateRewriter] = {
|
||||
"option": expand_legacy_options_update,
|
||||
"option": expand_v2_options_update,
|
||||
"option_v3": expand_v3_options_update,
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from ..core.connection import project_transaction
|
||||
from ..core.database import (
|
||||
API_ADD,
|
||||
API_DELETE,
|
||||
API_UPDATE,
|
||||
ChangeSet,
|
||||
refresh_materialized_views,
|
||||
changes_affect_materialized_views,
|
||||
model_mutation_transaction,
|
||||
refresh_materialized_views_after_commit,
|
||||
)
|
||||
from ..gis.backdrop import set_backdrop
|
||||
from ..gis.labels import add_label, delete_label, set_label
|
||||
@@ -131,7 +132,7 @@ def _execute_delete_command(name: str, change_set: ChangeSet) -> ChangeSet:
|
||||
|
||||
|
||||
def execute_batch_commands(name: str, change_set: ChangeSet) -> ChangeSet:
|
||||
with project_transaction(name):
|
||||
with model_mutation_transaction(name):
|
||||
rewritten = ChangeSet()
|
||||
for operation in change_set.operations:
|
||||
rewritten.merge(expand_command(name, ChangeSet(operation)))
|
||||
@@ -146,8 +147,8 @@ def execute_batch_commands(name: str, change_set: ChangeSet) -> ChangeSet:
|
||||
elif operation_type == API_DELETE:
|
||||
result.merge(_execute_delete_command(name, ChangeSet(operation)))
|
||||
|
||||
if rewritten.operations:
|
||||
refresh_materialized_views(name)
|
||||
if changes_affect_materialized_views(rewritten):
|
||||
refresh_materialized_views_after_commit(name)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from psycopg_pool import ConnectionPool
|
||||
from app.core.config import settings
|
||||
from app.infra.db.project_routing import get_project_pgconn_string
|
||||
|
||||
_check_connection = ConnectionPool.check_connection
|
||||
_pools: OrderedDict[str, ConnectionPool] = OrderedDict()
|
||||
_pool_conninfo: dict[str, str] = {}
|
||||
_pool_borrows: dict[str, int] = {}
|
||||
@@ -21,6 +22,10 @@ _active_project_connection: ContextVar[tuple[str, Connection] | None] = ContextV
|
||||
"wndb_active_project_connection",
|
||||
default=None,
|
||||
)
|
||||
_active_model_mutation_locks: ContextVar[frozenset[str]] = ContextVar(
|
||||
"wndb_active_model_mutation_locks",
|
||||
default=frozenset(),
|
||||
)
|
||||
|
||||
|
||||
def _close_pool(pool: ConnectionPool) -> None:
|
||||
@@ -78,6 +83,7 @@ def get_project_pool(name: str) -> ConnectionPool:
|
||||
min_size=settings.PROJECT_PG_POOL_MIN_SIZE,
|
||||
max_size=settings.PROJECT_PG_POOL_SIZE + settings.PROJECT_PG_MAX_OVERFLOW,
|
||||
kwargs={"autocommit": True, "row_factory": dict_row},
|
||||
check=_check_connection,
|
||||
open=True,
|
||||
)
|
||||
_pools[name] = pool
|
||||
@@ -140,6 +146,7 @@ def get_admin_pool() -> ConnectionPool:
|
||||
min_size=settings.PROJECT_PG_POOL_MIN_SIZE,
|
||||
max_size=settings.PROJECT_PG_POOL_SIZE,
|
||||
kwargs={"autocommit": True, "row_factory": dict_row},
|
||||
check=_check_connection,
|
||||
open=True,
|
||||
)
|
||||
_admin_pools[conninfo] = pool
|
||||
@@ -192,10 +199,12 @@ def project_transaction(name: str) -> Iterator[Connection]:
|
||||
try:
|
||||
with pool.connection() as conn:
|
||||
token = _active_project_connection.set((name, conn))
|
||||
lock_token = _active_model_mutation_locks.set(frozenset())
|
||||
try:
|
||||
with conn.transaction():
|
||||
yield conn
|
||||
finally:
|
||||
_active_model_mutation_locks.reset(lock_token)
|
||||
_active_project_connection.reset(token)
|
||||
finally:
|
||||
with _registry_lock:
|
||||
@@ -208,6 +217,17 @@ def is_project_transaction_active(name: str) -> bool:
|
||||
return active is not None and active[0] == name
|
||||
|
||||
|
||||
def is_model_mutation_lock_active(name: str) -> bool:
|
||||
"""Return whether the current project transaction already owns its model lock."""
|
||||
return name in _active_model_mutation_locks.get()
|
||||
|
||||
|
||||
def mark_model_mutation_lock_active(name: str) -> None:
|
||||
"""Record a transaction-scoped advisory lock to avoid duplicate round trips."""
|
||||
locks = _active_model_mutation_locks.get()
|
||||
_active_model_mutation_locks.set(locks | {name})
|
||||
|
||||
|
||||
@contextmanager
|
||||
def admin_connection() -> Iterator[Connection]:
|
||||
"""Borrow a PostgreSQL administration connection from its pool."""
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Callable
|
||||
|
||||
from psycopg import sql
|
||||
from psycopg.rows import Row, dict_row
|
||||
|
||||
from .connection import is_project_transaction_active, project_connection
|
||||
from app.infra.db.project_routing import get_project_database_name
|
||||
|
||||
from .connection import (
|
||||
is_model_mutation_lock_active,
|
||||
is_project_transaction_active,
|
||||
mark_model_mutation_lock_active,
|
||||
project_connection,
|
||||
project_transaction,
|
||||
)
|
||||
|
||||
API_ADD = "add"
|
||||
API_UPDATE = "update"
|
||||
@@ -61,6 +70,20 @@ class DatabaseCommand:
|
||||
self.sql = statement
|
||||
self.changes = changes
|
||||
|
||||
|
||||
class MaterializedViewRefreshAfterCommitError(RuntimeError):
|
||||
"""Report a failed view refresh without implying that the write rolled back."""
|
||||
|
||||
changes_committed = True
|
||||
|
||||
def __init__(self, project: str) -> None:
|
||||
self.project = project
|
||||
super().__init__(
|
||||
f"Project {project!r} changes were committed, but materialized view "
|
||||
"refresh failed"
|
||||
)
|
||||
|
||||
|
||||
QueryParams = Sequence[Any] | Mapping[str, Any]
|
||||
|
||||
|
||||
@@ -78,6 +101,27 @@ def _execute(cur, query: str, params: QueryParams | None = None):
|
||||
return cur.execute(query, params) if params is not None else cur.execute(query)
|
||||
|
||||
|
||||
def acquire_model_mutation_lock(conn, name: str) -> None:
|
||||
"""Serialize model replacement and ordinary WNDB mutations per database."""
|
||||
if is_model_mutation_lock_active(name):
|
||||
return
|
||||
physical_name = get_project_database_name(name)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"select pg_advisory_xact_lock(hashtextextended(%s, 0))",
|
||||
(f"tjwater:wndb:model:{physical_name}",),
|
||||
)
|
||||
mark_model_mutation_lock_active(name)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def model_mutation_transaction(name: str) -> Iterator[Any]:
|
||||
"""Open a project transaction and acquire its model lock before reading."""
|
||||
with project_transaction(name) as conn:
|
||||
acquire_model_mutation_lock(conn, name)
|
||||
yield conn
|
||||
|
||||
|
||||
def read(name: str, query: str, params: QueryParams | None = None) -> Row:
|
||||
with project_connection(name) as conn, conn.cursor(row_factory=dict_row) as cur:
|
||||
_execute(cur, query, params)
|
||||
@@ -104,8 +148,15 @@ def try_read(
|
||||
|
||||
|
||||
def write(name: str, query: str, params: QueryParams | None = None) -> None:
|
||||
with project_connection(name) as conn, conn.cursor() as cur:
|
||||
_execute(cur, query, params)
|
||||
connection_context = (
|
||||
project_connection(name)
|
||||
if is_project_transaction_active(name)
|
||||
else model_mutation_transaction(name)
|
||||
)
|
||||
with connection_context as conn:
|
||||
acquire_model_mutation_lock(conn, name)
|
||||
with conn.cursor() as cur:
|
||||
_execute(cur, query, params)
|
||||
|
||||
|
||||
def refresh_materialized_views(name: str, *, concurrently: bool = True) -> None:
|
||||
@@ -114,6 +165,13 @@ def refresh_materialized_views(name: str, *, concurrently: bool = True) -> None:
|
||||
cur.execute("CALL gis.refresh_all_materialized_views(%s)", (concurrently,))
|
||||
|
||||
|
||||
def refresh_materialized_views_after_commit(name: str) -> None:
|
||||
try:
|
||||
refresh_materialized_views(name)
|
||||
except Exception as exc:
|
||||
raise MaterializedViewRefreshAfterCommitError(name) from exc
|
||||
|
||||
|
||||
_MATERIALIZED_VIEW_SOURCES = (
|
||||
"network.nodes",
|
||||
"network.junctions",
|
||||
@@ -135,9 +193,48 @@ def _affects_materialized_views(command: DatabaseCommand) -> bool:
|
||||
return any(source in statement for source in _MATERIALIZED_VIEW_SOURCES)
|
||||
|
||||
|
||||
_MATERIALIZED_VIEW_ELEMENT_TYPES = frozenset(
|
||||
{
|
||||
"junction",
|
||||
"reservoir",
|
||||
"tank",
|
||||
"pipe",
|
||||
"pump",
|
||||
"valve",
|
||||
"demand",
|
||||
"vertex",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def changes_affect_materialized_views(change_set: ChangeSet) -> bool:
|
||||
"""Return whether a dispatched WNDB batch changes a published GIS source."""
|
||||
return any(
|
||||
operation.get("type") in _MATERIALIZED_VIEW_ELEMENT_TYPES
|
||||
for operation in change_set.operations
|
||||
)
|
||||
|
||||
|
||||
def execute_command(name: str, command: DatabaseCommand) -> ChangeSet:
|
||||
"""Apply a model mutation without the removed database undo/redo journal."""
|
||||
write(name, command.sql)
|
||||
if _affects_materialized_views(command) and not is_project_transaction_active(name):
|
||||
refresh_materialized_views(name)
|
||||
refresh_materialized_views_after_commit(name)
|
||||
return ChangeSet.from_list(command.changes)
|
||||
|
||||
|
||||
def execute_locked_command(
|
||||
name: str,
|
||||
builder: Callable[[], DatabaseCommand | None],
|
||||
) -> ChangeSet:
|
||||
"""Build a read-modify-write command only after acquiring the model lock."""
|
||||
nested_transaction = is_project_transaction_active(name)
|
||||
command: DatabaseCommand | None = None
|
||||
with model_mutation_transaction(name):
|
||||
command = builder()
|
||||
if command is None:
|
||||
return ChangeSet()
|
||||
result = execute_command(name, command)
|
||||
if not nested_transaction and _affects_materialized_views(command):
|
||||
refresh_materialized_views_after_commit(name)
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
from psycopg import Connection, sql
|
||||
|
||||
from .connection import project_connection, project_transaction
|
||||
from .database import acquire_model_mutation_lock
|
||||
|
||||
_MODEL_SCHEMAS = ("network", "gis")
|
||||
_ALLOWED_EXTERNAL_REFERENCES = {
|
||||
("analysis", "results", "network", "nodes"),
|
||||
("analysis", "results", "network", "links"),
|
||||
("asset", "scada_devices", "network", "nodes"),
|
||||
("asset", "scada_devices", "network", "links"),
|
||||
}
|
||||
|
||||
|
||||
def _model_tables(conn: Connection) -> list[tuple[str, str]]:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
select n.nspname as schema_name, c.relname as table_name
|
||||
from pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where n.nspname = any(%s) and c.relkind in ('r', 'p')
|
||||
order by n.nspname, c.relname
|
||||
""",
|
||||
(list(_MODEL_SCHEMAS),),
|
||||
)
|
||||
return [(row["schema_name"], row["table_name"]) for row in cur.fetchall()]
|
||||
|
||||
|
||||
def _copy_order(
|
||||
conn: Connection, tables: list[tuple[str, str]]
|
||||
) -> list[tuple[str, str]]:
|
||||
table_set = set(tables)
|
||||
dependencies: dict[tuple[str, str], set[tuple[str, str]]] = {
|
||||
table: set() for table in tables
|
||||
}
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
select source_ns.nspname as source_schema,
|
||||
source.relname as source_table,
|
||||
target_ns.nspname as target_schema,
|
||||
target.relname as target_table
|
||||
from pg_constraint constraint_row
|
||||
join pg_class source on source.oid = constraint_row.conrelid
|
||||
join pg_namespace source_ns on source_ns.oid = source.relnamespace
|
||||
join pg_class target on target.oid = constraint_row.confrelid
|
||||
join pg_namespace target_ns on target_ns.oid = target.relnamespace
|
||||
where constraint_row.contype = 'f'
|
||||
and source_ns.nspname = any(%s)
|
||||
and target_ns.nspname = any(%s)
|
||||
""",
|
||||
(list(_MODEL_SCHEMAS), list(_MODEL_SCHEMAS)),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
source = (row["source_schema"], row["source_table"])
|
||||
target = (row["target_schema"], row["target_table"])
|
||||
if source in table_set and target in table_set and source != target:
|
||||
dependencies[source].add(target)
|
||||
|
||||
ordered: list[tuple[str, str]] = []
|
||||
remaining = set(tables)
|
||||
while remaining:
|
||||
ready = sorted(
|
||||
table for table in remaining if not (dependencies[table] & remaining)
|
||||
)
|
||||
if not ready:
|
||||
cycle = ", ".join(f"{schema}.{table}" for schema, table in sorted(remaining))
|
||||
raise RuntimeError(f"Model table foreign-key cycle detected: {cycle}")
|
||||
ordered.extend(ready)
|
||||
remaining.difference_update(ready)
|
||||
return ordered
|
||||
|
||||
|
||||
def _table_columns(
|
||||
conn: Connection, schema_name: str, table_name: str
|
||||
) -> list[str]:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
select column_name
|
||||
from information_schema.columns
|
||||
where table_schema = %s and table_name = %s
|
||||
and is_generated = 'NEVER'
|
||||
order by ordinal_position
|
||||
""",
|
||||
(schema_name, table_name),
|
||||
)
|
||||
return [row["column_name"] for row in cur.fetchall()]
|
||||
|
||||
|
||||
def _external_references(conn: Connection) -> set[tuple[str, str, str, str]]:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
select source_ns.nspname as source_schema,
|
||||
source.relname as source_table,
|
||||
target_ns.nspname as target_schema,
|
||||
target.relname as target_table
|
||||
from pg_constraint constraint_row
|
||||
join pg_class source on source.oid = constraint_row.conrelid
|
||||
join pg_namespace source_ns on source_ns.oid = source.relnamespace
|
||||
join pg_class target on target.oid = constraint_row.confrelid
|
||||
join pg_namespace target_ns on target_ns.oid = target.relnamespace
|
||||
where constraint_row.contype = 'f'
|
||||
and target_ns.nspname = any(%s)
|
||||
and source_ns.nspname <> all(%s)
|
||||
""",
|
||||
(list(_MODEL_SCHEMAS), list(_MODEL_SCHEMAS)),
|
||||
)
|
||||
return {
|
||||
(
|
||||
row["source_schema"],
|
||||
row["source_table"],
|
||||
row["target_schema"],
|
||||
row["target_table"],
|
||||
)
|
||||
for row in cur.fetchall()
|
||||
}
|
||||
|
||||
|
||||
def _copy_table(
|
||||
source_conn: Connection,
|
||||
target_conn: Connection,
|
||||
schema_name: str,
|
||||
table_name: str,
|
||||
columns: list[str],
|
||||
) -> None:
|
||||
relation = sql.Identifier(schema_name, table_name)
|
||||
column_list = sql.SQL(", ").join(map(sql.Identifier, columns))
|
||||
copy_out = sql.SQL("copy {} ({}) to stdout").format(relation, column_list)
|
||||
copy_in = sql.SQL("copy {} ({}) from stdin").format(relation, column_list)
|
||||
with source_conn.cursor().copy(copy_out) as source_copy:
|
||||
with target_conn.cursor().copy(copy_in) as target_copy:
|
||||
for chunk in source_copy:
|
||||
target_copy.write(chunk)
|
||||
|
||||
|
||||
def replace_project_model(
|
||||
target_project: str,
|
||||
source_project: str,
|
||||
*,
|
||||
copy_source_scada: bool = False,
|
||||
) -> None:
|
||||
"""Atomically replace WNDB/GIS model tables from a validated staging DB.
|
||||
|
||||
Business and analysis tables remain in the target database. Historical
|
||||
analysis rows keep references that still exist and become element-neutral
|
||||
when an element disappeared. SCADA devices are retained only when their
|
||||
bound node or link still exists in the replacement model.
|
||||
|
||||
``copy_source_scada`` is used only for temporary project clones. Normal INP
|
||||
replacement keeps the target project's existing device mappings and drops
|
||||
mappings whose model element no longer exists.
|
||||
"""
|
||||
with project_connection(source_project) as source_conn, source_conn.transaction():
|
||||
with source_conn.cursor() as cur:
|
||||
cur.execute("set transaction isolation level repeatable read, read only")
|
||||
|
||||
source_tables = _model_tables(source_conn)
|
||||
source_columns = {
|
||||
table: _table_columns(source_conn, *table) for table in source_tables
|
||||
}
|
||||
source_scada_columns = (
|
||||
_table_columns(source_conn, "asset", "scada_devices")
|
||||
if copy_source_scada
|
||||
else []
|
||||
)
|
||||
copy_order = _copy_order(source_conn, source_tables)
|
||||
|
||||
with project_transaction(target_project) as target_conn:
|
||||
acquire_model_mutation_lock(target_conn, target_project)
|
||||
|
||||
target_tables = _model_tables(target_conn)
|
||||
if set(target_tables) != set(source_tables):
|
||||
missing = sorted(set(source_tables) - set(target_tables))
|
||||
extra = sorted(set(target_tables) - set(source_tables))
|
||||
raise RuntimeError(
|
||||
f"Staging/target model schema mismatch; missing={missing}, extra={extra}"
|
||||
)
|
||||
for table, columns in source_columns.items():
|
||||
if _table_columns(target_conn, *table) != columns:
|
||||
raise RuntimeError(
|
||||
f"Staging/target columns differ for {table[0]}.{table[1]}"
|
||||
)
|
||||
if copy_source_scada and _table_columns(
|
||||
target_conn, "asset", "scada_devices"
|
||||
) != source_scada_columns:
|
||||
raise RuntimeError(
|
||||
"Source/target columns differ for asset.scada_devices"
|
||||
)
|
||||
|
||||
unexpected = _external_references(target_conn) - _ALLOWED_EXTERNAL_REFERENCES
|
||||
if unexpected:
|
||||
formatted = ", ".join(
|
||||
f"{source_schema}.{source_table}->{target_schema}.{target_table}"
|
||||
for source_schema, source_table, target_schema, target_table in sorted(
|
||||
unexpected
|
||||
)
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Model replacement has unsupported external references: {formatted}"
|
||||
)
|
||||
|
||||
with target_conn.cursor() as cur:
|
||||
if not copy_source_scada:
|
||||
cur.execute(
|
||||
"create temporary table model_scada_snapshot on commit drop "
|
||||
"as table asset.scada_devices"
|
||||
)
|
||||
cur.execute("delete from asset.scada_devices")
|
||||
cur.execute(
|
||||
"create temporary table model_result_refs on commit drop as "
|
||||
"select result_id, node_id, link_id from analysis.results"
|
||||
)
|
||||
cur.execute("update analysis.results set node_id = null, link_id = null")
|
||||
for schema_name, table_name in reversed(copy_order):
|
||||
cur.execute(
|
||||
sql.SQL("delete from {}").format(
|
||||
sql.Identifier(schema_name, table_name)
|
||||
)
|
||||
)
|
||||
|
||||
for schema_name, table_name in copy_order:
|
||||
_copy_table(
|
||||
source_conn,
|
||||
target_conn,
|
||||
schema_name,
|
||||
table_name,
|
||||
source_columns[(schema_name, table_name)],
|
||||
)
|
||||
|
||||
if copy_source_scada:
|
||||
_copy_table(
|
||||
source_conn,
|
||||
target_conn,
|
||||
"asset",
|
||||
"scada_devices",
|
||||
source_scada_columns,
|
||||
)
|
||||
|
||||
with target_conn.cursor() as cur:
|
||||
if not copy_source_scada:
|
||||
cur.execute(
|
||||
"""
|
||||
insert into asset.scada_devices
|
||||
select snapshot.*
|
||||
from model_scada_snapshot snapshot
|
||||
where (
|
||||
snapshot.node_id is not null
|
||||
and exists (
|
||||
select 1 from network.nodes node
|
||||
where node.id = snapshot.node_id
|
||||
)
|
||||
) or (
|
||||
snapshot.link_id is not null
|
||||
and exists (
|
||||
select 1 from network.links link
|
||||
where link.id = snapshot.link_id
|
||||
)
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
update analysis.results result
|
||||
set node_id = case
|
||||
when exists (
|
||||
select 1 from network.nodes node
|
||||
where node.id = refs.node_id
|
||||
) then refs.node_id
|
||||
else null
|
||||
end,
|
||||
link_id = case
|
||||
when exists (
|
||||
select 1 from network.links link
|
||||
where link.id = refs.link_id
|
||||
) then refs.link_id
|
||||
else null
|
||||
end
|
||||
from model_result_refs refs
|
||||
where refs.result_id = result.result_id
|
||||
"""
|
||||
)
|
||||
@@ -1,13 +1,46 @@
|
||||
from collections.abc import Iterable
|
||||
from contextlib import contextmanager
|
||||
import re
|
||||
from uuid import uuid4
|
||||
|
||||
from psycopg import sql
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.core.config import settings
|
||||
from app.infra.db.project_routing import (
|
||||
get_project_database_name,
|
||||
get_project_template_database_name,
|
||||
)
|
||||
|
||||
from .connection import (
|
||||
admin_connection,
|
||||
close_project_pool,
|
||||
get_project_pool,
|
||||
is_project_pool_open,
|
||||
)
|
||||
|
||||
_server_databases = ["template0", "template1", "postgres", "project"]
|
||||
_SERVER_DATABASES = frozenset({"template0", "template1", "postgres", "project"})
|
||||
_TEMPORARY_DATABASE_PREFIX = "tjw_tmp_"
|
||||
|
||||
|
||||
def _protected_databases() -> frozenset[str]:
|
||||
return _SERVER_DATABASES | {
|
||||
settings.METADATA_DB_NAME,
|
||||
settings.WNDB_TEMPLATE_DB_NAME,
|
||||
}
|
||||
|
||||
|
||||
def _validate_project_database(name: str, *, allow_template_source: bool = False) -> None:
|
||||
if not name:
|
||||
raise ValueError("Project database name must not be empty")
|
||||
|
||||
protected = {database.casefold() for database in _protected_databases()}
|
||||
is_template = name.casefold().endswith("_template")
|
||||
if (
|
||||
allow_template_source
|
||||
and name.casefold() == settings.WNDB_TEMPLATE_DB_NAME.casefold()
|
||||
):
|
||||
return
|
||||
if name.casefold() in protected or is_template:
|
||||
raise ValueError(f"Database {name!r} is protected and cannot be managed as a project")
|
||||
|
||||
|
||||
def list_project() -> list[str]:
|
||||
@@ -16,92 +49,219 @@ def list_project() -> list[str]:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
for p in cur.execute(
|
||||
"select datname from pg_database where datname <> all(%s) order by datname",
|
||||
(_server_databases,),
|
||||
(list(_protected_databases()),),
|
||||
):
|
||||
ps.append(p["datname"])
|
||||
if not str(p["datname"]).casefold().endswith("_template"):
|
||||
ps.append(p["datname"])
|
||||
return ps
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _database_locks(cur, *database_names: str):
|
||||
"""Serialize physical database lifecycle operations across server workers."""
|
||||
lock_names = sorted(set(database_names), key=str.casefold)
|
||||
for database_name in lock_names:
|
||||
cur.execute(
|
||||
"select pg_advisory_lock(hashtextextended(%s, 0))",
|
||||
(f"tjwater:wndb:{database_name}",),
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for database_name in reversed(lock_names):
|
||||
cur.execute(
|
||||
"select pg_advisory_unlock(hashtextextended(%s, 0))",
|
||||
(f"tjwater:wndb:{database_name}",),
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _temporary_database_capacity(cur, database_name: str):
|
||||
"""Serialize temporary creation and enforce a server-wide hard limit."""
|
||||
if not database_name.startswith(_TEMPORARY_DATABASE_PREFIX):
|
||||
yield
|
||||
return
|
||||
|
||||
lock_name = "tjwater:wndb:temporary-database-capacity"
|
||||
cur.execute("select pg_advisory_lock(hashtextextended(%s, 0))", (lock_name,))
|
||||
try:
|
||||
cur.execute(
|
||||
"select count(*) as count from pg_database where datname like %s",
|
||||
(f"{_TEMPORARY_DATABASE_PREFIX}%",),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
count = int(row["count"] if row is not None else 0)
|
||||
limit = max(1, settings.WNDB_TEMP_DB_MAX_COUNT)
|
||||
if count >= limit:
|
||||
raise RuntimeError(
|
||||
f"Temporary database limit reached ({count}/{limit}); "
|
||||
"retry after an active analysis completes"
|
||||
)
|
||||
yield
|
||||
finally:
|
||||
cur.execute("select pg_advisory_unlock(hashtextextended(%s, 0))", (lock_name,))
|
||||
|
||||
|
||||
def _database_allows_connections(cur, database_name: str) -> bool:
|
||||
cur.execute(
|
||||
"select datallowconn from pg_database where datname = %s",
|
||||
(database_name,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise ValueError(f"Database {database_name!r} does not exist")
|
||||
return bool(row["datallowconn"])
|
||||
|
||||
|
||||
def _set_database_connections(cur, database_name: str, *, allowed: bool) -> None:
|
||||
cur.execute(
|
||||
"update pg_database set datallowconn = %s where datname = %s",
|
||||
(allowed, database_name),
|
||||
)
|
||||
|
||||
|
||||
def temporary_project_name(project: str, purpose: str) -> str:
|
||||
"""Return a collision-resistant physical database name for one run."""
|
||||
physical_name = get_project_database_name(project)
|
||||
safe_purpose = re.sub(r"[^a-z0-9_]+", "_", purpose.casefold()).strip("_")
|
||||
safe_project = re.sub(r"[^a-z0-9_]+", "_", physical_name.casefold()).strip("_")
|
||||
prefix = (
|
||||
f"{_TEMPORARY_DATABASE_PREFIX}{safe_purpose or 'run'}_"
|
||||
f"{safe_project or 'project'}"
|
||||
)[:29]
|
||||
return f"{prefix}_{uuid4().hex}"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def temporary_project_database(project: str, purpose: str):
|
||||
"""Clone one project's runnable model into an isolated temporary database."""
|
||||
temporary_name = temporary_project_name(project, purpose)
|
||||
try:
|
||||
copy_project(get_project_template_database_name(project), temporary_name)
|
||||
# Import lazily to keep physical database lifecycle independent from
|
||||
# model-copy implementation details at module import time.
|
||||
from .database import refresh_materialized_views_after_commit
|
||||
from .model_replace import replace_project_model
|
||||
|
||||
replace_project_model(
|
||||
temporary_name,
|
||||
project,
|
||||
copy_source_scada=True,
|
||||
)
|
||||
refresh_materialized_views_after_commit(temporary_name)
|
||||
yield temporary_name
|
||||
finally:
|
||||
if have_project(temporary_name):
|
||||
delete_project(temporary_name)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def temporary_template_database(name_hint: str, purpose: str):
|
||||
"""Create an empty schema-only temporary database from the fixed template."""
|
||||
temporary_name = temporary_project_name(name_hint, purpose)
|
||||
try:
|
||||
copy_project(get_project_template_database_name(name_hint), temporary_name)
|
||||
yield temporary_name
|
||||
finally:
|
||||
if have_project(temporary_name):
|
||||
delete_project(temporary_name)
|
||||
|
||||
|
||||
def have_project(name: str) -> bool:
|
||||
database_name = get_project_database_name(name)
|
||||
with admin_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("select 1 from pg_database where datname = %s", (name,))
|
||||
cur.execute("select 1 from pg_database where datname = %s", (database_name,))
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def copy_project(source: str, new: str) -> None:
|
||||
physical_source = get_project_database_name(source)
|
||||
physical_new = get_project_database_name(new)
|
||||
_validate_project_database(physical_source, allow_template_source=True)
|
||||
_validate_project_database(physical_new)
|
||||
close_project_pool(source)
|
||||
close_project_pool(new)
|
||||
|
||||
with admin_connection() as admin_conn:
|
||||
with admin_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"update pg_database set datallowconn = false where datname = %s",
|
||||
(source,),
|
||||
)
|
||||
try:
|
||||
cur.execute(
|
||||
"select pg_terminate_backend(pid) from pg_stat_activity where datname = %s and pid <> pg_backend_pid()",
|
||||
(source,),
|
||||
)
|
||||
cur.execute(
|
||||
sql.SQL("create database {} with template = {}").format(
|
||||
sql.Identifier(new), sql.Identifier(source)
|
||||
)
|
||||
)
|
||||
finally:
|
||||
cur.execute(
|
||||
"update pg_database set datallowconn = true where datname = %s",
|
||||
(source,),
|
||||
)
|
||||
with _temporary_database_capacity(cur, physical_new):
|
||||
with _database_locks(cur, physical_source, physical_new):
|
||||
source_allowed = _database_allows_connections(cur, physical_source)
|
||||
if source_allowed:
|
||||
_set_database_connections(
|
||||
cur,
|
||||
physical_source,
|
||||
allowed=False,
|
||||
)
|
||||
try:
|
||||
cur.execute(
|
||||
"select pg_terminate_backend(pid) from pg_stat_activity "
|
||||
"where datname = %s and pid <> pg_backend_pid()",
|
||||
(physical_source,),
|
||||
)
|
||||
cur.execute(
|
||||
sql.SQL("create database {} with template = {}").format(
|
||||
sql.Identifier(physical_new),
|
||||
sql.Identifier(physical_source),
|
||||
)
|
||||
)
|
||||
finally:
|
||||
if source_allowed:
|
||||
_set_database_connections(
|
||||
cur,
|
||||
physical_source,
|
||||
allowed=True,
|
||||
)
|
||||
|
||||
|
||||
def create_project(name: str) -> None:
|
||||
return copy_project("project", name)
|
||||
return copy_project(get_project_template_database_name(name), name)
|
||||
|
||||
|
||||
def delete_project(name: str) -> None:
|
||||
database_name = get_project_database_name(name)
|
||||
_validate_project_database(database_name)
|
||||
close_project_pool(name)
|
||||
with admin_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"select pg_terminate_backend(pid) from pg_stat_activity "
|
||||
"where datname = %s and pid <> pg_backend_pid()",
|
||||
(name,),
|
||||
)
|
||||
cur.execute(
|
||||
sql.SQL("drop database {}").format(sql.Identifier(name))
|
||||
)
|
||||
with _database_locks(cur, database_name):
|
||||
was_allowed = _database_allows_connections(cur, database_name)
|
||||
if was_allowed:
|
||||
_set_database_connections(cur, database_name, allowed=False)
|
||||
try:
|
||||
cur.execute(
|
||||
"select pg_terminate_backend(pid) from pg_stat_activity "
|
||||
"where datname = %s and pid <> pg_backend_pid()",
|
||||
(database_name,),
|
||||
)
|
||||
cur.execute(
|
||||
sql.SQL("drop database {}").format(
|
||||
sql.Identifier(database_name)
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
if was_allowed:
|
||||
_set_database_connections(cur, database_name, allowed=True)
|
||||
raise
|
||||
|
||||
|
||||
def clean_project(excluded: list[str] = []) -> None:
|
||||
projects = list_project()
|
||||
def clean_project(projects: Iterable[str]) -> None:
|
||||
"""Delete only the explicitly supplied project databases."""
|
||||
targets = list(dict.fromkeys(projects))
|
||||
physical_targets = [get_project_database_name(project) for project in targets]
|
||||
for database_name in physical_targets:
|
||||
_validate_project_database(database_name)
|
||||
|
||||
if not targets:
|
||||
return
|
||||
|
||||
with admin_connection() as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
row = cur.execute("select current_database()").fetchone()
|
||||
if row != None:
|
||||
current_db = row["current_database"]
|
||||
if current_db in projects:
|
||||
projects.remove(current_db)
|
||||
for project in projects:
|
||||
if project in _server_databases or project in excluded:
|
||||
continue
|
||||
cur.execute(
|
||||
"select pg_terminate_backend(pid) from pg_stat_activity "
|
||||
"where datname = %s and pid <> pg_backend_pid()",
|
||||
(project,),
|
||||
)
|
||||
cur.execute(
|
||||
sql.SQL("drop database {}").format(sql.Identifier(project))
|
||||
)
|
||||
current_db = row["current_database"] if row is not None else None
|
||||
if current_db in physical_targets:
|
||||
raise ValueError(f"Cannot delete the current database {current_db!r}")
|
||||
|
||||
|
||||
def open_project(name: str) -> None:
|
||||
get_project_pool(name)
|
||||
|
||||
|
||||
def is_project_open(name: str) -> bool:
|
||||
return is_project_pool_open(name)
|
||||
|
||||
|
||||
def close_project(name: str) -> None:
|
||||
close_project_pool(name)
|
||||
for project in targets:
|
||||
delete_project(project)
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
from psycopg.rows import dict_row
|
||||
from ..core.database import read_all, sql_literal, try_read
|
||||
|
||||
from ..core.connection import project_connection
|
||||
from ..core.database import read_all, sql_literal, try_read, write
|
||||
from ..core.connection import project_connection
|
||||
from ..model.elements import get_link_nodes
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
def sql_update_coord(node: str, x: float, y: float) -> str:
|
||||
geom = f"st_setsrid(st_makepoint({sql_literal(x)}, {sql_literal(y)}), 900914)"
|
||||
@@ -21,8 +16,8 @@ def sql_delete_coord(node: str) -> str:
|
||||
|
||||
|
||||
def from_postgis_point(coord: str) -> dict[str, float]:
|
||||
xy = coord.lower().removeprefix('point(').removesuffix(')').split(' ')
|
||||
return { 'x': float(xy[0]), 'y': float(xy[1]) }
|
||||
xy = coord.lower().removeprefix("point(").removesuffix(")").split(" ")
|
||||
return {"x": float(xy[0]), "y": float(xy[1])}
|
||||
|
||||
|
||||
def get_node_coord(name: str, node: str) -> dict[str, float]:
|
||||
@@ -31,51 +26,15 @@ def get_node_coord(name: str, node: str) -> dict[str, float]:
|
||||
"select st_astext(geom) as coord_geom from gis.node_geometries where node_id = %s",
|
||||
(node,),
|
||||
)
|
||||
if row == None:
|
||||
write(name, sql_insert_coord(node, 0.0, 0.0))
|
||||
return {'x': 0.0, 'y': 0.0}
|
||||
return from_postgis_point(row['coord_geom'])
|
||||
|
||||
# DingZQ 2025-01-03, get nodes in extent
|
||||
# return node id list
|
||||
# node_id:junction:x:y
|
||||
def get_nodes_in_extent(name: str, x1: float, y1: float, x2: float, y2: float) -> list[str]:
|
||||
nodes = []
|
||||
objs = read_all(name, 'select node_id, st_astext(geom) as coord_geom from gis.node_geometries')
|
||||
for obj in objs:
|
||||
node_id = obj['node_id']
|
||||
coord = from_postgis_point(obj['coord_geom'])
|
||||
x = coord['x']
|
||||
y = coord['y']
|
||||
if x1 <= x <= x2 and y1 <= y <= y2:
|
||||
nodes.append(f"{node_id}:junction:{x}:{y}")
|
||||
return nodes
|
||||
|
||||
# DingZQ 2025-01-03, get links in extent
|
||||
# return link id list
|
||||
# link_id:pipe:node_id1:node_id2
|
||||
def get_links_in_extent(name: str, x1: float, y1: float, x2: float, y2: float) -> list[str]:
|
||||
node_ids = set([s.split(':')[0] for s in get_nodes_in_extent(name, x1, y1, x2, y2)])
|
||||
|
||||
all_link_ids = []
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute("select link_id from network.pipes")
|
||||
for record in cur:
|
||||
all_link_ids.append(record['link_id'])
|
||||
|
||||
links = []
|
||||
for link_id in all_link_ids:
|
||||
nodes = get_link_nodes(name, link_id)
|
||||
if nodes[0] in node_ids and nodes[1] in node_ids:
|
||||
links.append(f"{link_id}:pipe:{nodes[0]}:{nodes[1]}")
|
||||
return links
|
||||
if row is None:
|
||||
return {"x": 0.0, "y": 0.0}
|
||||
return from_postgis_point(row["coord_geom"])
|
||||
|
||||
|
||||
def node_has_coord(name: str, node: str) -> bool:
|
||||
return try_read(
|
||||
name, "select node_id from gis.node_geometries where node_id = %s", (node,)
|
||||
) != None
|
||||
) is not None
|
||||
|
||||
|
||||
#--------------------------------------------------------------
|
||||
@@ -94,11 +53,14 @@ def inp_in_coord(line: str) -> str:
|
||||
|
||||
def inp_out_coord(name: str) -> list[str]:
|
||||
lines = []
|
||||
objs = read_all(name, 'select node_id, st_astext(geom) as coord_geom from gis.node_geometries')
|
||||
objs = read_all(
|
||||
name,
|
||||
"select node_id, st_astext(geom) as coord_geom from gis.node_geometries",
|
||||
)
|
||||
for obj in objs:
|
||||
node = obj['node_id']
|
||||
coord = from_postgis_point(obj['coord_geom'])
|
||||
x = coord['x']
|
||||
y = coord['y']
|
||||
lines.append(f'{node} {x} {y}')
|
||||
node = obj["node_id"]
|
||||
coord = from_postgis_point(obj["coord_geom"])
|
||||
x = coord["x"]
|
||||
y = coord["y"]
|
||||
lines.append(f"{node} {x} {y}")
|
||||
return lines
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Read-only projections backed by the GIS materialized-view query layer."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..core.database import read, read_all
|
||||
|
||||
|
||||
def _node_coord_rows(
|
||||
rows: list[Any],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
return {
|
||||
str(row["id"]): {
|
||||
"x": float(row["x"]),
|
||||
"y": float(row["y"]),
|
||||
"type": str(row["node_type"]),
|
||||
}
|
||||
for row in rows
|
||||
}
|
||||
|
||||
|
||||
def get_network_node_coords(name: str) -> dict[str, dict[str, Any]]:
|
||||
"""Return every publishable node with one view-backed query."""
|
||||
rows = read_all(
|
||||
name,
|
||||
"""
|
||||
SELECT id, x, y, node_type
|
||||
FROM gis.network_nodes
|
||||
ORDER BY id
|
||||
""",
|
||||
)
|
||||
return _node_coord_rows(rows)
|
||||
|
||||
|
||||
def get_major_node_coords(
|
||||
name: str, diameter: int
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Return endpoints of pipes above the requested diameter."""
|
||||
rows = read_all(
|
||||
name,
|
||||
"""
|
||||
SELECT n.id, n.x, n.y, n.node_type
|
||||
FROM gis.pipes AS p
|
||||
CROSS JOIN LATERAL (
|
||||
VALUES (p.start_node_id), (p.end_node_id)
|
||||
) AS endpoint(id)
|
||||
JOIN gis.network_nodes AS n ON n.id = endpoint.id
|
||||
WHERE p.diameter > %s
|
||||
GROUP BY n.id, n.x, n.y, n.node_type
|
||||
ORDER BY n.id
|
||||
""",
|
||||
(diameter,),
|
||||
)
|
||||
return _node_coord_rows(rows)
|
||||
|
||||
|
||||
def get_network_link_nodes(name: str) -> list[str]:
|
||||
"""Return every publishable link in the established API wire format."""
|
||||
rows = read_all(
|
||||
name,
|
||||
"""
|
||||
SELECT id, link_type, start_node_id, end_node_id
|
||||
FROM gis.network_links
|
||||
ORDER BY id
|
||||
""",
|
||||
)
|
||||
return [
|
||||
f"{row['id']}:{row['link_type']}:{row['start_node_id']}:{row['end_node_id']}"
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def get_major_pipe_nodes(name: str, diameter: int) -> list[str]:
|
||||
"""Return large pipes in the established API wire format."""
|
||||
rows = read_all(
|
||||
name,
|
||||
"""
|
||||
SELECT id, start_node_id, end_node_id
|
||||
FROM gis.pipes
|
||||
WHERE diameter > %s
|
||||
ORDER BY id
|
||||
""",
|
||||
(diameter,),
|
||||
)
|
||||
return [
|
||||
f"{row['id']}:pipe:{row['start_node_id']}:{row['end_node_id']}"
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def get_topology_rows(
|
||||
name: str, node_ids: list[str]
|
||||
) -> tuple[list[Any], list[Any]]:
|
||||
"""Load selected nodes and their internal links with two batch queries."""
|
||||
if not node_ids:
|
||||
return [], []
|
||||
|
||||
nodes = read_all(
|
||||
name,
|
||||
"""
|
||||
SELECT n.id,
|
||||
ST_X(g.geom) AS x,
|
||||
ST_Y(g.geom) AS y,
|
||||
n.node_type::text AS node_type
|
||||
FROM network.nodes AS n
|
||||
JOIN gis.node_geometries AS g ON g.node_id = n.id
|
||||
WHERE n.id = ANY(%s)
|
||||
ORDER BY n.id
|
||||
""",
|
||||
(node_ids,),
|
||||
)
|
||||
links = read_all(
|
||||
name,
|
||||
"""
|
||||
SELECT l.id,
|
||||
l.start_node_id,
|
||||
l.end_node_id,
|
||||
COALESCE(p.length, 0.0) AS length
|
||||
FROM network.links AS l
|
||||
LEFT JOIN network.pipes AS p ON p.link_id = l.id
|
||||
WHERE l.start_node_id = ANY(%s)
|
||||
AND l.end_node_id = ANY(%s)
|
||||
ORDER BY l.id
|
||||
""",
|
||||
(node_ids, node_ids),
|
||||
)
|
||||
return nodes, links
|
||||
|
||||
|
||||
def get_boundary_link_ids(name: str, node_ids: list[str]) -> list[str]:
|
||||
"""Return links with exactly one endpoint inside the supplied node set."""
|
||||
if not node_ids:
|
||||
return []
|
||||
rows = read_all(
|
||||
name,
|
||||
"""
|
||||
SELECT id
|
||||
FROM network.links
|
||||
WHERE (start_node_id = ANY(%s)) <> (end_node_id = ANY(%s))
|
||||
ORDER BY id
|
||||
""",
|
||||
(node_ids, node_ids),
|
||||
)
|
||||
return [str(row["id"]) for row in rows]
|
||||
|
||||
|
||||
def get_junction_demands(
|
||||
name: str, node_ids: list[str]
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Read authoritative demands for selected junctions from model tables."""
|
||||
if not node_ids:
|
||||
return {}
|
||||
rows = read_all(
|
||||
name,
|
||||
"""
|
||||
SELECT junction_id,
|
||||
sequence_no,
|
||||
base_demand,
|
||||
pattern_id,
|
||||
category
|
||||
FROM network.demands
|
||||
WHERE junction_id = ANY(%s)
|
||||
ORDER BY junction_id, sequence_no
|
||||
""",
|
||||
(node_ids,),
|
||||
)
|
||||
result: dict[str, list[dict[str, Any]]] = {}
|
||||
for row in rows:
|
||||
result.setdefault(str(row["junction_id"]), []).append(
|
||||
{
|
||||
"demand": float(row["base_demand"]),
|
||||
"pattern": row["pattern_id"],
|
||||
"category": row["category"],
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def sum_junction_base_demand(name: str, node_ids: list[str]) -> float:
|
||||
"""Sum selected junction demand from the authoritative model table."""
|
||||
if not node_ids:
|
||||
return 0.0
|
||||
row = read(
|
||||
name,
|
||||
"""
|
||||
SELECT COALESCE(SUM(base_demand), 0.0) AS total_base_demand
|
||||
FROM network.demands
|
||||
WHERE junction_id = ANY(%s)
|
||||
""",
|
||||
(node_ids,),
|
||||
)
|
||||
return float(row["total_base_demand"])
|
||||
@@ -2,10 +2,8 @@ import platform
|
||||
import math
|
||||
from typing import Any
|
||||
import pyclipper
|
||||
from ..model.elements import get_node_links, get_link_nodes, is_pipe
|
||||
from ..model.pipes import get_pipe
|
||||
from ..core.database import read, try_read, read_all
|
||||
from .coordinates import node_has_coord, get_node_coord
|
||||
from .network_views import get_boundary_link_ids, get_topology_rows
|
||||
|
||||
|
||||
def from_postgis_polygon(polygon: str) -> list[tuple[float, float]]:
|
||||
@@ -42,21 +40,7 @@ def get_nodes_in_boundary(name: str, boundary: list[tuple[float, float]]) -> lis
|
||||
|
||||
|
||||
def _get_links_on_boundary(name: str, nodes: list[str]) -> list[str]:
|
||||
links: list[str] = []
|
||||
|
||||
for node in nodes:
|
||||
node_links = get_node_links(name, node)
|
||||
for link in node_links:
|
||||
if link in links:
|
||||
continue
|
||||
|
||||
link_nodes = get_link_nodes(name, link)
|
||||
if link_nodes[0] in nodes and link_nodes[1] not in nodes:
|
||||
links.append(link)
|
||||
elif link_nodes[0] not in nodes and link_nodes[1] in nodes:
|
||||
links.append(link)
|
||||
|
||||
return links
|
||||
return get_boundary_link_ids(name, nodes)
|
||||
|
||||
|
||||
def get_nodes_in_region(name: str, region_id: str) -> list[str]:
|
||||
@@ -128,37 +112,39 @@ def _angle_of_node_link(node: str, link: str, nodes, links) -> float:
|
||||
|
||||
class Topology:
|
||||
def __init__(self, db: str, nodes: list[str]) -> None:
|
||||
self._nodes: dict[str, Any] = {}
|
||||
self._max_x_node = ''
|
||||
self._node_list: list[str] = []
|
||||
for node in nodes:
|
||||
if not node_has_coord(db, node):
|
||||
continue
|
||||
if get_node_links(db, node) == 0:
|
||||
continue
|
||||
self._nodes[node] = get_node_coord(db, node) | { 'links': [] }
|
||||
self._node_list.append(node)
|
||||
if self._max_x_node == '' or self._nodes[node]['x'] > self._nodes[self._max_x_node]['x']:
|
||||
self._max_x_node = node
|
||||
node_rows, link_rows = get_topology_rows(db, nodes)
|
||||
|
||||
self._nodes: dict[str, Any] = {
|
||||
str(row["id"]): {
|
||||
"x": float(row["x"]),
|
||||
"y": float(row["y"]),
|
||||
"type": str(row["node_type"]),
|
||||
"links": [],
|
||||
}
|
||||
for row in node_rows
|
||||
}
|
||||
self._node_list = list(self._nodes)
|
||||
self._max_x_node = max(
|
||||
self._nodes,
|
||||
key=lambda node_id: self._nodes[node_id]["x"],
|
||||
default="",
|
||||
)
|
||||
|
||||
self._links: dict[str, Any] = {}
|
||||
self._link_list: list[str] = []
|
||||
for node in self._nodes:
|
||||
for link in get_node_links(db, node):
|
||||
candidate = True
|
||||
link_nodes = get_link_nodes(db, link)
|
||||
for link_node in link_nodes:
|
||||
if link_node not in self._nodes:
|
||||
candidate = False
|
||||
break
|
||||
if candidate:
|
||||
length = get_pipe(db, link)['length'] if is_pipe(db, link) else 0.0
|
||||
self._links[link] = { 'node1' : link_nodes[0], 'node2' : link_nodes[1], 'length' : length }
|
||||
self._link_list.append(link)
|
||||
if link not in self._nodes[link_nodes[0]]['links']:
|
||||
self._nodes[link_nodes[0]]['links'].append(link)
|
||||
if link not in self._nodes[link_nodes[1]]['links']:
|
||||
self._nodes[link_nodes[1]]['links'].append(link)
|
||||
for row in link_rows:
|
||||
link_id = str(row["id"])
|
||||
node1 = str(row["start_node_id"])
|
||||
node2 = str(row["end_node_id"])
|
||||
if node1 not in self._nodes or node2 not in self._nodes:
|
||||
continue
|
||||
self._links[link_id] = {
|
||||
"node1": node1,
|
||||
"node2": node2,
|
||||
"length": float(row["length"]),
|
||||
}
|
||||
self._nodes[node1]["links"].append(link_id)
|
||||
self._nodes[node2]["links"].append(link_id)
|
||||
self._link_list = list(self._links)
|
||||
|
||||
def nodes(self):
|
||||
return self._nodes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
|
||||
from ..core.projects import close_project, have_project, is_project_open, open_project
|
||||
from ..core.projects import have_project
|
||||
from ..core.database import ChangeSet
|
||||
from .sections import (
|
||||
BACKDROP,
|
||||
@@ -56,7 +56,7 @@ from ..model.reactions import inp_out_reaction
|
||||
from ..model.mixing import inp_out_mixing
|
||||
from ..model.times import inp_out_time
|
||||
from ..model.reports import inp_out_report
|
||||
from ..model.options_legacy import inp_out_option
|
||||
from ..model.options_v2 import inp_out_option_v2
|
||||
from ..model.options_v3 import inp_out_option_v3
|
||||
from ..gis.coordinates import inp_out_coord
|
||||
from ..gis.vertices import inp_out_vertex
|
||||
@@ -72,11 +72,6 @@ def dump_inp(project: str, inp: str, version: str = '3'):
|
||||
if not have_project(project):
|
||||
return
|
||||
|
||||
project_open = is_project_open(project)
|
||||
|
||||
if not project_open:
|
||||
open_project(project)
|
||||
|
||||
dir = os.getcwd()
|
||||
path = os.path.join(dir, inp)
|
||||
|
||||
@@ -173,7 +168,7 @@ def dump_inp(project: str, inp: str, version: str = '3'):
|
||||
if version == '3':
|
||||
file.write('\n'.join(inp_out_option_v3(project)))
|
||||
else:
|
||||
file.write('\n'.join(inp_out_option(project)))
|
||||
file.write('\n'.join(inp_out_option_v2(project)))
|
||||
|
||||
elif name == COORDINATES:
|
||||
file.write('\n'.join(inp_out_coord(project)))
|
||||
@@ -194,10 +189,6 @@ def dump_inp(project: str, inp: str, version: str = '3'):
|
||||
|
||||
file.close()
|
||||
|
||||
if not project_open:
|
||||
close_project(project)
|
||||
|
||||
|
||||
def export_inp(project: str, version: str = '3') -> ChangeSet:
|
||||
if version != '3' and version != '2':
|
||||
version = '2'
|
||||
@@ -205,11 +196,6 @@ def export_inp(project: str, version: str = '3') -> ChangeSet:
|
||||
if not have_project(project):
|
||||
return ChangeSet()
|
||||
|
||||
project_open = is_project_open(project)
|
||||
|
||||
if not project_open:
|
||||
open_project(project)
|
||||
|
||||
inp = ''
|
||||
|
||||
for name in section_name:
|
||||
@@ -294,7 +280,7 @@ def export_inp(project: str, version: str = '3') -> ChangeSet:
|
||||
if version == '3':
|
||||
inp += '\n'.join(inp_out_option_v3(project))
|
||||
else:
|
||||
inp += '\n'.join(inp_out_option(project))
|
||||
inp += '\n'.join(inp_out_option_v2(project))
|
||||
|
||||
elif name == COORDINATES:
|
||||
inp += '\n'.join(inp_out_coord(project))
|
||||
@@ -313,7 +299,4 @@ def export_inp(project: str, version: str = '3') -> ChangeSet:
|
||||
|
||||
inp += '\n'
|
||||
|
||||
if not project_open:
|
||||
close_project(project)
|
||||
|
||||
return ChangeSet({'operation': 'export', 'inp': inp})
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
from psycopg import sql
|
||||
|
||||
from ..core.projects import (
|
||||
close_project,
|
||||
create_project,
|
||||
copy_project,
|
||||
delete_project,
|
||||
have_project,
|
||||
is_project_open,
|
||||
open_project,
|
||||
temporary_project_name,
|
||||
temporary_template_database,
|
||||
)
|
||||
from app.infra.db.project_routing import get_project_template_database_name
|
||||
from ..core.connection import project_transaction
|
||||
from ..core.database import ChangeSet, refresh_materialized_views, sql_literal, write
|
||||
from ..core.model_replace import replace_project_model
|
||||
from ..core.database import (
|
||||
ChangeSet,
|
||||
refresh_materialized_views_after_commit,
|
||||
sql_literal,
|
||||
write,
|
||||
)
|
||||
from .sections import (
|
||||
BACKDROP,
|
||||
BOUND,
|
||||
@@ -68,7 +76,7 @@ from ..model.reactions import inp_in_reaction
|
||||
from ..model.mixing import inp_in_mixing
|
||||
from ..model.times import inp_in_time
|
||||
from ..model.reports import inp_in_report
|
||||
from ..model.options_legacy import inp_in_option
|
||||
from ..model.options_v2 import inp_in_option_v2
|
||||
from ..model.options_v3 import inp_in_option_v3
|
||||
from ..gis.coordinates import inp_in_coord
|
||||
from ..gis.vertices import inp_in_vertex
|
||||
@@ -82,10 +90,11 @@ from .exporter import export_inp
|
||||
|
||||
_S = "S"
|
||||
_L = "L"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _inp_in_option(section: list[str], version: str = "3") -> str:
|
||||
return inp_in_option_v3(section) if version == "3" else inp_in_option(section)
|
||||
return inp_in_option_v3(section) if version == "3" else inp_in_option_v2(section)
|
||||
|
||||
|
||||
_handler = {
|
||||
@@ -389,60 +398,49 @@ def read_inp(project: str, inp: str, version: str = "3") -> bool:
|
||||
if version != "3" and version != "2":
|
||||
version = "2"
|
||||
|
||||
if is_project_open(project):
|
||||
close_project(project)
|
||||
if not have_project(project):
|
||||
raise ValueError(f"Project database {project!r} does not exist")
|
||||
|
||||
if have_project(project):
|
||||
delete_project(project)
|
||||
staging_project = temporary_project_name(project, "model_import")
|
||||
replacement_committed = False
|
||||
try:
|
||||
copy_project(get_project_template_database_name(project), staging_project)
|
||||
with project_transaction(staging_project):
|
||||
parse_file(staging_project, inp, version)
|
||||
replace_project_model(project, staging_project)
|
||||
replacement_committed = True
|
||||
finally:
|
||||
try:
|
||||
if have_project(staging_project):
|
||||
delete_project(staging_project)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to remove model-import staging database %s",
|
||||
staging_project,
|
||||
)
|
||||
|
||||
create_project(project)
|
||||
open_project(project)
|
||||
if replacement_committed:
|
||||
refresh_materialized_views_after_commit(project)
|
||||
|
||||
with project_transaction(project):
|
||||
parse_file(project, inp, version)
|
||||
refresh_materialized_views(project)
|
||||
|
||||
"""try:
|
||||
parse_file(project, inp, version)
|
||||
except:
|
||||
close_project(project)
|
||||
delete_project(project)
|
||||
return False"""
|
||||
|
||||
close_project(project)
|
||||
return True
|
||||
|
||||
|
||||
# DingZQ, 2024-12-28, convert v3 to v2
|
||||
def convert_inp_v3_to_v2(inp: str) -> ChangeSet:
|
||||
project = "v3Tov2"
|
||||
|
||||
if is_project_open(project):
|
||||
close_project(project)
|
||||
|
||||
if have_project(project):
|
||||
delete_project(project)
|
||||
|
||||
create_project(project)
|
||||
open_project(project)
|
||||
|
||||
filename = f"inp/{project}_temp.inp"
|
||||
if os.path.exists(filename):
|
||||
os.remove(filename)
|
||||
|
||||
with open(filename, "w", encoding="utf-8") as f:
|
||||
f.write(inp)
|
||||
|
||||
parse_file(project, filename, "3")
|
||||
|
||||
"""try:
|
||||
parse_file(project, inp, version)
|
||||
except:
|
||||
close_project(project)
|
||||
delete_project(project)
|
||||
return False"""
|
||||
|
||||
return export_inp(project, "2")
|
||||
temp_path: str | None = None
|
||||
with temporary_template_database("conversion", "v3_to_v2") as project:
|
||||
try:
|
||||
with NamedTemporaryFile(
|
||||
mode="w", suffix=".inp", encoding="utf-8", delete=False
|
||||
) as temp_file:
|
||||
temp_file.write(inp)
|
||||
temp_path = temp_file.name
|
||||
with project_transaction(project):
|
||||
parse_file(project, temp_path, "3")
|
||||
return export_inp(project, "2")
|
||||
finally:
|
||||
if temp_path is not None:
|
||||
os.remove(temp_path)
|
||||
|
||||
|
||||
def import_inp(project: str, cs: ChangeSet, version: str = "3") -> bool:
|
||||
@@ -452,17 +450,21 @@ def import_inp(project: str, cs: ChangeSet, version: str = "3") -> bool:
|
||||
if "inp" not in cs.operations[0]:
|
||||
return False
|
||||
|
||||
filename = f"inp/{project}_temp.inp"
|
||||
if os.path.exists(filename):
|
||||
os.remove(filename)
|
||||
|
||||
_print_time(f'Start writing temp file "{filename}"...')
|
||||
with open(filename, "w", encoding="utf-8") as f:
|
||||
f.write(str(cs.operations[0]["inp"]))
|
||||
_print_time(f'End writing temp file "{filename}"...')
|
||||
|
||||
result = read_inp(project, filename, version)
|
||||
|
||||
# os.remove(filename)
|
||||
|
||||
return result
|
||||
temp_path: str | None = None
|
||||
try:
|
||||
with NamedTemporaryFile(
|
||||
mode="w",
|
||||
suffix=".inp",
|
||||
prefix="tjwater_import_",
|
||||
encoding="utf-8",
|
||||
delete=False,
|
||||
) as temp_file:
|
||||
temp_file.write(str(cs.operations[0]["inp"]))
|
||||
temp_path = temp_file.name
|
||||
return read_inp(project, temp_path, version)
|
||||
finally:
|
||||
if temp_path is not None:
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@@ -94,16 +94,3 @@ def delete_demand_by_junction(name: str, junction: str) -> ChangeSet:
|
||||
if row is None:
|
||||
return ChangeSet()
|
||||
return ChangeSet(g_update_prefix | {'type': 'demand', 'junction': junction, 'demands': []})
|
||||
|
||||
|
||||
def unset_demand_by_pattern(name: str, pattern: str) -> ChangeSet:
|
||||
cs = ChangeSet()
|
||||
|
||||
rows = read_all(name, "select distinct junction_id as junction from network.demands where pattern_id = %s", (pattern,))
|
||||
for row in rows:
|
||||
ds = get_demand(name, row['junction'])
|
||||
for d in ds['demands']:
|
||||
d['pattern'] = None
|
||||
cs.append(g_update_prefix | {'type': 'demand', 'junction': row['junction'], 'demands': ds['demands']})
|
||||
|
||||
return cs
|
||||
|
||||
@@ -159,26 +159,6 @@ def get_nodes(name: str) -> list[str]:
|
||||
return _get_all(name, _NODE)
|
||||
|
||||
|
||||
def get_nodes_id_and_type(name: str) -> dict[str, str]:
|
||||
rows = read_all_typed(name, "SELECT id, node_type FROM network.nodes", ())
|
||||
return {row["id"]: row["node_type"] for row in rows}
|
||||
|
||||
|
||||
def get_major_nodes(name: str, diameter: int) -> list[str]:
|
||||
rows = read_all_typed(
|
||||
name,
|
||||
"""
|
||||
SELECT DISTINCT endpoint
|
||||
FROM network.links AS l
|
||||
JOIN network.pipes AS p ON p.link_id = l.id
|
||||
CROSS JOIN LATERAL (VALUES (l.start_node_id), (l.end_node_id)) AS e(endpoint)
|
||||
WHERE p.diameter > %s
|
||||
""",
|
||||
(diameter,),
|
||||
)
|
||||
return [row["endpoint"] for row in rows]
|
||||
|
||||
|
||||
def get_junctions(name: str) -> list[str]:
|
||||
return _get_nodes_by_type(name, JUNCTION)
|
||||
|
||||
@@ -195,20 +175,6 @@ def get_links(name: str) -> list[str]:
|
||||
return _get_all(name, _LINK)
|
||||
|
||||
|
||||
def get_links_id_and_type(name: str) -> dict[str, str]:
|
||||
rows = read_all_typed(name, "SELECT id, link_type FROM network.links", ())
|
||||
return {row["id"]: row["link_type"] for row in rows}
|
||||
|
||||
|
||||
def get_major_pipes(name: str, diameter: int) -> list[str]:
|
||||
rows = read_all_typed(
|
||||
name,
|
||||
"SELECT link_id FROM network.pipes WHERE diameter > %s ORDER BY link_id",
|
||||
(diameter,),
|
||||
)
|
||||
return [row["link_id"] for row in rows]
|
||||
|
||||
|
||||
def get_pipes(name: str) -> list[str]:
|
||||
return _get_links_by_type(name, PIPE)
|
||||
|
||||
@@ -247,10 +213,10 @@ def get_node_links(name: str, node_id: str) -> list[str]:
|
||||
|
||||
|
||||
def get_all_node_links(name: str) -> dict[str, list[str]]:
|
||||
"""Build the node adjacency map with one scan of the link table."""
|
||||
"""Build the node adjacency map with one scan of the unified GIS view."""
|
||||
rows = read_all_typed(
|
||||
name,
|
||||
"SELECT id, start_node_id, end_node_id FROM network.links ORDER BY id",
|
||||
"SELECT id, start_node_id, end_node_id FROM gis.network_links ORDER BY id",
|
||||
(),
|
||||
)
|
||||
result: dict[str, list[str]] = {}
|
||||
|
||||
@@ -6,6 +6,7 @@ from ..core.database import (
|
||||
ChangeSet,
|
||||
DatabaseCommand,
|
||||
execute_command,
|
||||
execute_locked_command,
|
||||
g_add_prefix,
|
||||
g_delete_prefix,
|
||||
g_update_prefix,
|
||||
@@ -94,8 +95,12 @@ class Junction(object):
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return { 'type': self.type, 'id': self.id, 'x': self.x, 'y': self.y, 'elevation': self.elevation }
|
||||
|
||||
def _set_junction(name: str, cs: ChangeSet) -> DatabaseCommand:
|
||||
raw_new = get_junction(name, cs.operations[0]['id'])
|
||||
def _set_junction(
|
||||
name: str,
|
||||
cs: ChangeSet,
|
||||
current: dict[str, Any] | None = None,
|
||||
) -> DatabaseCommand:
|
||||
raw_new = current if current is not None else get_junction(name, cs.operations[0]['id'])
|
||||
|
||||
new_dict = cs.operations[0]
|
||||
schema = get_junction_schema(name)
|
||||
@@ -113,11 +118,15 @@ def _set_junction(name: str, cs: ChangeSet) -> DatabaseCommand:
|
||||
|
||||
|
||||
def set_junction(name: str, cs: ChangeSet) -> ChangeSet:
|
||||
if 'id' not in cs.operations[0]:
|
||||
operation = cs.operations[0]
|
||||
if 'id' not in operation:
|
||||
return ChangeSet()
|
||||
if get_junction(name, cs.operations[0]['id']) == {}:
|
||||
return ChangeSet()
|
||||
return execute_command(name, _set_junction(name, cs))
|
||||
|
||||
def build_command() -> DatabaseCommand | None:
|
||||
current = get_junction(name, operation['id'])
|
||||
return None if current == {} else _set_junction(name, cs, current)
|
||||
|
||||
return execute_locked_command(name, build_command)
|
||||
|
||||
|
||||
def _add_junction(name: str, cs: ChangeSet) -> DatabaseCommand:
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
from psycopg import sql
|
||||
|
||||
from ..core.database import ChangeSet, g_update_prefix, read_all, sql_literal
|
||||
from .options import get_option_schema, generate_v3
|
||||
|
||||
|
||||
def _inp_in_option(section: list[str]) -> ChangeSet:
|
||||
def _inp_in_option_v2(section: list[str]) -> ChangeSet:
|
||||
if len(section) <= 0:
|
||||
return ChangeSet()
|
||||
|
||||
@@ -34,9 +32,9 @@ def _inp_in_option(section: list[str]) -> ChangeSet:
|
||||
return result
|
||||
|
||||
|
||||
def inp_in_option(section: list[str]) -> str:
|
||||
def inp_in_option_v2(section: list[str]) -> str:
|
||||
sql = ''
|
||||
result = _inp_in_option(section)
|
||||
result = _inp_in_option_v2(section)
|
||||
for op in result.operations:
|
||||
for key in op.keys():
|
||||
if key == 'operation' or key == 'type':
|
||||
@@ -48,7 +46,7 @@ def inp_in_option(section: list[str]) -> str:
|
||||
return sql
|
||||
|
||||
|
||||
def inp_out_option(name: str) -> list[str]:
|
||||
def inp_out_option_v2(name: str) -> list[str]:
|
||||
lines = []
|
||||
objs = read_all(name, "select key, value from network.simulation_settings where engine_version = 'legacy' order by key")
|
||||
|
||||
@@ -71,7 +69,7 @@ def inp_out_option(name: str) -> list[str]:
|
||||
# why write this ?
|
||||
if key == 'PRESSURE':
|
||||
continue
|
||||
# release version does not support new keys and has error message
|
||||
# EPANET V2 does not support these newer keys.
|
||||
if key == 'HTOL' or key == 'QTOL' or key == 'RQTOL':
|
||||
continue
|
||||
# ignore some weird settings for DDA
|
||||
@@ -1,5 +1,3 @@
|
||||
from psycopg import sql
|
||||
|
||||
from ..core.database import ChangeSet, g_update_prefix, read_all, sql_literal
|
||||
from .options import get_option_schema, get_option_v3_schema, generate_v2, generate_v3
|
||||
|
||||
|
||||
@@ -93,7 +93,10 @@ def _delete_pattern(name: str, cs: ChangeSet) -> DatabaseCommand:
|
||||
id = cs.operations[0]['id']
|
||||
f_id = sql_literal(id)
|
||||
|
||||
statement = f"delete from network.patterns where id = {f_id};"
|
||||
statement = (
|
||||
f"update network.demands set pattern_id = null where pattern_id = {f_id};"
|
||||
f"\ndelete from network.patterns where id = {f_id};"
|
||||
)
|
||||
|
||||
change = g_delete_prefix | { 'type': 'pattern' } | { 'id': id }
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from ..core.database import (
|
||||
ChangeSet,
|
||||
DatabaseCommand,
|
||||
execute_command,
|
||||
execute_locked_command,
|
||||
g_add_prefix,
|
||||
g_delete_prefix,
|
||||
g_update_prefix,
|
||||
@@ -144,9 +145,12 @@ class Pipe(object):
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return { 'type': self.type, 'id': self.id, 'node1': self.node1, 'node2': self.node2, 'length': self.length, 'diameter': self.diameter, 'roughness': self.roughness, 'minor_loss': self.minor_loss, 'status': self.status }
|
||||
|
||||
def _set_pipe(name: str, cs: ChangeSet) -> DatabaseCommand:
|
||||
raw_new = get_pipe(name, cs.operations[0]['id'])
|
||||
|
||||
def _set_pipe(
|
||||
name: str,
|
||||
cs: ChangeSet,
|
||||
current: dict[str, Any] | None = None,
|
||||
) -> DatabaseCommand:
|
||||
raw_new = current if current is not None else get_pipe(name, cs.operations[0]['id'])
|
||||
new_dict = cs.operations[0]
|
||||
schema = get_pipe_schema(name)
|
||||
for key, value in schema.items():
|
||||
@@ -154,19 +158,59 @@ def _set_pipe(name: str, cs: ChangeSet) -> DatabaseCommand:
|
||||
raw_new[key] = new_dict[key]
|
||||
new = Pipe(raw_new)
|
||||
|
||||
statement = f"update network.links set start_node_id = {new.f_node1}, end_node_id = {new.f_node2} where id = {new.f_id};"
|
||||
statement += f"\nupdate network.pipes set length = {new.f_length}, diameter = {new.f_diameter}, roughness = {new.f_roughness}, minor_loss = {new.f_minor_loss}, status = {new.f_status} where link_id = {new.f_id};"
|
||||
link_columns = {
|
||||
'node1': ('start_node_id', new.f_node1),
|
||||
'node2': ('end_node_id', new.f_node2),
|
||||
}
|
||||
pipe_columns = {
|
||||
'length': ('length', new.f_length),
|
||||
'diameter': ('diameter', new.f_diameter),
|
||||
'roughness': ('roughness', new.f_roughness),
|
||||
'minor_loss': ('minor_loss', new.f_minor_loss),
|
||||
'status': ('status', new.f_status),
|
||||
}
|
||||
statements = []
|
||||
link_assignments = [
|
||||
f"{column} = {value}"
|
||||
for field, (column, value) in link_columns.items()
|
||||
if field in new_dict
|
||||
]
|
||||
if link_assignments:
|
||||
statements.append(
|
||||
f"update network.links set {', '.join(link_assignments)} where id = {new.f_id};"
|
||||
)
|
||||
pipe_assignments = [
|
||||
f"{column} = {value}"
|
||||
for field, (column, value) in pipe_columns.items()
|
||||
if field in new_dict
|
||||
]
|
||||
if pipe_assignments:
|
||||
statements.append(
|
||||
f"update network.pipes set {', '.join(pipe_assignments)} where link_id = {new.f_id};"
|
||||
)
|
||||
statement = "\n".join(statements)
|
||||
change = g_update_prefix | new.as_dict()
|
||||
|
||||
return DatabaseCommand(statement, [change])
|
||||
|
||||
|
||||
def set_pipe(name: str, cs: ChangeSet) -> ChangeSet:
|
||||
if 'id' not in cs.operations[0]:
|
||||
operation = cs.operations[0]
|
||||
if 'id' not in operation:
|
||||
return ChangeSet()
|
||||
if get_pipe(name, cs.operations[0]['id']) == {}:
|
||||
mutable_fields = {
|
||||
'node1', 'node2', 'length', 'diameter', 'roughness', 'minor_loss', 'status'
|
||||
}
|
||||
if not mutable_fields.intersection(operation):
|
||||
return ChangeSet()
|
||||
return execute_command(name, _set_pipe(name, cs))
|
||||
|
||||
def build_command() -> DatabaseCommand | None:
|
||||
current = get_pipe(name, operation['id'])
|
||||
if current == {}:
|
||||
return None
|
||||
return _set_pipe(name, cs, current)
|
||||
|
||||
return execute_locked_command(name, build_command)
|
||||
|
||||
|
||||
def _add_pipe(name: str, cs: ChangeSet) -> DatabaseCommand:
|
||||
|
||||
@@ -4,6 +4,7 @@ from ..core.database import (
|
||||
ChangeSet,
|
||||
DatabaseCommand,
|
||||
execute_command,
|
||||
execute_locked_command,
|
||||
g_add_prefix,
|
||||
g_delete_prefix,
|
||||
g_update_prefix,
|
||||
@@ -87,8 +88,12 @@ class Pump(object):
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return { 'type': self.type, 'id': self.id, 'node1': self.node1, 'node2': self.node2, 'power': self.power, 'head': self.head, 'speed': self.speed, 'pattern': self.pattern }
|
||||
|
||||
def _set_pump(name: str, cs: ChangeSet) -> DatabaseCommand:
|
||||
raw_new = get_pump(name, cs.operations[0]['id'])
|
||||
def _set_pump(
|
||||
name: str,
|
||||
cs: ChangeSet,
|
||||
current: dict[str, Any] | None = None,
|
||||
) -> DatabaseCommand:
|
||||
raw_new = current if current is not None else get_pump(name, cs.operations[0]['id'])
|
||||
|
||||
new_dict = cs.operations[0]
|
||||
schema = get_pump_schema(name)
|
||||
@@ -105,11 +110,15 @@ def _set_pump(name: str, cs: ChangeSet) -> DatabaseCommand:
|
||||
|
||||
|
||||
def set_pump(name: str, cs: ChangeSet) -> ChangeSet:
|
||||
if 'id' not in cs.operations[0]:
|
||||
operation = cs.operations[0]
|
||||
if 'id' not in operation:
|
||||
return ChangeSet()
|
||||
if get_pump(name, cs.operations[0]['id']) == {}:
|
||||
return ChangeSet()
|
||||
return execute_command(name, _set_pump(name, cs))
|
||||
|
||||
def build_command() -> DatabaseCommand | None:
|
||||
current = get_pump(name, operation['id'])
|
||||
return None if current == {} else _set_pump(name, cs, current)
|
||||
|
||||
return execute_locked_command(name, build_command)
|
||||
|
||||
|
||||
def _add_pump(name: str, cs: ChangeSet) -> DatabaseCommand:
|
||||
|
||||
@@ -4,6 +4,7 @@ from ..core.database import (
|
||||
ChangeSet,
|
||||
DatabaseCommand,
|
||||
execute_command,
|
||||
execute_locked_command,
|
||||
g_add_prefix,
|
||||
g_delete_prefix,
|
||||
g_update_prefix,
|
||||
@@ -85,8 +86,12 @@ class Reservoir(object):
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return { 'type': self.type, 'id': self.id, 'x': self.x, 'y': self.y, 'head': self.head, 'pattern': self.pattern }
|
||||
|
||||
def _set_reservoir(name: str, cs: ChangeSet) -> DatabaseCommand:
|
||||
raw_new = get_reservoir(name, cs.operations[0]['id'])
|
||||
def _set_reservoir(
|
||||
name: str,
|
||||
cs: ChangeSet,
|
||||
current: dict[str, Any] | None = None,
|
||||
) -> DatabaseCommand:
|
||||
raw_new = current if current is not None else get_reservoir(name, cs.operations[0]['id'])
|
||||
|
||||
new_dict = cs.operations[0]
|
||||
schema = get_reservoir_schema(name)
|
||||
@@ -104,11 +109,15 @@ def _set_reservoir(name: str, cs: ChangeSet) -> DatabaseCommand:
|
||||
|
||||
|
||||
def set_reservoir(name: str, cs: ChangeSet) -> ChangeSet:
|
||||
if 'id' not in cs.operations[0]:
|
||||
operation = cs.operations[0]
|
||||
if 'id' not in operation:
|
||||
return ChangeSet()
|
||||
if get_reservoir(name, cs.operations[0]['id']) == {}:
|
||||
return ChangeSet()
|
||||
return execute_command(name, _set_reservoir(name, cs))
|
||||
|
||||
def build_command() -> DatabaseCommand | None:
|
||||
current = get_reservoir(name, operation['id'])
|
||||
return None if current == {} else _set_reservoir(name, cs, current)
|
||||
|
||||
return execute_locked_command(name, build_command)
|
||||
|
||||
|
||||
def _add_reservoir(name: str, cs: ChangeSet) -> DatabaseCommand:
|
||||
|
||||
@@ -4,6 +4,7 @@ from ..core.database import (
|
||||
ChangeSet,
|
||||
DatabaseCommand,
|
||||
execute_command,
|
||||
execute_locked_command,
|
||||
g_add_prefix,
|
||||
g_delete_prefix,
|
||||
g_update_prefix,
|
||||
@@ -121,8 +122,12 @@ class Tank(object):
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return { 'type': self.type, 'id': self.id, 'x': self.x, 'y': self.y, 'elevation': self.elevation, 'init_level': self.init_level, 'min_level': self.min_level, 'max_level': self.max_level, 'diameter': self.diameter, 'min_vol': self.min_vol, 'vol_curve': self.vol_curve, 'overflow': self.overflow }
|
||||
|
||||
def _set_tank(name: str, cs: ChangeSet) -> DatabaseCommand:
|
||||
raw_new = get_tank(name, cs.operations[0]['id'])
|
||||
def _set_tank(
|
||||
name: str,
|
||||
cs: ChangeSet,
|
||||
current: dict[str, Any] | None = None,
|
||||
) -> DatabaseCommand:
|
||||
raw_new = current if current is not None else get_tank(name, cs.operations[0]['id'])
|
||||
|
||||
new_dict = cs.operations[0]
|
||||
schema = get_tank_schema(name)
|
||||
@@ -140,11 +145,15 @@ def _set_tank(name: str, cs: ChangeSet) -> DatabaseCommand:
|
||||
|
||||
|
||||
def set_tank(name: str, cs: ChangeSet) -> ChangeSet:
|
||||
if 'id' not in cs.operations[0]:
|
||||
operation = cs.operations[0]
|
||||
if 'id' not in operation:
|
||||
return ChangeSet()
|
||||
if get_tank(name, cs.operations[0]['id']) == {}:
|
||||
return ChangeSet()
|
||||
return execute_command(name, _set_tank(name, cs))
|
||||
|
||||
def build_command() -> DatabaseCommand | None:
|
||||
current = get_tank(name, operation['id'])
|
||||
return None if current == {} else _set_tank(name, cs, current)
|
||||
|
||||
return execute_locked_command(name, build_command)
|
||||
|
||||
|
||||
def _add_tank(name: str, cs: ChangeSet) -> DatabaseCommand:
|
||||
|
||||
@@ -4,6 +4,7 @@ from ..core.database import (
|
||||
ChangeSet,
|
||||
DatabaseCommand,
|
||||
execute_command,
|
||||
execute_locked_command,
|
||||
g_add_prefix,
|
||||
g_delete_prefix,
|
||||
g_update_prefix,
|
||||
@@ -94,8 +95,12 @@ class Valve(object):
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return { 'type': self.type, 'id': self.id, 'node1': self.node1, 'node2': self.node2, 'diameter': self.diameter, 'v_type': self.v_type, 'setting': self.setting, 'minor_loss': self.minor_loss }
|
||||
|
||||
def _set_valve(name: str, cs: ChangeSet) -> DatabaseCommand:
|
||||
raw_new = get_valve(name, cs.operations[0]['id'])
|
||||
def _set_valve(
|
||||
name: str,
|
||||
cs: ChangeSet,
|
||||
current: dict[str, Any] | None = None,
|
||||
) -> DatabaseCommand:
|
||||
raw_new = current if current is not None else get_valve(name, cs.operations[0]['id'])
|
||||
|
||||
new_dict = cs.operations[0]
|
||||
schema = get_valve_schema(name)
|
||||
@@ -112,11 +117,15 @@ def _set_valve(name: str, cs: ChangeSet) -> DatabaseCommand:
|
||||
|
||||
|
||||
def set_valve(name: str, cs: ChangeSet) -> ChangeSet:
|
||||
if 'id' not in cs.operations[0]:
|
||||
operation = cs.operations[0]
|
||||
if 'id' not in operation:
|
||||
return ChangeSet()
|
||||
if get_valve(name, cs.operations[0]['id']) == {}:
|
||||
return ChangeSet()
|
||||
return execute_command(name, _set_valve(name, cs))
|
||||
|
||||
def build_command() -> DatabaseCommand | None:
|
||||
current = get_valve(name, operation['id'])
|
||||
return None if current == {} else _set_valve(name, cs, current)
|
||||
|
||||
return execute_locked_command(name, build_command)
|
||||
|
||||
|
||||
def _add_valve(name: str, cs: ChangeSet) -> DatabaseCommand:
|
||||
|
||||
Reference in New Issue
Block a user