update_gen.php 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  1. #!/usr/bin/env php
  2. <?php
  3. /**
  4. * SQL Update Script Generator
  5. * Generates SQL update scripts
  6. *
  7. * Usage: php script.php MODULE_NAME VERSION [--old-install=file] [--new-install=file] [--old-enable=file] [--new-enable=file]
  8. */
  9. class SQLUpdateChecker {
  10. private $moduleName;
  11. private $version;
  12. private $updatePath;
  13. private $apiKey;
  14. private $results = [];
  15. private $inputFiles = [];
  16. const ENGINE = 'openai'; // 'deepseek' or 'openai'
  17. const API_ENDPOINT = 'https://api.openai.com/v1/chat/completions';
  18. const MODEL_VER = 5;
  19. // const MODEL = 'gpt-4-turbo';
  20. const MODEL = 'gpt-5.6-terra'; // 'gpt-5-mini';
  21. const MAX_TOKENS_PER_REQUEST = 140000; // Conservative limit for model context // 120000 for gpt4
  22. const MAX_TOKENS_GPT5 = 9000; // can be increased if "finish_reason: length" error occured
  23. const MAX_TOKENS = 4000;
  24. const TOKENS_PER_CHAR = 0.325; // Approximate tokens per character, to evaluate potential silent truncation
  25. public function __construct($moduleName, $version, $args) {
  26. $this->moduleName = $moduleName;
  27. $this->version = $version;
  28. $this->parseInputFiles($args);
  29. $this->loadApiKey();
  30. $this->findUpdatePath();
  31. }
  32. private function parseInputFiles($args) {
  33. $fileOptions = [
  34. '--old-install' => 'old_install',
  35. '--new-install' => 'new_install',
  36. '--old-enable' => 'old_enable',
  37. '--new-enable' => 'new_enable'
  38. ];
  39. foreach ($args as $arg) {
  40. foreach ($fileOptions as $option => $key) {
  41. if (strpos($arg, $option . '=') === 0) {
  42. $file = substr($arg, strlen($option) + 1);
  43. if (!file_exists($file)) {
  44. $this->error("File not found: $file");
  45. }
  46. $this->inputFiles[$key] = $file;
  47. }
  48. }
  49. }
  50. // Validate: either all files provided or none
  51. $providedCount = count($this->inputFiles);
  52. if ($providedCount > 0 && $providedCount < 4) {
  53. $this->error("Input files must be provided for all or none. Required: --old-install, --new-install, --old-enable, --new-enable");
  54. }
  55. if ($providedCount === 4) {
  56. echo "✓ Using input files:\n";
  57. echo " - Old install.sql: {$this->inputFiles['old_install']}\n";
  58. echo " - New install.sql: {$this->inputFiles['new_install']}\n";
  59. echo " - Old enable.sql: {$this->inputFiles['old_enable']}\n";
  60. echo " - New enable.sql: {$this->inputFiles['new_enable']}\n";
  61. }
  62. }
  63. private function loadApiKey() {
  64. $keyFile = $_SERVER['HOME'] . '/.una_update_script_gen';
  65. if (!file_exists($keyFile)) {
  66. $this->error("API key file not found: $keyFile");
  67. }
  68. $this->apiKey = trim(file_get_contents($keyFile));
  69. if (empty($this->apiKey)) {
  70. $this->error("API key is empty in $keyFile");
  71. }
  72. }
  73. private function findUpdatePath() {
  74. $basePattern = "modules/boonex/{$this->moduleName}/updates/*_{$this->version}";
  75. if ('system' == $this->moduleName)
  76. $basePattern = "upgrade/files/*-{$this->version}";
  77. $paths = glob($basePattern);
  78. if (empty($paths)) {
  79. $this->error("No update folder found matching pattern: $basePattern");
  80. }
  81. if (count($paths) > 1) {
  82. $this->error("Multiple update folders found: " . implode(', ', $paths));
  83. }
  84. $this->updatePath = $paths[0];
  85. echo "✓ Found update path: {$this->updatePath}\n\n";
  86. }
  87. private function error($message) {
  88. echo "ERROR: $message\n";
  89. exit(1);
  90. }
  91. private function readFile($relativePath) {
  92. $fullPath = $this->updatePath . '/' . $relativePath;
  93. if (!file_exists($fullPath)) {
  94. return null;
  95. }
  96. return file_get_contents($fullPath);
  97. }
  98. private function getContentFromFile($fileKey) {
  99. if (!isset($this->inputFiles[$fileKey])) {
  100. return null;
  101. }
  102. $content = file_get_contents($this->inputFiles[$fileKey]);
  103. if ($content === false) {
  104. $this->error("Failed to read file: {$this->inputFiles[$fileKey]}");
  105. }
  106. $content = trim($content);
  107. // Show file stats
  108. $charCount = strlen($content);
  109. $estimatedTokens = (int)($charCount * self::TOKENS_PER_CHAR);
  110. echo " → File: {$this->inputFiles[$fileKey]}\n";
  111. echo " → Size: " . number_format($charCount) . " characters (~" . number_format($estimatedTokens) . " tokens)\n";
  112. return $content;
  113. }
  114. private function getUserInput($prompt) {
  115. echo "\n$prompt\n";
  116. echo "Enter content (end with a line containing only '~~~!'):\n";
  117. echo "(Leave empty to skip this check)\n";
  118. $content = '';
  119. $lineCount = 0;
  120. while (true) {
  121. $line = fgets(STDIN);
  122. if ($line === false) {
  123. $this->error("Failed to read input");
  124. }
  125. if (trim($line) === '~~~!') {
  126. break;
  127. }
  128. $content .= $line;
  129. $lineCount++;
  130. }
  131. $content = trim($content);
  132. if (empty($content)) {
  133. return null; // Signal to skip this check
  134. }
  135. // Check content size
  136. $charCount = strlen($content);
  137. $estimatedTokens = (int)($charCount * self::TOKENS_PER_CHAR);
  138. echo " → Input size: " . number_format($charCount) . " characters (~" . number_format($estimatedTokens) . " tokens)\n";
  139. return $content;
  140. }
  141. private function getContent($prompt, $fileKey) {
  142. // If using file input mode
  143. if (!empty($this->inputFiles)) {
  144. return $this->getContentFromFile($fileKey);
  145. }
  146. // Otherwise use manual input
  147. return $this->getUserInput($prompt);
  148. }
  149. private function estimatePromptTokens($oldContent, $newContent) {
  150. $totalChars = strlen($oldContent) + strlen($newContent) + 3350; // +3350 for prompt text
  151. return (int)($totalChars * self::TOKENS_PER_CHAR);
  152. }
  153. private function validateContentSize($oldContent, $newContent) {
  154. $estimatedTokens = $this->estimatePromptTokens($oldContent, $newContent);
  155. echo " → Estimated total tokens for analysis: ~" . number_format($estimatedTokens) . "\n";
  156. if ($estimatedTokens > self::MAX_TOKENS_PER_REQUEST) {
  157. echo "\n\033[31m";
  158. echo "ERROR: Content is too large for analysis!\n";
  159. echo " Estimated tokens: " . number_format($estimatedTokens) . "\n";
  160. echo " Maximum allowed: " . number_format(self::MAX_TOKENS_PER_REQUEST) . "\n";
  161. echo " \n";
  162. echo " This would result in silent truncation or API errors.\n";
  163. echo " Please consider:\n";
  164. echo " - Breaking the update into smaller parts\n";
  165. echo " - Analyzing only the most critical sections\n";
  166. echo " - Using a different validation approach for large files\n";
  167. echo "\033[0m\n";
  168. echo "\nDo you want to continue anyway? (y/N): ";
  169. $response = trim(fgets(STDIN));
  170. if (strtolower($response) !== 'y') {
  171. echo "Skipping $scriptType analysis.\n";
  172. return false;
  173. }
  174. echo "\033[33m⚠ Proceeding with analysis - results may be incomplete!\033[0m\n\n";
  175. } elseif ($estimatedTokens > self::MAX_TOKENS_PER_REQUEST * 0.8) {
  176. echo "\033[33m ⚠ Warning: Content is approaching token limits (" .
  177. number_format((int)($estimatedTokens / self::MAX_TOKENS_PER_REQUEST * 100)) . "% of max)\033[0m\n";
  178. }
  179. return true;
  180. }
  181. private function callDeepSeek($messages) {
  182. $data = [
  183. 'model' => 'deepseek-chat', // REQUIRED: Model to use
  184. 'messages' => [ // REQUIRED: Conversation messages
  185. [
  186. 'role' => 'user',
  187. 'content' => $messages
  188. ]
  189. ],
  190. 'max_tokens' => 8192, // OPTIONAL: Maximum response length
  191. 'temperature' => 0.1, // OPTIONAL: Lower for more deterministic output
  192. 'top_p' => 0.9, // OPTIONAL: Nucleus sampling
  193. 'stream' => false // OPTIONAL: No streaming for this use case
  194. ];
  195. $headers = [
  196. 'Content-Type: application/json',
  197. 'Authorization: Bearer ' . $this->apiKey
  198. ];
  199. $ch = curl_init();
  200. curl_setopt_array($ch, [
  201. CURLOPT_URL => 'https://api.deepseek.com/chat/completions',
  202. CURLOPT_RETURNTRANSFER => true,
  203. CURLOPT_POST => true,
  204. CURLOPT_POSTFIELDS => json_encode($data),
  205. CURLOPT_HTTPHEADER => $headers,
  206. CURLOPT_TIMEOUT => 30,
  207. ]);
  208. $response = curl_exec($ch);
  209. $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  210. $error = curl_error($ch);
  211. if ($error) {
  212. $this->error("CURL error: $error");
  213. }
  214. if ($httpCode !== 200) {
  215. $this->error("Deepseek API error (HTTP $httpCode): $response");
  216. }
  217. $result = json_decode($response, true);
  218. if (!isset($result['choices'][0]['message']['content'])) {
  219. $this->error("Invalid Deepseek API response: $response");
  220. }
  221. return $result['choices'][0]['message']['content'];
  222. }
  223. private function callOpenAI($messages) {
  224. $data = [
  225. 'model' => self::MODEL,
  226. 'messages' => $messages,
  227. ];
  228. if (self::MODEL_VER == 5) {
  229. $data['max_completion_tokens'] = self::MAX_TOKENS_GPT5; // 8000
  230. }
  231. else {
  232. $data['temperature'] = 0.1;
  233. $data['max_tokens'] = self::MAX_TOKENS;
  234. }
  235. $ch = curl_init(self::API_ENDPOINT);
  236. curl_setopt_array($ch, [
  237. CURLOPT_RETURNTRANSFER => true,
  238. CURLOPT_POST => true,
  239. CURLOPT_HTTPHEADER => [
  240. 'Content-Type: application/json',
  241. 'Authorization: Bearer ' . $this->apiKey
  242. ],
  243. CURLOPT_POSTFIELDS => json_encode($data),
  244. CURLOPT_TIMEOUT => 360
  245. ]);
  246. $response = curl_exec($ch);
  247. $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  248. $error = curl_error($ch);
  249. if ($error) {
  250. $this->error("CURL error: $error");
  251. }
  252. if ($httpCode !== 200) {
  253. $this->error("OpenAI API error (HTTP $httpCode): $response");
  254. }
  255. // echo "LOG RAW RESPONSE: \n--------\n" . $response . "\n--------\n";
  256. $result = json_decode($response, true);
  257. if (!isset($result['choices'][0]['message']['content'])) {
  258. $this->error("Invalid OpenAI API response: $response");
  259. }
  260. if (empty($result['choices'][0]['message']['content'])) {
  261. $this->error("Content in empty. " . (isset($result['choices'][0]['finish_reason']) ? "finish_reason: " . $result['choices'][0]['finish_reason'] : ''));
  262. }
  263. return $result['choices'][0]['message']['content'];
  264. }
  265. private function genSQL($oldContent, $newContent) {
  266. echo "\n🔍 Checking...\n";
  267. if (!$this->validateContentSize($oldContent, $newContent, 'old and new sql')) {
  268. return $this->getSkippedResults();
  269. }
  270. $prompt = <<<PROMPT
  271. You are an expert PHP and SQL validator. Compare old install.sql and new install.sql then create SQL update script with the following criteria.
  272. Criteria:
  273. 1. syntax_correct: SQL syntax must be correct and compatible with MySQL 5.5.3 or higher.
  274. 2. idempotent: generated SQL can be run multiple times without errors/duplicates, it should use IF EXISTS, IF NOT EXISTS, INSERT IGNORE and also deleting the same records before inserting when INSERT IGNORE can't use uniq indexes - it's most used technique to avoid duplicates)
  275. 3. all_changes_included: All changes from new file must be present in update script
  276. 4. no_extra_changes: No extra changes beyond what's in the new file
  277. 5. old_records_deleted: Records in old but not in new are properly deleted
  278. 6. sys_objects_storage_warning: Check if sys_objects_storage table is modified, then add TODO to get correct value for current storage engine
  279. 7. inconsistencies: report logical problems or potential errors
  280. 8. separate_schema: database schema changes must be in separate piece of code.
  281. 9. structure_in_blocks: separate output in blicks with comments, blocks should be separated by table names or groups os simmilar tables (like `sys_options` and `sys_options_categories`)
  282. 10. no_order_update_for_forms_inputs: don't update order field for forms, just set closest one for the updated field.
  283. 11. assume data exists in the db, don't create data that already exists (like pages, blocks, settins, menus, storages, transcoders, etc).
  284. 12. don't split SQL querys into multiple lines.
  285. 13. for icon field in menu add checking for exact old value before updating it.
  286. List of uniq table fields, as table => uniq (or set of fields with plus sign) field pairs:
  287. sys_acl_actions => ID+Module
  288. sys_form_displays => object+display_name
  289. sys_form_display_inputs => display_name+input_name
  290. sys_form_inputs => object+name
  291. sys_form_pre_lists => key
  292. sys_grid_actions => type+name
  293. sys_grid_fields => object+name
  294. sys_localization_categories => Name
  295. sys_localization_keys => Key
  296. sys_localization_languages => Name
  297. sys_localization_strings => IDKey+IDLanguage
  298. sys_modules => name
  299. sys_modules => uri
  300. sys_objects_category => object
  301. sys_objects_chart => object
  302. sys_objects_connection => object
  303. sys_objects_content_info => name
  304. sys_objects_editor => object
  305. sys_objects_embeds => object
  306. sys_objects_file_handlers => object
  307. sys_objects_form => object
  308. sys_objects_grid => object
  309. sys_objects_iconset => object
  310. sys_objects_live_updates => name
  311. sys_objects_menu => object
  312. sys_objects_metatags => object
  313. sys_objects_page => object
  314. sys_objects_page => uri
  315. sys_objects_payments => object
  316. sys_objects_player => object
  317. sys_objects_privacy => object
  318. sys_objects_push => object
  319. sys_objects_recommendation => name
  320. sys_objects_search_extended => object
  321. sys_objects_sms => object
  322. sys_objects_storage => object
  323. sys_objects_transcoder => object
  324. sys_objects_uploader => object
  325. sys_objects_wiki => object
  326. sys_options => name
  327. sys_options_categories => name
  328. sys_options_mixes => name
  329. sys_options_mixes2options => option+mix_id
  330. sys_options_types => name
  331. sys_pages_layouts => name
  332. sys_permalinks => standard+permalink+check
  333. sys_profiles => type+content_id+content_id
  334. sys_profiles_track => profile_id+action
  335. sys_recommendation_criteria => object_id+name
  336. sys_recommendation_data => profile_id+object_id+item_id
  337. sys_search_extended_fields => object+name
  338. sys_search_extended_sorting_fields => object+name+direction
  339. sys_seo_uri_rewrites => uri_orig
  340. sys_seo_uri_rewrites => uri_rewrite
  341. sys_statistics => name
  342. sys_std_pages => name
  343. sys_std_pages_widgets => widget_id+page_id
  344. sys_std_roles => name
  345. sys_std_roles_members => account_id
  346. sys_std_widgets_bookmarks => widget_id+profile_id
  347. Write minimal necessary code. Don't use complex SQL like CASE, WHEN, THEN.
  348. Output must be compatible with MySQL 5.5.3 and MariaDB 5.5.
  349. Make sure that not changed fields aren't updated.
  350. Don't make long delete statements with long condition, simple easy to read approach is better.
  351. OLD install.sql:
  352. ```sql
  353. $oldContent
  354. ```
  355. NEW install.sql:
  356. ```sql
  357. $newContent
  358. ```
  359. Respond with SQL script only, any comments must be added as comments in SQL
  360. PROMPT;
  361. if ('deepseek' == self::ENGINE) {
  362. $response = $this->callDeepSeek($prompt);
  363. } else {
  364. $response = $this->callOpenAI([
  365. ['role' => 'system', 'content' => 'You are MySQL expert validator. Respond only with valid JSON. Main key is "sql" with generated SQL update script as value. Other keys are optional and used to report check results.'],
  366. ['role' => 'user', 'content' => $prompt]
  367. ]);
  368. }
  369. return $this->parseJsonResponse($response, 'sql');
  370. }
  371. private function parseJsonResponse($response, $filename) {
  372. // Try to extract JSON from response
  373. $response = trim($response);
  374. // Remove markdown code blocks if present
  375. $response = preg_replace('/```json\s*/', '', $response);
  376. $response = preg_replace('/```\s*$/', '', $response);
  377. $response = trim($response);
  378. $data = json_decode($response, true);
  379. if (json_last_error() !== JSON_ERROR_NONE) {
  380. $this->error("Failed to parse JSON response for $filename: " . json_last_error_msg() . "\nResponse: $response");
  381. }
  382. return $data;
  383. }
  384. private function getSkippedResults() {
  385. return [
  386. 'syntax_correct' => ['pass' => null, 'details' => 'Skipped due to size constraints'],
  387. 'idempotent' => ['pass' => null, 'details' => 'Skipped due to size constraints'],
  388. 'all_changes_included' => ['pass' => null, 'details' => 'Skipped due to size constraints'],
  389. 'no_extra_changes' => ['pass' => null, 'details' => 'Skipped due to size constraints'],
  390. 'old_records_deleted' => ['pass' => null, 'details' => 'Skipped due to size constraints'],
  391. 'sys_objects_storage_warning' => ['has_changes' => false, 'details' => 'Skipped due to size constraints'],
  392. 'inconsistencies' => ['pass' => null, 'details' => 'Skipped due to size constraints']
  393. ];
  394. }
  395. private function displayResults($results, $title) {
  396. // print_r($results);
  397. if (isset($results['sql'])) {
  398. echo "\nGenerated SQL Update Script:\n";
  399. echo "----------------------------------------\n";
  400. echo mysqlHighlight($results['sql']) . "\n";
  401. echo "----------------------------------------\n";
  402. unset($results['sql']);
  403. }
  404. if (!empty($results)) {
  405. echo "\nOther notes:\n";
  406. echo "----------------------------------------\n";
  407. print_r($results);
  408. echo "----------------------------------------\n";
  409. echo "\n";
  410. }
  411. }
  412. public function run() {
  413. echo "SQL Update Script Checker\n";
  414. echo "Module: {$this->moduleName}\n";
  415. echo "Version: {$this->version}\n";
  416. echo str_repeat("-", 80) . "\n";
  417. $oldInstallSQL = $this->getContent("Enter OLD install.sql content:", 'old_install');
  418. $newInstallSQL = $this->getContent("Enter NEW install.sql content:", 'new_install');
  419. if ($oldInstallSQL === null && $newInstallSQL === null) {
  420. echo "\033[90m⊘ Skipping install.sql check (empty input)\033[0m\n";
  421. }
  422. else {
  423. $results = $this->genSQL($oldInstallSQL, $newInstallSQL);
  424. $this->displayResults($results, "installer.php(script.php) and install.sql(sql.sql) Results");
  425. $this->results['installer.php'] = $results;
  426. }
  427. }
  428. }
  429. // Main execution
  430. if ($argc < 3) {
  431. echo "Usage: php {$argv[0]} MODULE_NAME VERSION [--old-install=file] [--new-install=file] [--old-enable=file] [--new-enable=file]\n";
  432. echo "\nExamples:\n";
  433. echo " Interactive mode:\n";
  434. echo " php {$argv[0]} bx_timeline 15.0.0-A2\n";
  435. echo " php {$argv[0]} system 15.0.0-A2\n\n";
  436. echo " File input mode (not tested!!!):\n";
  437. echo " php {$argv[0]} bx_timeline 14.0.0-RC1 \\\n";
  438. echo " --old-install=/path/to/old_install.sql \\\n";
  439. echo " --new-install=/path/to/new_install.sql \\\n";
  440. echo "Note: All four file options must be provided together, or none at all.\n";
  441. exit(1);
  442. }
  443. try {
  444. $checker = new SQLUpdateChecker($argv[1], $argv[2], array_slice($argv, 3));
  445. $checker->run();
  446. } catch (Exception $e) {
  447. echo "\033[31mERROR: " . $e->getMessage() . "\033[0m\n";
  448. exit(1);
  449. }
  450. function mysqlHighlight(string $sql): string
  451. {
  452. // ANSI
  453. $RESET = "\033[0m";
  454. $KEYWORD = "\033[1;34m"; // blue
  455. $STRING = "\033[0;32m"; // green
  456. $NUMBER = "\033[0;35m"; // magenta
  457. $COMMENT = "\033[0;90m"; // gray
  458. $placeholders = [];
  459. $i = 0;
  460. // 1️⃣ Extract comments
  461. $sql = preg_replace_callback(
  462. '/(--[^\n]*|#.*?$|\/\*[\s\S]*?\*\/)/m',
  463. function ($m) use (&$placeholders, &$i, $COMMENT, $RESET) {
  464. $key = "__C{$i}__";
  465. $placeholders[$key] = $COMMENT . $m[1] . $RESET;
  466. $i++;
  467. return $key;
  468. },
  469. $sql
  470. );
  471. // 2️⃣ Extract strings
  472. $sql = preg_replace_callback(
  473. "/('(?:\\\\'|[^'])*'|\"(?:\\\\\"|[^\"])*\")/",
  474. function ($m) use (&$placeholders, &$i, $STRING, $RESET) {
  475. $key = "__S{$i}__";
  476. $placeholders[$key] = $STRING . $m[1] . $RESET;
  477. $i++;
  478. return $key;
  479. },
  480. $sql
  481. );
  482. // 3️⃣ Numbers
  483. $sql = preg_replace(
  484. '/\b\d+(\.\d+)?\b/',
  485. $NUMBER . '$0' . $RESET,
  486. $sql
  487. );
  488. // 4️⃣ Keywords
  489. $keywords = [
  490. 'SELECT','INSERT','UPDATE','DELETE','FROM','WHERE','JOIN','LEFT','RIGHT',
  491. 'INNER','OUTER','ON','VALUES','SET','INTO','CREATE','TABLE','DROP','ALTER',
  492. 'AND','OR','NOT','NULL','IS','IN','AS','ORDER','BY','GROUP','LIMIT',
  493. 'HAVING','DISTINCT','UNION'
  494. ];
  495. $sql = preg_replace_callback(
  496. '/\b(' . implode('|', $keywords) . ')\b/i',
  497. fn($m) => $KEYWORD . strtoupper($m[1]) . $RESET,
  498. $sql
  499. );
  500. // 5️⃣ Restore strings & comments
  501. return strtr($sql, $placeholders);
  502. }