update_gen.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  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'; // '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. curl_close($ch);
  212. if ($error) {
  213. $this->error("CURL error: $error");
  214. }
  215. if ($httpCode !== 200) {
  216. $this->error("Deepseek API error (HTTP $httpCode): $response");
  217. }
  218. $result = json_decode($response, true);
  219. if (!isset($result['choices'][0]['message']['content'])) {
  220. $this->error("Invalid Deepseek API response: $response");
  221. }
  222. return $result['choices'][0]['message']['content'];
  223. }
  224. private function callOpenAI($messages) {
  225. $data = [
  226. 'model' => self::MODEL,
  227. 'messages' => $messages,
  228. ];
  229. if (self::MODEL_VER == 5) {
  230. $data['max_completion_tokens'] = self::MAX_TOKENS_GPT5; // 8000
  231. }
  232. else {
  233. $data['temperature'] = 0.1;
  234. $data['max_tokens'] = self::MAX_TOKENS;
  235. }
  236. $ch = curl_init(self::API_ENDPOINT);
  237. curl_setopt_array($ch, [
  238. CURLOPT_RETURNTRANSFER => true,
  239. CURLOPT_POST => true,
  240. CURLOPT_HTTPHEADER => [
  241. 'Content-Type: application/json',
  242. 'Authorization: Bearer ' . $this->apiKey
  243. ],
  244. CURLOPT_POSTFIELDS => json_encode($data),
  245. CURLOPT_TIMEOUT => 360
  246. ]);
  247. $response = curl_exec($ch);
  248. $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  249. $error = curl_error($ch);
  250. curl_close($ch);
  251. if ($error) {
  252. $this->error("CURL error: $error");
  253. }
  254. if ($httpCode !== 200) {
  255. $this->error("OpenAI API error (HTTP $httpCode): $response");
  256. }
  257. // echo "LOG RAW RESPONSE: \n--------\n" . $response . "\n--------\n";
  258. $result = json_decode($response, true);
  259. if (!isset($result['choices'][0]['message']['content'])) {
  260. $this->error("Invalid OpenAI API response: $response");
  261. }
  262. if (empty($result['choices'][0]['message']['content'])) {
  263. $this->error("Content in empty. " . (isset($result['choices'][0]['finish_reason']) ? "finish_reason: " . $result['choices'][0]['finish_reason'] : ''));
  264. }
  265. return $result['choices'][0]['message']['content'];
  266. }
  267. private function genSQL($oldContent, $newContent) {
  268. echo "\n🔍 Checking...\n";
  269. if (!$this->validateContentSize($oldContent, $newContent, 'old and new sql')) {
  270. return $this->getSkippedResults();
  271. }
  272. $prompt = <<<PROMPT
  273. 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.
  274. Criteria:
  275. 1. syntax_correct: SQL syntax must be correct and compatible with MySQL 5.5.3 or higher.
  276. 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)
  277. 3. all_changes_included: All changes from new file must be present in update script
  278. 4. no_extra_changes: No extra changes beyond what's in the new file
  279. 5. old_records_deleted: Records in old but not in new are properly deleted
  280. 6. sys_objects_storage_warning: Check if sys_objects_storage table is modified, then add TODO to get correct value for current storage engine
  281. 7. inconsistencies: report logical problems or potential errors
  282. 8. separate_schema: database schema changes must be in separate piece of code.
  283. 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`)
  284. 10. no_order_update_for_forms_inputs: don't update order field for forms, just set closest one for the updated field.
  285. List of uniq table fields, as table => uniq (or set of fields with plus sign) field pairs:
  286. sys_acl_actions => ID+Module
  287. sys_form_displays => object+display_name
  288. sys_form_display_inputs => display_name+input_name
  289. sys_form_inputs => object+name
  290. sys_form_pre_lists => key
  291. sys_grid_actions => type+name
  292. sys_grid_fields => object+name
  293. sys_localization_categories => Name
  294. sys_localization_keys => Key
  295. sys_localization_languages => Name
  296. sys_localization_strings => IDKey+IDLanguage
  297. sys_modules => name
  298. sys_modules => uri
  299. sys_objects_category => object
  300. sys_objects_chart => object
  301. sys_objects_connection => object
  302. sys_objects_content_info => name
  303. sys_objects_editor => object
  304. sys_objects_embeds => object
  305. sys_objects_file_handlers => object
  306. sys_objects_form => object
  307. sys_objects_grid => object
  308. sys_objects_iconset => object
  309. sys_objects_live_updates => name
  310. sys_objects_menu => object
  311. sys_objects_metatags => object
  312. sys_objects_page => object
  313. sys_objects_page => uri
  314. sys_objects_payments => object
  315. sys_objects_player => object
  316. sys_objects_privacy => object
  317. sys_objects_push => object
  318. sys_objects_recommendation => name
  319. sys_objects_search_extended => object
  320. sys_objects_sms => object
  321. sys_objects_storage => object
  322. sys_objects_transcoder => object
  323. sys_objects_uploader => object
  324. sys_objects_wiki => object
  325. sys_options => name
  326. sys_options_categories => name
  327. sys_options_mixes => name
  328. sys_options_mixes2options => option+mix_id
  329. sys_options_types => name
  330. sys_pages_layouts => name
  331. sys_permalinks => standard+permalink+check
  332. sys_profiles => type+content_id+content_id
  333. sys_profiles_track => profile_id+action
  334. sys_recommendation_criteria => object_id+name
  335. sys_recommendation_data => profile_id+object_id+item_id
  336. sys_search_extended_fields => object+name
  337. sys_search_extended_sorting_fields => object+name+direction
  338. sys_seo_uri_rewrites => uri_orig
  339. sys_seo_uri_rewrites => uri_rewrite
  340. sys_statistics => name
  341. sys_std_pages => name
  342. sys_std_pages_widgets => widget_id+page_id
  343. sys_std_roles => name
  344. sys_std_roles_members => account_id
  345. sys_std_widgets_bookmarks => widget_id+profile_id
  346. Write minimal necessary code.
  347. OLD install.sql:
  348. ```sql
  349. $oldContent
  350. ```
  351. NEW install.sql:
  352. ```sql
  353. $newContent
  354. ```
  355. Respond with SQL script only, any comments must be added as comments in SQL
  356. PROMPT;
  357. if ('deepseek' == self::ENGINE) {
  358. $response = $this->callDeepSeek($prompt);
  359. } else {
  360. $response = $this->callOpenAI([
  361. ['role' => 'system', 'content' => 'You are MySQL expert validator. Respond only with valid JSON.'],
  362. ['role' => 'user', 'content' => $prompt]
  363. ]);
  364. }
  365. return $this->parseJsonResponse($response, 'sql');
  366. }
  367. private function parseJsonResponse($response, $filename) {
  368. // Try to extract JSON from response
  369. $response = trim($response);
  370. // Remove markdown code blocks if present
  371. $response = preg_replace('/```json\s*/', '', $response);
  372. $response = preg_replace('/```\s*$/', '', $response);
  373. $response = trim($response);
  374. $data = json_decode($response, true);
  375. if (json_last_error() !== JSON_ERROR_NONE) {
  376. $this->error("Failed to parse JSON response for $filename: " . json_last_error_msg() . "\nResponse: $response");
  377. }
  378. return $data;
  379. }
  380. private function getSkippedResults() {
  381. return [
  382. 'syntax_correct' => ['pass' => null, 'details' => 'Skipped due to size constraints'],
  383. 'idempotent' => ['pass' => null, 'details' => 'Skipped due to size constraints'],
  384. 'all_changes_included' => ['pass' => null, 'details' => 'Skipped due to size constraints'],
  385. 'no_extra_changes' => ['pass' => null, 'details' => 'Skipped due to size constraints'],
  386. 'old_records_deleted' => ['pass' => null, 'details' => 'Skipped due to size constraints'],
  387. 'sys_objects_storage_warning' => ['has_changes' => false, 'details' => 'Skipped due to size constraints'],
  388. 'inconsistencies' => ['pass' => null, 'details' => 'Skipped due to size constraints']
  389. ];
  390. }
  391. private function displayResults($results, $title) {
  392. print_r($results);
  393. if (isset($results['sql'])) {
  394. echo "\nGenerated SQL Update Script:\n";
  395. echo "----------------------------------------\n";
  396. echo mysqlHighlight($results['sql']) . "\n";
  397. echo "----------------------------------------\n";
  398. unset($results['sql']);
  399. }
  400. if (!empty($results['sql'])) {
  401. echo "\nOther notes:\n";
  402. echo "----------------------------------------\n";
  403. print_r($results);
  404. echo "----------------------------------------\n";
  405. echo "\n";
  406. }
  407. }
  408. public function run() {
  409. echo "SQL Update Script Checker\n";
  410. echo "Module: {$this->moduleName}\n";
  411. echo "Version: {$this->version}\n";
  412. echo str_repeat("-", 80) . "\n";
  413. $oldInstallSQL = $this->getContent("Enter OLD install.sql content:", 'old_install');
  414. $newInstallSQL = $this->getContent("Enter NEW install.sql content:", 'new_install');
  415. if ($oldInstallSQL === null && $newInstallSQL === null) {
  416. echo "\033[90m⊘ Skipping install.sql check (empty input)\033[0m\n";
  417. }
  418. else {
  419. $results = $this->genSQL($oldInstallSQL, $newInstallSQL);
  420. $this->displayResults($results, "installer.php(script.php) and install.sql(sql.sql) Results");
  421. $this->results['installer.php'] = $results;
  422. }
  423. }
  424. }
  425. // Main execution
  426. if ($argc < 3) {
  427. echo "Usage: php {$argv[0]} MODULE_NAME VERSION [--old-install=file] [--new-install=file] [--old-enable=file] [--new-enable=file]\n";
  428. echo "\nExamples:\n";
  429. echo " Interactive mode:\n";
  430. echo " php {$argv[0]} bx_timeline 15.0.0-A2\n";
  431. echo " php {$argv[0]} system 15.0.0-A2\n\n";
  432. echo " File input mode (not tested!!!):\n";
  433. echo " php {$argv[0]} bx_timeline 14.0.0-RC1 \\\n";
  434. echo " --old-install=/path/to/old_install.sql \\\n";
  435. echo " --new-install=/path/to/new_install.sql \\\n";
  436. echo "Note: All four file options must be provided together, or none at all.\n";
  437. exit(1);
  438. }
  439. try {
  440. $checker = new SQLUpdateChecker($argv[1], $argv[2], array_slice($argv, 3));
  441. $checker->run();
  442. } catch (Exception $e) {
  443. echo "\033[31mERROR: " . $e->getMessage() . "\033[0m\n";
  444. exit(1);
  445. }
  446. function mysqlHighlight(string $sql): string
  447. {
  448. // ANSI
  449. $RESET = "\033[0m";
  450. $KEYWORD = "\033[1;34m"; // blue
  451. $STRING = "\033[0;32m"; // green
  452. $NUMBER = "\033[0;35m"; // magenta
  453. $COMMENT = "\033[0;90m"; // gray
  454. $placeholders = [];
  455. $i = 0;
  456. // 1️⃣ Extract comments
  457. $sql = preg_replace_callback(
  458. '/(--[^\n]*|#.*?$|\/\*[\s\S]*?\*\/)/m',
  459. function ($m) use (&$placeholders, &$i, $COMMENT, $RESET) {
  460. $key = "__C{$i}__";
  461. $placeholders[$key] = $COMMENT . $m[1] . $RESET;
  462. $i++;
  463. return $key;
  464. },
  465. $sql
  466. );
  467. // 2️⃣ Extract strings
  468. $sql = preg_replace_callback(
  469. "/('(?:\\\\'|[^'])*'|\"(?:\\\\\"|[^\"])*\")/",
  470. function ($m) use (&$placeholders, &$i, $STRING, $RESET) {
  471. $key = "__S{$i}__";
  472. $placeholders[$key] = $STRING . $m[1] . $RESET;
  473. $i++;
  474. return $key;
  475. },
  476. $sql
  477. );
  478. // 3️⃣ Numbers
  479. $sql = preg_replace(
  480. '/\b\d+(\.\d+)?\b/',
  481. $NUMBER . '$0' . $RESET,
  482. $sql
  483. );
  484. // 4️⃣ Keywords
  485. $keywords = [
  486. 'SELECT','INSERT','UPDATE','DELETE','FROM','WHERE','JOIN','LEFT','RIGHT',
  487. 'INNER','OUTER','ON','VALUES','SET','INTO','CREATE','TABLE','DROP','ALTER',
  488. 'AND','OR','NOT','NULL','IS','IN','AS','ORDER','BY','GROUP','LIMIT',
  489. 'HAVING','DISTINCT','UNION'
  490. ];
  491. $sql = preg_replace_callback(
  492. '/\b(' . implode('|', $keywords) . ')\b/i',
  493. fn($m) => $KEYWORD . strtoupper($m[1]) . $RESET,
  494. $sql
  495. );
  496. // 5️⃣ Restore strings & comments
  497. return strtr($sql, $placeholders);
  498. }