Deploy the revision-aware XL-Vask import and guarded autopilot infrastructure. Automatic actions remain fail-closed pending production readiness, calibration, dry-run, and canary gates.
402 lines
20 KiB
PHP
402 lines
20 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
/**
|
|
* Ensures additive schema for XL Vask usage-log review state.
|
|
*/
|
|
class xlvask_usage_logs_schema_bootstrap
|
|
{
|
|
private static bool $initialized = false;
|
|
|
|
public static function ensureTables(): void
|
|
{
|
|
if (self::$initialized) {
|
|
return;
|
|
}
|
|
|
|
global $db;
|
|
|
|
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
|
return;
|
|
}
|
|
|
|
if (!self::tableExists($db, 'xlvask_usage_logs')) {
|
|
return;
|
|
}
|
|
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_at', 'DATETIME NULL AFTER WashItems');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_by', 'INT NULL AFTER ignored_at');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_reason', 'TEXT NULL AFTER ignored_by');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_total_net_amount', 'DECIMAL(12,2) NULL AFTER ignored_reason');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_primary_product_name', 'VARCHAR(255) NULL AFTER cached_total_net_amount');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_amount_at', 'DATETIME NULL AFTER cached_primary_product_name');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_hash', 'CHAR(64) NULL AFTER cached_amount_at');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_revision', 'VARCHAR(128) NULL AFTER source_hash');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_observed_at', 'DATETIME NULL AFTER source_revision');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_stable_since', 'DATETIME NULL AFTER source_observed_at');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_observation_count', 'INT NOT NULL DEFAULT 0 AFTER source_stable_since');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'import_state', "VARCHAR(24) NOT NULL DEFAULT 'unchanged' AFTER source_observation_count");
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'resolution_state', "VARCHAR(32) NOT NULL DEFAULT 'needs_review' AFTER import_state");
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'certainty', "VARCHAR(16) NOT NULL DEFAULT 'none' AFTER resolution_state");
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'planned_action', "VARCHAR(32) NOT NULL DEFAULT 'none' AFTER certainty");
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'state_reason', 'TEXT NULL AFTER planned_action');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'expected_version', 'INT NOT NULL DEFAULT 1 AFTER state_reason');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'last_run_id', 'BIGINT NULL AFTER expected_version');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'last_evaluated_at', 'DATETIME NULL AFTER last_run_id');
|
|
self::ensureAutomationTables($db);
|
|
|
|
self::$initialized = true;
|
|
}
|
|
|
|
private static function ensureAutomationTables(object $db): void
|
|
{
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_suggestions` (
|
|
`id` INT NOT NULL AUTO_INCREMENT,
|
|
`usage_log_id` INT NOT NULL,
|
|
`wash_id` VARCHAR(128) NOT NULL,
|
|
`signature_hash` CHAR(64) NOT NULL,
|
|
`signature_json` LONGTEXT NULL,
|
|
`action` VARCHAR(32) NOT NULL,
|
|
`status` VARCHAR(32) NOT NULL DEFAULT 'suggested',
|
|
`confidence` DECIMAL(5,4) NOT NULL DEFAULT 0.0000,
|
|
`source` VARCHAR(32) NOT NULL DEFAULT 'deterministic',
|
|
`matched_order_id` INT NULL,
|
|
`created_order_id` INT NULL,
|
|
`proposed_order_json` LONGTEXT NULL,
|
|
`candidate_order_json` LONGTEXT NULL,
|
|
`reason` TEXT NULL,
|
|
`created_by` INT NULL,
|
|
`decided_by` INT NULL,
|
|
`decided_at` DATETIME NULL,
|
|
`executed_at` DATETIME NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
KEY `idx_xlvask_automation_usage` (`usage_log_id`),
|
|
KEY `idx_xlvask_automation_wash` (`wash_id`),
|
|
KEY `idx_xlvask_automation_signature` (`signature_hash`),
|
|
KEY `idx_xlvask_automation_status` (`status`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
|
|
foreach ([
|
|
'run_id' => 'BIGINT NULL AFTER usage_log_id',
|
|
'policy_version' => "VARCHAR(64) NOT NULL DEFAULT 'xlvask-autopilot-v1' AFTER source",
|
|
'model' => 'VARCHAR(96) NULL AFTER policy_version',
|
|
'model_confidence' => 'DECIMAL(5,4) NULL AFTER model',
|
|
'calibrated_probability' => 'DECIMAL(5,4) NULL AFTER model_confidence',
|
|
'certainty' => "VARCHAR(16) NOT NULL DEFAULT 'uncertain' AFTER calibrated_probability",
|
|
'evidence_json' => 'LONGTEXT NULL AFTER certainty',
|
|
'contradictions_json' => 'LONGTEXT NULL AFTER evidence_json',
|
|
'risk_flags_json' => 'LONGTEXT NULL AFTER contradictions_json',
|
|
'plan_steps_json' => 'LONGTEXT NULL AFTER risk_flags_json',
|
|
'expected_version' => 'INT NULL AFTER plan_steps_json',
|
|
'input_hash' => 'CHAR(64) NULL AFTER expected_version',
|
|
] as $column => $definition) {
|
|
self::addColumnIfMissing($db, 'xlvask_automation_suggestions', $column, $definition);
|
|
}
|
|
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_feedback` (
|
|
`id` INT NOT NULL AUTO_INCREMENT,
|
|
`usage_log_id` INT NULL,
|
|
`wash_id` VARCHAR(128) NULL,
|
|
`signature_hash` CHAR(64) NOT NULL,
|
|
`signature_json` LONGTEXT NULL,
|
|
`action` VARCHAR(32) NOT NULL,
|
|
`decision` VARCHAR(32) NOT NULL,
|
|
`order_id` INT NULL,
|
|
`reason` TEXT NULL,
|
|
`created_by` INT NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
KEY `idx_xlvask_feedback_signature_action` (`signature_hash`, `action`),
|
|
KEY `idx_xlvask_feedback_usage` (`usage_log_id`),
|
|
KEY `idx_xlvask_feedback_decision` (`decision`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_openai_cache` (
|
|
`id` INT NOT NULL AUTO_INCREMENT,
|
|
`cache_key` CHAR(64) NOT NULL,
|
|
`schema_name` VARCHAR(96) NOT NULL,
|
|
`input_json` LONGTEXT NOT NULL,
|
|
`result_json` LONGTEXT NOT NULL,
|
|
`hits` INT NOT NULL DEFAULT 0,
|
|
`last_hit_at` DATETIME NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`),
|
|
KEY `idx_xlvask_openai_cache_schema` (`schema_name`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_autopilot_runs` (
|
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
|
`idempotency_key` CHAR(64) NOT NULL,
|
|
`mode` VARCHAR(16) NOT NULL,
|
|
`status` VARCHAR(24) NOT NULL DEFAULT 'queued',
|
|
`phase` VARCHAR(32) NOT NULL DEFAULT 'queued',
|
|
`date_from` DATE NULL,
|
|
`date_to` DATE NULL,
|
|
`force_refetch` TINYINT(1) NOT NULL DEFAULT 0,
|
|
`requested_ids_json` LONGTEXT NULL,
|
|
`requested_limit` INT NOT NULL DEFAULT 500,
|
|
`request_hash` CHAR(64) NOT NULL,
|
|
`scope_hall_ids_json` LONGTEXT NOT NULL,
|
|
`processed` INT NOT NULL DEFAULT 0,
|
|
`total` INT NOT NULL DEFAULT 0,
|
|
`summary_json` LONGTEXT NULL,
|
|
`warning` TEXT NULL,
|
|
`error` TEXT NULL,
|
|
`lease_token` CHAR(36) NULL,
|
|
`lease_expires_at` DATETIME NULL,
|
|
`attempt_count` INT NOT NULL DEFAULT 0,
|
|
`max_attempts` INT NOT NULL DEFAULT 3,
|
|
`next_attempt_at` DATETIME NULL,
|
|
`created_by` INT NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
`started_at` DATETIME NULL,
|
|
`finished_at` DATETIME NULL,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `uniq_xlvask_autopilot_run_idempotency` (`idempotency_key`),
|
|
KEY `idx_xlvask_autopilot_run_status` (`status`, `created_at`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'lease_token', 'CHAR(36) NULL AFTER error');
|
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'lease_expires_at', 'DATETIME NULL AFTER lease_token');
|
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'scope_hall_ids_json', "LONGTEXT NULL AFTER requested_ids_json");
|
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'requested_limit', 'INT NOT NULL DEFAULT 500 AFTER requested_ids_json');
|
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'request_hash', "CHAR(64) NOT NULL DEFAULT '' AFTER requested_limit");
|
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'attempt_count', 'INT NOT NULL DEFAULT 0 AFTER lease_expires_at');
|
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'max_attempts', 'INT NOT NULL DEFAULT 3 AFTER attempt_count');
|
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'next_attempt_at', 'DATETIME NULL AFTER max_attempts');
|
|
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_autopilot_run_items` (
|
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
|
`run_id` BIGINT NOT NULL,
|
|
`usage_log_id` INT NULL,
|
|
`wash_id` VARCHAR(128) NULL,
|
|
`import_state` VARCHAR(24) NOT NULL DEFAULT 'unchanged',
|
|
`resolution_state` VARCHAR(32) NOT NULL DEFAULT 'needs_review',
|
|
`certainty` VARCHAR(16) NOT NULL DEFAULT 'none',
|
|
`planned_action` VARCHAR(32) NOT NULL DEFAULT 'none',
|
|
`source_hash` CHAR(64) NULL,
|
|
`expected_version` INT NULL,
|
|
`result_json` LONGTEXT NULL,
|
|
`error` TEXT NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `uniq_xlvask_autopilot_run_usage` (`run_id`, `usage_log_id`),
|
|
KEY `idx_xlvask_autopilot_run_item_state` (`run_id`, `resolution_state`, `certainty`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'updated_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
|
|
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_audit` (
|
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
|
`run_id` BIGINT NULL,
|
|
`usage_log_id` INT NULL,
|
|
`wash_id` VARCHAR(128) NULL,
|
|
`event_type` VARCHAR(48) NOT NULL,
|
|
`action` VARCHAR(32) NULL,
|
|
`policy_version` VARCHAR(64) NOT NULL,
|
|
`input_hash` CHAR(64) NULL,
|
|
`source_revision` VARCHAR(128) NULL,
|
|
`expected_version` INT NULL,
|
|
`before_json` LONGTEXT NULL,
|
|
`after_json` LONGTEXT NULL,
|
|
`evidence_json` LONGTEXT NULL,
|
|
`actor_id` INT NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
KEY `idx_xlvask_audit_usage` (`usage_log_id`, `created_at`),
|
|
KEY `idx_xlvask_audit_run` (`run_id`, `created_at`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_calibrations` (
|
|
`id` INT NOT NULL AUTO_INCREMENT,
|
|
`policy_version` VARCHAR(64) NOT NULL,
|
|
`segment_key` VARCHAR(191) NOT NULL,
|
|
`precision_value` DECIMAL(7,6) NOT NULL,
|
|
`wilson_lower_bound` DECIMAL(7,6) NOT NULL,
|
|
`holdout_examples` INT NOT NULL,
|
|
`segment_examples` INT NOT NULL,
|
|
`contradictions` INT NOT NULL DEFAULT 0,
|
|
`calibrated_probability` DECIMAL(7,6) NOT NULL,
|
|
`artifact_hash` CHAR(64) NOT NULL,
|
|
`active` TINYINT(1) NOT NULL DEFAULT 0,
|
|
`backtest_json` LONGTEXT NULL,
|
|
`created_by` INT NULL,
|
|
`activated_by` INT NULL,
|
|
`activated_at` DATETIME NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `uniq_xlvask_calibration_artifact` (`artifact_hash`),
|
|
KEY `idx_xlvask_calibration_lookup` (`policy_version`, `segment_key`, `active`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'backtest_json', 'LONGTEXT NULL AFTER active');
|
|
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'created_by', 'INT NULL AFTER backtest_json');
|
|
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'activated_by', 'INT NULL AFTER created_by');
|
|
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'activated_at', 'DATETIME NULL AFTER activated_by');
|
|
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_calibration_labels` (
|
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
|
`suggestion_id` INT NOT NULL,
|
|
`outcome` VARCHAR(16) NOT NULL,
|
|
`adjudicated_by` INT NOT NULL,
|
|
`adjudicated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `uniq_xlvask_calibration_label_suggestion` (`suggestion_id`),
|
|
KEY `idx_xlvask_calibration_label_time` (`adjudicated_at`, `id`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
|
|
// Immutable adjudication events supersede the legacy one-row-per-suggestion table.
|
|
// The nullable legacy id supports an idempotent, non-destructive backfill.
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_calibration_label_events` (
|
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
|
`suggestion_id` INT NOT NULL,
|
|
`outcome` VARCHAR(16) NOT NULL,
|
|
`adjudicated_by` INT NOT NULL,
|
|
`adjudicated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
`legacy_label_id` BIGINT NULL,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `uniq_xlvask_calibration_legacy_label` (`legacy_label_id`),
|
|
KEY `idx_xlvask_calibration_event_suggestion` (`suggestion_id`, `id`),
|
|
KEY `idx_xlvask_calibration_event_time` (`adjudicated_at`, `id`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
$db->query(
|
|
"INSERT IGNORE INTO xlvask_automation_calibration_label_events
|
|
(suggestion_id, outcome, adjudicated_by, adjudicated_at, legacy_label_id)
|
|
SELECT suggestion_id, outcome, adjudicated_by, adjudicated_at, id
|
|
FROM xlvask_automation_calibration_labels"
|
|
);
|
|
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_decision_previews` (
|
|
`id` CHAR(36) NOT NULL,
|
|
`selection_hash` CHAR(64) NOT NULL,
|
|
`action` VARCHAR(32) NOT NULL,
|
|
`payload_json` LONGTEXT NOT NULL,
|
|
`created_by` INT NULL,
|
|
`expires_at` DATETIME NOT NULL,
|
|
`applied_at` DATETIME NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
KEY `idx_xlvask_preview_expiry` (`expires_at`, `applied_at`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
}
|
|
|
|
public static function washIdUniquenessReady(): bool
|
|
{
|
|
global $db;
|
|
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
|
return false;
|
|
}
|
|
|
|
return self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id');
|
|
}
|
|
|
|
/**
|
|
* Explicit activation migration. Never call this from constructors, GETs, or normal runs.
|
|
* Returns false without modifying conflicting records when duplicate wash IDs exist.
|
|
*/
|
|
public static function applyWashIdUniquenessMigration(): bool
|
|
{
|
|
global $db;
|
|
if (!self::tableExists($db, 'orders') || !self::columnExists($db, 'orders', 'wash_id')) {
|
|
return false;
|
|
}
|
|
|
|
if (self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id')) {
|
|
return true;
|
|
}
|
|
|
|
$duplicates = $db->query(
|
|
"SELECT LOWER(TRIM(`wash_id`)) normalized_wash_id FROM `orders`
|
|
WHERE `wash_id` IS NOT NULL AND TRIM(`wash_id`) <> ''
|
|
GROUP BY LOWER(TRIM(`wash_id`)) HAVING COUNT(*) > 1 LIMIT 1"
|
|
);
|
|
if ($duplicates !== false && is_object($duplicates) && (int)$duplicates->num_rows === 0) {
|
|
if (!self::columnExists($db, 'orders', 'xlvask_normalized_wash_id')) {
|
|
$db->query(
|
|
"ALTER TABLE `orders` ADD COLUMN `xlvask_normalized_wash_id` VARCHAR(128)
|
|
GENERATED ALWAYS AS (NULLIF(LOWER(TRIM(`wash_id`)), '')) STORED"
|
|
);
|
|
}
|
|
$db->query(
|
|
"ALTER TABLE `orders` ADD UNIQUE KEY `uniq_orders_xlvask_wash_id` (`xlvask_normalized_wash_id`)"
|
|
);
|
|
return self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id');
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static function addColumnIfMissing(object $db, string $table, string $column, string $definition): void
|
|
{
|
|
if (!self::columnExists($db, $table, $column)) {
|
|
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
|
|
}
|
|
}
|
|
|
|
private static function tableExists(object $db, string $table): bool
|
|
{
|
|
$table = self::escapeIdentifier($table);
|
|
$result = $db->query("SHOW TABLES LIKE '{$table}'");
|
|
|
|
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
|
return false;
|
|
}
|
|
|
|
return (int)$result->num_rows > 0;
|
|
}
|
|
|
|
private static function columnExists(object $db, string $table, string $column): bool
|
|
{
|
|
$table = self::escapeIdentifier($table);
|
|
$column = self::escapeIdentifier($column);
|
|
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
|
|
|
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
|
return false;
|
|
}
|
|
|
|
return (int)$result->num_rows > 0;
|
|
}
|
|
|
|
private static function indexExists(object $db, string $table, string $index): bool
|
|
{
|
|
if (!self::tableExists($db, $table)) {
|
|
return false;
|
|
}
|
|
|
|
$table = self::escapeIdentifier($table);
|
|
$index = self::escapeIdentifier($index);
|
|
$result = $db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = '{$index}'");
|
|
return $result !== false && is_object($result) && (int)$result->num_rows > 0;
|
|
}
|
|
|
|
private static function escapeIdentifier(string $value): string
|
|
{
|
|
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
|
|
}
|
|
}
|