Files
cacti/lib/aggregate.php
TheWitnessandTheWitness 3ab4c69fb7 PHPStan Level 8 - Phase 1 Conversion (#8135)
* PHPStan Level 8 - Phase 1 Conversion

Reduces the real (no-baseline) PHPStan level 8 error count from ~2,724 to 425
and regenerates phpstan-baseline.neon accordingly (8,941 -> 1,921 lines).

Core type-safety changes
- lib/database.php: narrow db_fetch_row()/db_fetch_row_prepared() to return
  array (was bool|array) and db_fetch_assoc()/db_fetch_assoc_prepared() to
  return array (was mixed); the _prepared workers coerce the connection-failure
  path to an empty array so the declared type is honest. empty()/cacti_sizeof()
  checks are unaffected.
- lib/database.php: narrow $db_conn guards from is_object() to instanceof PDO in
  the transaction/quote/insert-id helpers so the PDO calls resolve.
- lib/mib_cache.php: the fluent MibCache methods mib()/object()/table()/row()
  now return self instead of a bare object so chained calls type-check.

Type-safety casts and guards (mechanical, behaviour-preserving)
- Cast string|false/|null builtin results feeding string parameters
  (json_decode, base64_encode, trim, htmle, preg_replace, explode, etc.).
- Cast int|string / int|false values feeding int id/pid parameters.
- Guard resource|false fopen()/popen() handles before fwrite/fclose/fread, and
  wrap foreach over array|false/list|false results with `?: []`.

Genuine bugs found and fixed
- cli/add_tree.php / lib/api_tree.php: site tree nodes could not be created via
  the CLI - the --site-id option was never parsed, $nodeTypes had no 'site'
  entry (passing null to api_tree_item_save(int $type) -> TypeError), and
  api_tree_item_save() never persisted site_id. All three fixed.
- tree.php: leaves_exist() is typed : int but returned db_fetch_assoc_prepared()
  (an array of rows) for a SELECT COUNT(*); now uses db_fetch_cell_prepared()
  and returns the scalar count.
- lib/mib_cache.php: $entries_per_object was a float (division / ceil) used as
  an array key (PHP 8.1 implicit float-to-int key deprecation); now cast to int.

Config
- .phpstan.neon: add notIdentical.alwaysTrue and booleanAnd.alwaysFalse to the
  existing always-true/false ignore family (same "PHPStan gets confused"
  dynamic-constant cause already covered by identical.alwaysTrue,
  notEqual.alwaysTrue, booleanAnd.leftAlwaysTrue, etc.).
- phpstan-baseline.neon: regenerated; the remaining 425 entries are the
  untouched long tail for Phase 2.

* Remove redundant @phpstan-ignore comments

Follow-up cleanup for the level 8 Phase 1 work. Removes 14 inline
@phpstan-ignore comments that no longer suppress any reported error:

- cli/repair_database.php: `@phpstan-ignore booleanAnd.alwaysFalse` is now
  covered by the global booleanAnd.alwaysFalse ignore added to .phpstan.neon.
- 13 others (host.php, include/global.php, include/session.php,
  lib/api_automation.php x2, lib/functions.php x2, lib/html_utility.php x2,
  lib/installer.php, lib/package.php, lib/reports.php, lib/rrd.php) no longer
  match any reported error after the type-safety fixes (e.g. the
  db_fetch_row_prepared() return-type narrowing removed the offset-assign
  errors the api_automation ignores were suppressing).

Verified with reportUnmatchedIgnoredErrors enabled: 0 unmatched ignores
remain and the full level 8 run still reports 0 errors.

* ci: satisfy validator ratchet and refresh security sink baselines

- lib/api_tree.php: give the new tree-item site_id a real numeric
  constraint ('^[-0-9]+$', matching html_reports.php) instead of an
  empty regex, so the validator constraint ratchet passes without
  bumping its baseline.
- tests/security/baselines: refresh the three sink/hotspot signatures
  whose code text changed from the (string) casts added in this branch
  (lib/poller.php setsid, cli/rrdresize.php json_encode, lib/import.php
  simplexml_load_string).

* ci(js): allow DOMPurify 3.4.16 and update sync_js pin test

The dependabot bump (#8134) moved package.json/package-lock.json to
DOMPurify 3.4.16 on develop but left tests/js/sync_js.test.mjs pinned to
3.4.15, so the PR merge commit fails the 'DOMPurify is pinned' contract.
Bring package.json/package-lock.json to 3.4.16 (matching develop) and
update the pin test to expect 3.4.16.

* test: update sql.php null-handling assertion for the (string) cast

This branch changed scripts/sql.php from trim(\ ?? '') to
trim((string) \) (the phpstan-safe form that coerces both null and
false from shell_exec()). Update SqlScriptsTest's null-return assertion
to match the new cast instead of the removed '?? ''.

* fix: address Copilot review - ownership IDs, fopen guards, mib_cache bound

- chown()/chgrp(): pass integer UID/GID from fileowner()/filegroup()
  (guarding the false lookup) instead of string-casting them, which made
  them resolve account/group names like '1001' and fail. Covers
  lib/api_device.php, lib/boost.php and poller_maintenance.php (x2).
- fopen() failure now short-circuits instead of reporting success:
  lib/package.php returns false, lib/clog_webapi.php and utilities.php
  render an error, and snmpagent_mibcachechild.php skips the cache write
  and sentinel unlink so a concurrent writer's lock is preserved.
- lib/mib_cache.php: keep the float quotient as the loop bound and cast
  only the array offset, so a non-even entries/columns split no longer
  truncates the loop and drops a cached row.

* revert: keep db_fetch_* returning false on error; baseline Level 8 instead

Per review, restoring the intentional fail-safe: db_fetch_row[_prepared]()
return bool|array and db_fetch_assoc[_prepared]() return mixed again, so a
genuine SQL error (not just a missing connection) is signalled as false and
the === false checks in cdef_is_in_use(), boost_process_* and aggregate.php
work as before. Dropped the is_array() ? : [] coercion.

Level 8 stays green by regenerating phpstan-baseline.neon to capture the
re-introduced bool|array/mixed call-site errors (the ~1,850 the narrowing
had eliminated). Tightening those callers to drop them from the baseline is
Phase 2 work.

---------

Co-authored-by: TheWitness <copilot@users.noreply.github.com>
2026-10-02 10:08:56 -04:00

1200 lines
40 KiB
PHP

<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU General Public License for more details. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
| This code is designed, written, and maintained by the Cacti Group. See |
| about.php and/or the AUTHORS file for specific developer information. |
+-------------------------------------------------------------------------+
| http://www.cacti.net/ |
+-------------------------------------------------------------------------+
*/
/**
* Builds a URL for the children graphs of an aggregate graph.
*
* This function constructs a URL that links to the children graphs of a specified aggregate graph.
* It first prunes the graphs associated with the given local graph ID, then fetches the aggregate
* graph data and its associated child graph IDs. If child graph IDs are found, it constructs a URL
* with the appropriate query parameters and returns it as a hyperlink.
*
* @param int $local_graph_id The ID of the local graph.
* @param int $graph_start Optional. The start time for the graph. Default is -1.
* @param int $graph_end Optional. The end time for the graph. Default is -1.
* @param int $rra_id Optional. The RRA ID for the graph. Default is -1.
*
* @return string The constructed URL as a hyperlink.
*/
function aggregate_build_children_url(int $local_graph_id, int $graph_start = -1, int $graph_end = -1, int $rra_id = -1) : string {
aggregate_prune_graphs($local_graph_id);
$aggregate_data = db_fetch_row_prepared('SELECT *
FROM aggregate_graphs
WHERE local_graph_id = ?',
[$local_graph_id]);
if (cacti_sizeof($aggregate_data)) {
$local_graph_ids = array_rekey(
db_fetch_assoc_prepared('SELECT local_graph_id
FROM aggregate_graphs_items
WHERE aggregate_graph_id = ?',
[$aggregate_data['id']]
), 'local_graph_id', 'local_graph_id'
);
if (cacti_sizeof($local_graph_ids)) {
$graph_select = 'graph_add=';
foreach ($local_graph_ids as $graph) {
$graph_select .= $graph . '%2C';
}
return "<a class='hyperLink aggregates' href='" . htmle(CACTI_PATH_URL . 'graph_view.php?reset=1&page=1&graph_template_id=-1&host_id=-1&filter=&style=selective&action=preview' . ($graph_start >= 0 ? '&graph_start=' . $graph_start : '') . ($graph_end >= 0 ? '&graph_end=' . $graph_end : '') . ($rra_id >= 0 ? '&rra_id=' . $rra_id : '') . '&' . $graph_select) . "'><i class='drillDown ti ti-sitemap-filled expandAggregate' title='" . __esc('Display Graphs from this Aggregate') . "'></i></a><br>" . PHP_EOL;
}
}
return '';
}
/**
* Converts a list of graphs to use an aggregate template.
*
* This function takes an array of graph IDs and applies an aggregate template to each graph.
* It saves the new aggregate graph configuration to the database and updates the aggregate graph items.
*
* @param array $graphs An array of graph IDs to be converted.
*
* @return void
*/
function api_aggregate_convert_template(array $graphs) : void {
$aggregate_template_id = gfrv('aggregate_template_id');
$aggregate_template = db_fetch_row_prepared('SELECT *
FROM aggregate_graph_templates
WHERE id = ?',
[$aggregate_template_id]);
// without a valid template the saves below would write null template fields
if (!cacti_sizeof($aggregate_template)) {
raise_message('aggregate_template_error', __('The selected Aggregate Template was not found! No graphs were migrated.'), MESSAGE_LEVEL_ERROR);
return;
}
foreach ($graphs as $graph) {
$save = [];
$save['id'] = '';
$save['local_graph_id'] = $graph;
$save['aggregate_template_id'] = $aggregate_template_id;
$save['template_propogation'] = 'on';
$save['title_format'] = db_fetch_cell_prepared('SELECT title_cache FROM graph_templates_graph WHERE local_graph_id = ?', [$graph]);
$save['graph_template_id'] = $aggregate_template['graph_template_id'];
$save['gprint_prefix'] = $aggregate_template['gprint_prefix'];
$save['graph_type'] = $aggregate_template['graph_type'];
$save['total'] = $aggregate_template['total'];
$save['total_type'] = $aggregate_template['total_type'];
$save['total_prefix'] = $aggregate_template['total_prefix'];
$save['order_type'] = $aggregate_template['order_type'];
$id = sql_save($save, 'aggregate_graphs');
$task_items = array_rekey(
db_fetch_assoc_prepared('SELECT DISTINCT task_item_id
FROM graph_templates_item
WHERE local_graph_id = ?
ORDER BY sequence',
[$graph]
), 'task_item_id', 'task_item_id'
);
$task_items = implode(',', $task_items);
$member_graphs = array_rekey(db_fetch_assoc("SELECT DISTINCT local_graph_id
FROM graph_templates_item
WHERE task_item_id IN ($task_items)
AND graph_template_id>0"), 'local_graph_id', 'local_graph_id');
$sequence = 1;
foreach ($member_graphs as $mg) {
db_execute_prepared('REPLACE INTO aggregate_graphs_items
(aggregate_graph_id, local_graph_id, sequence)
VALUES (?, ?, ?)',
[$id, $mg, $sequence]);
$sequence++;
}
if (!push_out_aggregates($aggregate_template_id, $graph)) {
cacti_log('ERROR: Failed to rebuild aggregate graph after template conversion.', false, 'AGGREGATE');
return;
}
/**
* Save the last time a aggregate was altered/updated
* for Caching.
*/
set_config_option('time_last_change_aggregate_graph', time());
}
}
/**
* Associates a list of graphs with a local graph in the aggregate graphs.
*
* This function retrieves the aggregate template and aggregate ID for the given local graph ID.
* If an aggregate ID is found, it determines the maximum sequence number for the aggregate graph items.
* It then iterates over the provided graphs and inserts or replaces each graph into the aggregate graph items
* with the appropriate sequence number. Finally, it pushes out the aggregates using the aggregate template and local graph ID.
*
* @param int $local_graph_id The ID of the local graph to associate with.
* @param array $graphs An array of graph IDs to associate with the local graph.
*
* @return void
*/
function api_aggregate_associate(int $local_graph_id, array $graphs) : void {
$aggregate_template = db_fetch_cell_prepared('SELECT aggregate_template_id
FROM aggregate_graphs
WHERE local_graph_id = ?',
[$local_graph_id]);
$aggregate_id = db_fetch_cell_prepared('SELECT id
FROM aggregate_graphs
WHERE local_graph_id = ?',
[$local_graph_id]);
if (!empty($aggregate_id)) {
$max_sequence = db_fetch_cell_prepared('SELECT MAX(sequence)
FROM aggregate_graphs_items
WHERE aggregate_graph_id = ?',
[$aggregate_id]);
if ($max_sequence == '') {
$max_sequence = 1;
}
foreach ($graphs as $graph) {
db_execute_prepared('REPLACE INTO aggregate_graphs_items
(aggregate_graph_id, local_graph_id, sequence)
VALUES (?, ?, ?)',
[$aggregate_id, $graph, $max_sequence]);
$max_sequence++;
}
if (!push_out_aggregates($aggregate_template, $local_graph_id)) {
cacti_log('ERROR: Failed to rebuild aggregate graph after association.', false, 'AGGREGATE');
return;
}
/**
* Save the last time a aggregate was altered/updated
* for Caching.
*/
set_config_option('time_last_change_aggregate_graph', time());
}
}
/**
* Disassociates a list of graphs from an aggregate graph.
*
* This function removes the association between a specified local graph and a list of graphs
* from the aggregate graph. It first retrieves the aggregate template ID and aggregate ID
* for the given local graph ID. If an aggregate ID is found, it iterates through the list of
* graphs and deletes each association from the `aggregate_graphs_items` table. Finally, it
* calls the `push_out_aggregates` function to update the aggregate template.
*
* @param int $local_graph_id The ID of the local graph to disassociate.
* @param array $graphs An array of graph IDs to be disassociated from the aggregate graph.
*
* @return void
*/
function api_aggregate_disassociate(int $local_graph_id, array $graphs) : void {
$aggregate_template = db_fetch_cell_prepared('SELECT aggregate_template_id
FROM aggregate_graphs
WHERE local_graph_id = ?',
[$local_graph_id]);
$aggregate_id = db_fetch_cell_prepared('SELECT id
FROM aggregate_graphs
WHERE local_graph_id = ?',
[$local_graph_id]);
if (!empty($aggregate_id)) {
foreach ($graphs as $graph) {
db_execute_prepared('DELETE FROM aggregate_graphs_items
WHERE aggregate_graph_id = ?
AND local_graph_id = ?',
[$aggregate_id, $graph]);
}
if (!push_out_aggregates($aggregate_template, $local_graph_id)) {
cacti_log('ERROR: Failed to rebuild aggregate graph after disassociation.', false, 'AGGREGATE');
return;
}
/**
* Save the last time a aggregate was altered/updated
* for Caching.
*/
set_config_option('time_last_change_aggregate_graph', time());
}
}
/**
* Creates an aggregate graph from a list of graphs.
*
* @param string $aggregate_name The name of the aggregate graph to be created.
* @param array $graphs An array of graph IDs to be included in the aggregate graph.
* @param int $agg_template_id Optional. The ID of the aggregate template to use. Default is 0.
*
* @return void
*/
function api_aggregate_create(string $aggregate_name, array $graphs, int $agg_template_id = 0) : void {
$graph_items = [];
// get the first aggregate graph
if ($agg_template_id == 0) {
$agg_template = db_fetch_row_prepared('SELECT *
FROM aggregate_graphs
WHERE local_graph_id = ?',
[$graphs[0]]);
// get graph items
$graph_items = db_fetch_assoc('SELECT DISTINCT local_graph_id
FROM aggregate_graphs_items
WHERE aggregate_graph_id IN(
SELECT id
FROM aggregate_graphs
WHERE ' . array_to_sql_or($graphs, 'local_graph_id') . ')');
} else {
$agg_template = db_fetch_row_prepared('SELECT *
FROM aggregate_graph_templates
WHERE id = ?',
[$agg_template_id]);
// unset when dealing with a template
unset($agg_template['name']);
$agg_template['aggregate_template_id'] = $agg_template_id;
$agg_template['template_propogation'] = 'on';
// get graph items
foreach ($graphs as $graph) {
$graph_items[]['local_graph_id'] = $graph;
}
}
if (cacti_sizeof($agg_template)) {
// create new graph in cacti tables
$graph_template_graph = db_fetch_row_prepared('SELECT *
FROM graph_templates_graph
WHERE local_graph_id = ?',
[$graphs[0]]);
$graph_template_id = $graph_template_graph['graph_template_id'];
$local_graph_id = aggregate_graph_save(0, $graph_template_id, $aggregate_name, $agg_template_id);
// create new graph in aggregate table
$save = [];
$save = $agg_template;
$save['id'] = 0;
$save['local_graph_id'] = $local_graph_id;
$save['title_format'] = $aggregate_name;
$agg_id = sql_save($save, 'aggregate_graphs');
if (cacti_sizeof($graph_items)) {
$aggs = 1;
$sql = '';
foreach ($graph_items as $i) {
$sql .= ($aggs > 1 ? ',' : '') . "($agg_id, " . $i['local_graph_id'] . ", $aggs)";
$aggs++;
}
db_execute("INSERT INTO aggregate_graphs_items
(aggregate_graph_id, local_graph_id, sequence) VALUES $sql");
}
// update title cache
if (!empty($local_graph_id)) {
update_graph_title_cache($local_graph_id);
}
if (!push_out_aggregates($agg_template['aggregate_template_id'], $local_graph_id)) {
cacti_log('ERROR: Failed to rebuild newly created aggregate graph.', false, 'AGGREGATE');
}
}
}
/**
* aggregate_error_handler - PHP error handler
*
* @param int $errno error id
* @param string $errmsg error message
* @param string $filename file name
* @param int $linenum line of error
* @param array $vars additional variables
*
* @return bool
*/
function aggregate_error_handler(int $errno, string $errmsg, string $filename, int $linenum, array $vars = []) : bool {
$errno &= error_reporting();
// return if error handling disabled by @
if ($errno == 0) {
return true;
}
if (error_reporting() == 0) {
return true;
}
if (read_config_option('log_verbosity') >= POLLER_VERBOSITY_DEBUG) {
// define all error types
$errortype = [
E_ERROR => 'Error',
E_WARNING => 'Warning',
E_PARSE => 'Parsing Error',
E_NOTICE => 'Notice',
E_CORE_ERROR => 'Core Error',
E_CORE_WARNING => 'Core Warning',
E_COMPILE_ERROR => 'Compile Error',
E_COMPILE_WARNING => 'Compile Warning',
E_USER_ERROR => 'User Error',
E_USER_WARNING => 'User Warning',
E_USER_NOTICE => 'User Notice',
E_RECOVERABLE_ERROR => 'Catchable Fatal Error'
];
// create an error string for the log
$err = "ERRNO:'" . $errno . "' TYPE:'" . $errortype[$errno] .
"' MESSAGE:'" . $errmsg . "' IN FILE:'" . $filename .
"' LINE NO:'" . $linenum . "'";
// let's ignore some lesser issues
if (substr_count($errmsg, 'date_default_timezone')) {
return true;
}
if (substr_count($errmsg, 'Only variables')) {
return true;
}
// log the error to the Cacti log
cacti_log('PROGERR: ' . $err, false, 'AGGREGATE', POLLER_VERBOSITY_DEBUG);
print('PROGERR: ' . $err . '<br><pre>');
// backtrace, if available
cacti_debug_backtrace('AGGREGATE', true);
if (isset($GLOBALS['error_fatal'])) {
if ($GLOBALS['error_fatal'] & $errno) {
die("Fatal error $errno" . PHP_EOL);
}
}
}
return true;
}
/**
* get_next_sequence - returns the next available sequence id
*
* @param int $id The current id
* @param string $field The field name that contains the target id
* @param string $table_name The table name that contains the target id
* @param string $group_query An SQL 'where' clause to limit the query
* @param string $key_field
*
* @return int The next available sequence id
*/
function get_next_sequence(int $id, string $field, string $table_name, string $group_query, string $key_field = 'id') : int {
cacti_log(__FUNCTION__ . ' called. Id: ' . $id . ' field: ' . $field . ' table: ' . $table_name, true, 'AGGREGATE', POLLER_VERBOSITY_DEVDBG);
if (empty($id)) {
$data = db_fetch_row("SELECT max($field)+1 AS seq FROM $table_name WHERE $group_query");
if ($data['seq'] == '') {
return 1;
} else {
return $data['seq'];
}
} else {
$data = db_fetch_row("SELECT $field FROM $table_name WHERE $key_field = id");
return (int) $data[$field];
}
}
/**
* find out, if this is a pure STACKed graph
*
* @param int $_local_graph_id graph to be examined
*
* @return bool true, if pure STACKed graph
*/
function aggregate_is_pure_stacked_graph(int $_local_graph_id) : bool {
cacti_log(__FUNCTION__ . ' local_graph: ' . $_local_graph_id, true, 'AGGREGATE', POLLER_VERBOSITY_DEVDBG);
$_pure_stacked_graph = false;
if (!empty($_local_graph_id)) {
// fetch all AREA graph items
$_count = db_fetch_cell_prepared('SELECT COUNT(id)
FROM graph_templates_item
WHERE graph_templates_item.local_graph_id = ?
AND graph_templates_item.graph_type_id IN (?, ?, ?, ?)',
[$_local_graph_id, GRAPH_ITEM_TYPE_AREA, GRAPH_ITEM_TYPE_LINE1, GRAPH_ITEM_TYPE_LINE2, GRAPH_ITEM_TYPE_LINE3]);
cacti_log(__FUNCTION__ . ' #AREA/LINEx items: ' . $_count, true, 'AGGREGATE', POLLER_VERBOSITY_DEVDBG);
/* if there's at least one AREA item, this is NOT a pure LINEx graph
* if there's NO AREA item, this IS a pure LINEx graph
* in case we find STACKs, there must be at least one AREA as well, or the graph itself is malformed
* this would fail on a PURE GPRINT/HRULE/VRULE graph as well, but that is malformed, too
*/
$_pure_stacked_graph = ($_count == 0);
}
return $_pure_stacked_graph;
}
/**
* find out, if graph has a STACK
*
* @param int $_local_graph_id - graph to be examined
*
* @return bool - true, if pure STACKed graph
*/
function aggregate_is_stacked_graph(int $_local_graph_id) : bool {
cacti_log(__FUNCTION__ . ' local_graph: ' . $_local_graph_id, true, 'AGGREGATE', POLLER_VERBOSITY_DEVDBG);
$_pure_stacked_graph = false;
$_stacked_graph = false;
if (!empty($_local_graph_id)) {
// fetch all AREA graph items
$_count = db_fetch_cell_prepared('SELECT COUNT(id)
FROM graph_templates_item
WHERE graph_templates_item.local_graph_id = ?
AND graph_templates_item.graph_type_id = ?',
[$_local_graph_id, GRAPH_ITEM_TYPE_STACK]);
cacti_log(__FUNCTION__ . ' #AREA/LINEx items: ' . $_count, true, 'AGGREGATE', POLLER_VERBOSITY_DEVDBG);
/* if there's at least one AREA item, this is NOT a pure LINEx graph
* if there's NO AREA item, this IS a pure LINEx graph
* in case we find STACKs, there must be at least one AREA as well, or the graph itself is malformed
* this would fail on a PURE GPRINT/HRULE/VRULE graph as well, but that is malformed, too
*/
$_stacked_graph = ($_count > 0);
}
return $_stacked_graph;
}
/**
* Converts the graph type of a specific graph item from an old type to a new type.
*
* @param int $_graph_id The ID of the graph whose item type is to be converted.
* @param int $_old_type The current type of the graph item.
* @param int $_new_type The new type to which the graph item should be converted.
*
* @return void
*/
function aggregate_conditional_convert_graph_type(int $_graph_id, int $_old_type, int $_new_type) : void {
cacti_log(__FUNCTION__ . ' called: graph: ' . $_graph_id . ' old item type: ' . $_old_type . ' new item type: ' . $_new_type, true, 'AGGREGATE', POLLER_VERBOSITY_DEVDBG);
if (!empty($_graph_id) && !empty($_old_type)) {
// fetch the first item of requested graph_type
$_graph_item_id = db_fetch_cell_prepared('SELECT id
FROM graph_templates_item AS gti
WHERE gti.local_graph_id = ?
AND gti.graph_type_id = ?
ORDER BY sequence
LIMIT 1',
[$_graph_id, $_old_type]);
// and update it to the new graph_type
db_execute_prepared('UPDATE graph_templates_item
SET graph_templates_item.graph_type_id = ?
WHERE graph_templates_item.id = ?',
[$_new_type, $_graph_item_id]);
}
}
/**
* Changes the graph type of an aggregate graph item based on the provided old and new graph types.
*
* @param int $graph_index The index of the graph item within the aggregate.
* @param int $old_graph_type The current graph type of the item.
* @param int $new_graph_type The desired new graph type for the item.
*
* @return int The resulting graph type after applying the change.
*/
function aggregate_change_graph_type(int $graph_index, int $old_graph_type, int $new_graph_type) : int {
cacti_log(__FUNCTION__ . ' called. Index ' . $graph_index . ' old type ' . $old_graph_type . ' Graph Type: ' . $new_graph_type, true, 'AGGREGATE', POLLER_VERBOSITY_DEVDBG);
/* LEGEND entries and xRULEs stay unchanged
* xRULEs honestly do not make much sense on an aggregated graph, though */
switch ($old_graph_type) {
case GRAPH_ITEM_TYPE_GPRINT:
case GRAPH_ITEM_TYPE_GPRINT_LAST:
case GRAPH_ITEM_TYPE_GPRINT_MIN:
case GRAPH_ITEM_TYPE_GPRINT_MAX:
case GRAPH_ITEM_TYPE_GPRINT_AVERAGE:
case GRAPH_ITEM_TYPE_TEXTALIGN:
case GRAPH_ITEM_TYPE_TIC:
case GRAPH_ITEM_TYPE_COMMENT:
case GRAPH_ITEM_TYPE_HRULE:
case GRAPH_ITEM_TYPE_VRULE:
return $old_graph_type;
}
// this item is eligible to a type change
switch ($new_graph_type) {
case AGGREGATE_GRAPH_TYPE_KEEP:
// keep entry as defined by the Graph
return $old_graph_type;
case GRAPH_ITEM_TYPE_STACK:
/* create an AREA/STACK graph
* pay attention to AREA handling!
* any AREA/STACKed graph needs a base AREA entry
* but e.g. a graph that prints on both negative and positive y-axis may hold two AREAs
* so it's a good idea to keep all AREA entries of the first aggregated elementary graph (index 0)*/
if ($graph_index == 0 &&
($old_graph_type == GRAPH_ITEM_TYPE_STACK ||
$old_graph_type == GRAPH_ITEM_TYPE_LINESTACK ||
$old_graph_type == GRAPH_ITEM_TYPE_LINE1 ||
$old_graph_type == GRAPH_ITEM_TYPE_LINE2 ||
$old_graph_type == GRAPH_ITEM_TYPE_LINE3)) {
// if the graph type is a stack and the item is 1, it must be converted to area
return GRAPH_ITEM_TYPE_AREA;
}
if ($graph_index > 0 && $old_graph_type == GRAPH_ITEM_TYPE_AREA) {
// if the graph type is a stack and the item is 1, it must be converted to area
return GRAPH_ITEM_TYPE_STACK;
}
if ($graph_index == 0 && $old_graph_type == GRAPH_ITEM_TYPE_AREA) {
// don't change (multi-)AREAs on the first graph
return $old_graph_type;
} else {
/* this is either
* - not the first graph, any item type: convert to STACK
* - the first graph and a LINEx item: convert to STACK
*/
return GRAPH_ITEM_TYPE_STACK;
}
/* for a pure LINEx graph,
* this will result in a pure STACKed graph, without any AREA
* we will take care of this at the very end, after adding the last graph to the aggregate, during post-processing
*/
case AGGREGATE_GRAPH_TYPE_KEEP_STACKED:
// Like GRAPH_ITEM_TYPE_STACK but don't convert first item to AREA
if ($graph_index == 0) {
if ($old_graph_type == GRAPH_ITEM_TYPE_STACK) {
return GRAPH_ITEM_TYPE_AREA;
}
return $old_graph_type;
} else {
return GRAPH_ITEM_TYPE_STACK;
}
case AGGREGATE_GRAPH_TYPE_LINE1_STACK:
if ($graph_index == 0) {
return GRAPH_ITEM_TYPE_LINE1;
} else {
return GRAPH_ITEM_TYPE_LINESTACK;
}
case AGGREGATE_GRAPH_TYPE_LINE2_STACK:
if ($graph_index == 0) {
return GRAPH_ITEM_TYPE_LINE2;
} else {
return GRAPH_ITEM_TYPE_LINESTACK;
}
case AGGREGATE_GRAPH_TYPE_LINE3_STACK:
if ($graph_index == 0) {
return GRAPH_ITEM_TYPE_LINE3;
} else {
return GRAPH_ITEM_TYPE_LINESTACK;
}
case GRAPH_ITEM_TYPE_LINE1:
case GRAPH_ITEM_TYPE_LINE2:
case GRAPH_ITEM_TYPE_LINE3:
case GRAPH_ITEM_TYPE_LINESTACK:
default:
return $new_graph_type;
}
}
/**
* duplicate_color_template - duplicate color template
*
* @param int $_color_template_id - id of the base color template
* @param string $color_template_title - title of the duplicated color template
*
* @return void
*/
function duplicate_color_template(int $_color_template_id, string $color_template_title) : void {
cacti_log(__FUNCTION__ . ' called. Color Template Id: ' . $_color_template_id . ' Title: ' . $color_template_title, true, 'AGGREGATE', POLLER_VERBOSITY_DEVDBG);
// fetch data from table color_templates
$color_template = db_fetch_row_prepared('SELECT *
FROM color_templates
WHERE color_template_id = ?',
[$_color_template_id]);
// fetch data from table color_template_items
$color_template_items = db_fetch_assoc_prepared('SELECT *
FROM color_template_items
WHERE color_template_id = ?',
[$_color_template_id]);
$save = [];
// create new entry: color_templates
$save['color_template_id'] = 0;
// substitute the title variable
$save['name'] = str_replace('<template_title>', $color_template['name'], $color_template_title);
cacti_log(__FUNCTION__ . ' called. Id:' . $_color_template_id . ' Title: ' . $color_template_title . ' Replaced: ' . $save['name'], true, 'AGGREGATE', POLLER_VERBOSITY_DEVDBG);
$new_color_template_id = sql_save($save, 'color_templates', 'color_template_id');
// create new entry(s): color_template_items
if (cacti_sizeof($color_template_items)) {
foreach ($color_template_items as $color_template_item) {
$save = [];
$save['color_template_item_id'] = 0;
$save['color_template_id'] = $new_color_template_id;
$save['color_id'] = $color_template_item['color_id'];
$save['sequence'] = $color_template_item['sequence'];
cacti_log(__FUNCTION__ . ' called. Id:' . $new_color_template_id . ' Color: ' . $save['color_id'] . ' sequence: ' . $save['sequence'], true, 'AGGREGATE', POLLER_VERBOSITY_DEBUG);
$new_color_template_item_id = sql_save($save, 'color_template_items', 'color_template_item_id');
}
}
}
/**
* aggregate_cdef_make0 - return the id of a 'Make 0' cdef, create that cdef if necessary
*
* @return int - id of the 'Make 0' cdef
*/
function aggregate_cdef_make0() : int {
include_once(CACTI_PATH_LIBRARY . '/cdef.php');
cacti_log(__FUNCTION__ . ' called', true, 'AGGREGATE', POLLER_VERBOSITY_DEVDBG);
// magic name of new cdef
$magic = '_MAKE 0';
// search the 'magic' cdef
$cdef_id = db_fetch_cell_prepared('SELECT id
FROM cdef
WHERE name = ?',
[$magic]);
if (isset($cdef_id) && $cdef_id > 0) {
return $cdef_id; // hoping, that nobody changed the cdef_items!
}
// create a new cdef entry
$save = [];
$save['id'] = 0;
$save['hash'] = get_hash_cdef(0);
$save['system'] = 1;
$save['name'] = $magic;
// save the cdef itself
$new_cdef_id = sql_save($save, 'cdef');
cacti_log(__FUNCTION__ . ' created new cdef: ' . $new_cdef_id . ' name: ' . $magic, true, 'AGGREGATE', POLLER_VERBOSITY_DEBUG);
// create a new cdef item entry
$save = [];
$save['id'] = 0;
$save['hash'] = get_hash_cdef(0, 'cdef_item');
$save['cdef_id'] = $new_cdef_id;
$save['sequence'] = 1;
$save['type'] = 6; // this will be replaced by a define as soon as it exists for a pure text field
$save['value'] = 'CURRENT_DATA_SOURCE,0,*';
// save the cdef item, there's only one!
$cdef_item_id = sql_save($save, 'cdef_items');
cacti_log(__FUNCTION__ . ' created new cdef item: ' . $cdef_item_id, true, 'AGGREGATE', POLLER_VERBOSITY_DEBUG);
return $new_cdef_id;
}
/**
* Check whether an aggregate item has a usable CDEF expression.
*
* @param array<int,array{id:mixed,name:mixed,cdef_text:?string}> $cdefs
* @param int $cdef_id
*
* @return string|null A usable expression, or null when totalling must skip the item
*/
function aggregate_cdef_for_totalling(array $cdefs, int $cdef_id) : ?string {
if (!isset($cdefs[$cdef_id]) || $cdefs[$cdef_id]['cdef_text'] === null || $cdefs[$cdef_id]['cdef_text'] === '') {
return null;
}
return $cdefs[$cdef_id]['cdef_text'];
}
/**
* Validate every target before aggregate_cdef_totalling() performs any writes.
*
* @param array<int,array<string,mixed>> $graph_template_items
* @param array<int,array{id:mixed,name:mixed,cdef_text:?string}> $cdefs
*
* @return array{items:array<int,array{graph_item:array<string,mixed>,cdef_id:int,cdef_name:mixed,cdef_text:string}>,invalid_cdef_id:?int}
*/
function aggregate_prepare_cdef_totalling(array $graph_template_items, array $cdefs) : array {
$items = [];
foreach ($graph_template_items as $graph_template_item) {
$cdef_id = (int) $graph_template_item['cdef_id'];
$cdef_text = aggregate_cdef_for_totalling($cdefs, $cdef_id);
if ($cdef_text === null) {
return ['items' => [], 'invalid_cdef_id' => $cdef_id];
}
$items[] = [
'graph_item' => $graph_template_item,
'cdef_id' => $cdef_id,
'cdef_name' => $cdefs[$cdef_id]['name'],
'cdef_text' => $cdef_text,
];
}
return ['items' => $items, 'invalid_cdef_id' => null];
}
/**
* Creates and applies aggregate totalling CDEFs.
*
* @param int $_new_graph_id ID of the new graph.
* @param int $_graph_item_sequence First graph item sequence to total.
* @param int $_total_type Type of totalling required.
*
* @return bool True when every totalling CDEF was applied.
*/
function aggregate_cdef_totalling(int $_new_graph_id, int $_graph_item_sequence, int $_total_type) : bool {
include_once(CACTI_PATH_LIBRARY . '/cdef.php');
cacti_log(__FUNCTION__ . ' called. Working on Graph: ' . $_new_graph_id . ' sequence: ' . $_graph_item_sequence . ' totalling: ' . $_total_type, true, 'AGGREGATE', POLLER_VERBOSITY_DEVDBG);
$graph_template_items = [];
// take graph item data for the totalling items
if (!empty($_new_graph_id)) {
/* the totalling cdef must be applied to every item from this sequence on,
including the GPRINT legend items, or the legend prints untotalled
single data source values (see #7216) */
$graph_template_items = db_fetch_assoc_prepared('SELECT id, cdef_id
FROM graph_templates_item
WHERE local_graph_id = ?
AND sequence >= ?
ORDER BY sequence',
[$_new_graph_id, $_graph_item_sequence]);
if ($graph_template_items === false) {
cacti_log(__FUNCTION__ . ' could not load graph items.', true, 'AGGREGATE');
return false;
}
cacti_log(__FUNCTION__ . " totalling query: graph=$_new_graph_id seq=$_graph_item_sequence", true, 'AGGREGATE', POLLER_VERBOSITY_DEBUG);
}
// now get the list of cdefs
$_cdefs = db_fetch_assoc_prepared('SELECT id, name FROM cdef ORDER BY id', []);
$cdefs = [];
if ($_cdefs === false) {
cacti_log(__FUNCTION__ . ' could not load CDEFs.', true, 'AGGREGATE');
return false;
}
// build cdefs array to allow for indexing on cdef_id
foreach ($_cdefs as $_cdef) {
$cdefs[$_cdef['id']] = [
'id' => $_cdef['id'],
'name' => $_cdef['name'],
'cdef_text' => get_cdef($_cdef['id']),
];
}
// add pseudo CDEF for CURRENT_DATA_SOURCE, in case CDEF=NONE
// we then may apply the standard CDEF procedure to create a new CDEF
$cdefs[0] = [
'id' => 0,
'name' => 'Items',
'cdef_text' => 'CURRENT_DATA_SOURCE',
];
// new CDEF(s) are required!
$prepared = aggregate_prepare_cdef_totalling($graph_template_items, $cdefs);
if ($prepared['invalid_cdef_id'] !== null) {
cacti_log(__FUNCTION__ . ' could not apply totals due to invalid or empty CDEF id ' . $prepared['invalid_cdef_id'], true, 'AGGREGATE');
return false;
}
$totalling_items = $prepared['items'];
$num_items = cacti_sizeof($totalling_items);
if ($num_items > 0) {
$i = 0;
foreach ($totalling_items as $totalling_item) {
// current cdef
$graph_template_item = $totalling_item['graph_item'];
$cdef_id = $totalling_item['cdef_id'];
$cdef_name = $totalling_item['cdef_name'];
$cdef_text = $totalling_item['cdef_text'];
cacti_log(__FUNCTION__ . ' cdef id: ' . $cdef_id . ' name: ' . $cdef_name . ' value: ' . $cdef_text, true, 'AGGREGATE', POLLER_VERBOSITY_DEBUG);
// new cdef
$new_cdef_text = 'INVALID'; // in case sth goes wrong
switch ($_total_type) {
case AGGREGATE_TOTAL_TYPE_SIMILAR:
$new_cdef_text = str_replace('CURRENT_DATA_SOURCE', 'SIMILAR_DATA_SOURCES_NODUPS', $cdef_text);
break;
case AGGREGATE_TOTAL_TYPE_ALL:
$new_cdef_text = str_replace('CURRENT_DATA_SOURCE', 'ALL_DATA_SOURCES_NODUPS', $cdef_text);
break;
}
// is the new cdef already present?
$new_cdef_id = '';
foreach ($cdefs as $cdef) {
cacti_log(__FUNCTION__ . ' verify matching cdef: ' . $cdef['id'] . ' on: ' . $cdef['cdef_text'], true, 'AGGREGATE', POLLER_VERBOSITY_DEBUG);
if ($cdef['cdef_text'] === $new_cdef_text) {
$new_cdef_id = $cdef['id'];
cacti_log(__FUNCTION__ . ' matching cdef: ' . $new_cdef_id, true, 'AGGREGATE', POLLER_VERBOSITY_DEBUG);
// leave on first match
break;
}
}
// in case, we have NO match
if (empty($new_cdef_id)) {
// create a new cdef entry
$save = [];
$save['id'] = 0;
$save['hash'] = get_hash_cdef(0);
$save['system'] = 1;
$new_cdef_name = 'INVALID ' . $cdef_name; // in case anything goes wrong
switch ($_total_type) {
case AGGREGATE_TOTAL_TYPE_SIMILAR:
$new_cdef_name = '_AGGREGATE SIMILAR ' . $cdef_name;
break;
case AGGREGATE_TOTAL_TYPE_ALL:
$new_cdef_name = '_AGGREGATE ALL ' . $cdef_name;
break;
}
$save['name'] = $new_cdef_name;
// save the cdef itself
$new_cdef_id = sql_save($save, 'cdef');
cacti_log(__FUNCTION__ . ' created new cdef: ' . $new_cdef_id . ' name: ' . $new_cdef_name . ' value: ' . $new_cdef_text, true, 'AGGREGATE', POLLER_VERBOSITY_DEBUG);
// create a new cdef item entry
$save = [];
$save['id'] = 0;
$save['hash'] = get_hash_cdef(0, 'cdef_item');
$save['cdef_id'] = $new_cdef_id;
$save['sequence'] = 1;
$save['type'] = 6; // this will be replaced by a define as soon as it exists for a pure text field
$save['value'] = $new_cdef_text;
// save the cdef item, there's only one!
$cdef_item_id = sql_save($save, 'cdef_items');
cacti_log(__FUNCTION__ . ' created new cdef item: ' . $cdef_item_id, true, 'AGGREGATE', POLLER_VERBOSITY_DEBUG);
// now extend the cdef array to learn the newly entered cdef for the next loop
$cdefs[$new_cdef_id]['id'] = $new_cdef_id;
$cdefs[$new_cdef_id]['name'] = $new_cdef_name;
$cdefs[$new_cdef_id]['cdef_text'] = $new_cdef_text;
}
// now that we have a new cdef id, update record accordingly
db_execute_prepared('UPDATE graph_templates_item
SET cdef_id = ?
WHERE id = ?',
[$new_cdef_id, $graph_template_item['id']]);
cacti_log(__FUNCTION__ . ' updated new cdef id: ' . $new_cdef_id . ' for item: ' . $graph_template_item['id'], true, 'AGGREGATE', POLLER_VERBOSITY_DEBUG);
}
}
return true;
}
/** auto_hr - set a new hr when items are skipped
*
* @param array $s - array of skipped items
* @param array $h - array of items with HR
*
* @return array - array with new HR markers
*/
function auto_hr(array $s, array $h) : array {
cacti_log(__FUNCTION__ . ' called', true, 'AGGREGATE', POLLER_VERBOSITY_DEVDBG);
// start at end of array, both arrays are from 1 .. cacti_count(array)
$i = cacti_count($h);
// make sure, that last item always has a HR, even if template does not have any
$h[$i] = true;
do {
// if skipped item has a HR
if (isset($s[$i]) && ($s[$i] > 0) && $h[$i]) {
// set previous item (if any) to HR
if (isset($h[$i - 1])) {
$h[$i - 1] = $h[$i];
}
}
} while ($i-- > 0);
return $h;
}
/** auto_title - generate a title suggested to the user
*
* @param int $_local_graph_id - the id of the graph stanza
*
* @return string - the title
*/
function auto_title(int $_local_graph_id) : string {
cacti_log(__FUNCTION__ . ' called. Local Graph Id: ' . $_local_graph_id, true, 'AGGREGATE', POLLER_VERBOSITY_DEVDBG);
// apply given graph title, but drop host and query variables
$graph_title = 'Aggregate ';
$graph_title .= db_fetch_cell_prepared('SELECT title
FROM graph_templates_graph
WHERE local_graph_id = ?',
[$_local_graph_id]);
cacti_log('title:' . $graph_title, true, 'AGGREGATE', POLLER_VERBOSITY_DEBUG);
// remove all '- |query_*|' occurrences
$pattern = '/-?\s+\|query_\w+\|/';
$graph_title = preg_replace($pattern, '', $graph_title);
cacti_log('title:' . $graph_title, true, 'AGGREGATE', POLLER_VERBOSITY_DEBUG);
// remove all '- |host_*|' occurrences
$pattern = '/-?\s+\|host_\w+\|/';
$graph_title = preg_replace($pattern, '', (string) $graph_title);
cacti_log('title:' . $graph_title, true, 'AGGREGATE', POLLER_VERBOSITY_DEBUG);
return $graph_title;
}
/**
* Removes multiple aggregate graphs and their associated items from the database.
*
* @param array $graphs An array of graph IDs to be removed.
*
* @return void
*/
function api_aggregate_remove_multi(array $graphs) : void {
include_once(CACTI_PATH_LIBRARY . '/api_graph.php');
if (cacti_sizeof($graphs)) {
foreach ($graphs as $graph) {
$ag = db_fetch_cell_prepared('SELECT id
FROM aggregate_graphs
WHERE local_graph_id = ?',
[$graph]);
db_execute_prepared('DELETE FROM aggregate_graphs
WHERE local_graph_id = ?',
[$graph]);
db_execute_prepared('DELETE FROM aggregate_graphs_items
WHERE aggregate_graph_id = ?',
[$ag]);
db_execute_prepared('DELETE FROM aggregate_graphs_graph_item
WHERE aggregate_graph_id = ?',
[$ag]);
}
api_graph_remove_multi($graphs);
}
}
// To-do remove orphaned elements
/**
* Prunes orphaned graphs and their associated items from the database.
*
* @param int $local_graph_id Optional. The ID of a specific local graph to prune. If greater than 0,
* only the specified local graph and its associated items will be pruned.
* Defaults to 0, which means all orphaned graphs will be pruned.
*
* @return void
*/
function aggregate_prune_graphs(int $local_graph_id = 0) : void {
$aggregate_graphs = [];
$local_graph_ids = [];
$sql_where = '';
$sql_params = [];
if ($local_graph_id > 0) {
$sql_where = 'AND pagi.local_graph_id = ?';
$sql_params[] = $local_graph_id;
}
// Phase 1 Aggregate Graphs
$pruneme = db_fetch_assoc_prepared("SELECT
pagi.aggregate_graph_id,
pagi.local_graph_id
FROM aggregate_graphs_items AS pagi
LEFT JOIN graph_local AS gl
ON pagi.local_graph_id = gl.id
WHERE gl.id IS NULL
$sql_where",
$sql_params);
foreach ($pruneme as $p) {
$local_graph_ids[$p['local_graph_id']] = $p['local_graph_id'];
$aggregate_graphs[$p['aggregate_graph_id']] = $p['aggregate_graph_id'];
}
if (cacti_sizeof($local_graph_ids)) {
db_execute('DELETE FROM aggregate_graphs_items
WHERE local_graph_id IN (' . implode(',', $local_graph_ids) . ')');
}
if (cacti_sizeof($aggregate_graphs) || $local_graph_id > 0) {
if ($local_graph_id > 0) {
$agg_graph = db_fetch_cell_prepared('SELECT id
FROM aggregate_graphs
WHERE local_graph_id = ?',
[$local_graph_id]);
if (!empty($agg_graph)) {
$aggregate_graphs[$agg_graph] = $agg_graph;
}
}
// Phase 2 - Graphs based upon aggregates
foreach ($aggregate_graphs as $a) {
$graph_id = db_fetch_cell_prepared('SELECT local_graph_id
FROM aggregate_graphs
WHERE id = ?',
[$a]);
$bad_graph_items = array_rekey(db_fetch_assoc_prepared('SELECT gti.id
FROM graph_templates_item AS gti
LEFT JOIN data_template_rrd AS dtr
ON gti.task_item_id=dtr.id
WHERE gti.local_graph_id = ?
AND dtr.id IS NULL
AND gti.graph_type_id IN (4,5,6,7,8,9)', [$graph_id]), 'id', 'id');
if (cacti_sizeof($bad_graph_items)) {
db_execute('DELETE FROM graph_templates_item
WHERE id IN (' . implode(',', $bad_graph_items) . ')
AND local_graph_id > 0');
}
}
}
}
/**
* Converts a list of graphs to a specific graph template.
*
* @param array $graphs An array of graph IDs to be converted.
*
* @return void
*/
function api_aggregate_convert_to_graph(array $graphs) : void {
if (cacti_sizeof($graphs)) {
foreach ($graphs as $graph) {
$ag = db_fetch_cell_prepared('SELECT id
FROM aggregate_graphs
WHERE local_graph_id = ?',
[$graph]);
db_execute_prepared('DELETE FROM aggregate_graphs
WHERE local_graph_id = ?',
[$graph]);
db_execute_prepared('DELETE FROM aggregate_graphs_items
WHERE aggregate_graph_id = ?',
[$ag]);
db_execute_prepared('DELETE FROM aggregate_graphs_graph_item
WHERE aggregate_graph_id = ?',
[$ag]);
$graph_template = db_fetch_cell_prepared('SELECT MAX(gl.graph_template_id)
FROM graph_local AS gl
INNER JOIN graph_templates_item AS gti
ON gl.id = gti.local_graph_id
INNER JOIN data_template_rrd AS dtr
ON dtr.id = gti.task_item_id
WHERE dtr.id IN (
SELECT DISTINCT dtr.id FROM graph_local AS gl
INNER JOIN graph_templates_item AS gti
ON gti.local_graph_id = gl.id
INNER JOIN data_template_rrd AS dtr
ON dtr.id = gti.task_item_id
WHERE gl.id = ?
)',
[$graph]);
if ($graph_template > 0) {
db_execute_prepared('UPDATE graph_local
SET graph_template_id = ?
WHERE id = ?',
[$graph_template, $graph]);
}
}
}
}