$table) { if (substr($key, -6) === '_table' && is_string($table) && $table !== '') { $replacements['{{' . $key . '}}'] = $table; } } $shop_prefix = defined('G5_SHOP_TABLE_PREFIX') ? G5_SHOP_TABLE_PREFIX : G5_TABLE_PREFIX . 'shop_'; $fallbacks = array( 'member_auto_login_table' => G5_TABLE_PREFIX . 'member_auto_login', 'g5_shop_cart_table' => $shop_prefix . 'cart', 'g5_shop_item_table' => $shop_prefix . 'item', 'g5_shop_order_table' => $shop_prefix . 'order', 'g5_shop_personalpay_table' => $shop_prefix . 'personalpay', 'g5_shop_kcp_noti_table' => $shop_prefix . 'kcp_noti', 'g5_shop_lg_noti_table' => $shop_prefix . 'lg_noti', 'g5_shop_default_table' => $shop_prefix . 'default', 'g5_shop_coupon_table' => $shop_prefix . 'coupon', 'g5_shop_coupon_log_table' => $shop_prefix . 'coupon_log', 'g5_shop_coupon_zone_table' => $shop_prefix . 'coupon_zone', 'g5_shop_inicis_log_table' => $shop_prefix . 'inicis_log', 'g5_shop_order_data_table' => $shop_prefix . 'order_data', 'g5_shop_order_access_table' => $shop_prefix . 'order_access', 'g5_shop_post_log_table' => $shop_prefix . 'order_post_log', 'g5_shop_order_cancel_log_table' => $shop_prefix . 'order_cancel_log', 'g5_shop_inicis_pay_table' => $shop_prefix . 'inicis_pay', 'g5_shop_inicis_pay_event_table' => $shop_prefix . 'inicis_pay_event', 'sms5_config_table' => G5_TABLE_PREFIX . 'sms5_config', 'sms5_write_table' => G5_TABLE_PREFIX . 'sms5_write', 'sms5_history_table' => G5_TABLE_PREFIX . 'sms5_history', 'sms5_book_table' => G5_TABLE_PREFIX . 'sms5_book', 'sms5_book_group_table' => G5_TABLE_PREFIX . 'sms5_book_group', 'sms5_form_table' => G5_TABLE_PREFIX . 'sms5_form', 'sms5_form_group_table' => G5_TABLE_PREFIX . 'sms5_form_group' ); foreach ($fallbacks as $key => $table) { if (!isset($replacements['{{' . $key . '}}'])) { $replacements['{{' . $key . '}}'] = $table; } } return strtr($sql, $replacements); } function g5_migration_parse_condition($directive, $file) { if (!preg_match('/^--\s*@(if|skip-if)-(table|column|index)-(exists|missing)\s+(\S+)(?:\s+(\S+))?\s*$/i', $directive, $matches)) { return array('error' => '지원하지 않는 실행 조건입니다: ' . basename($file) . ' - ' . trim($directive)); } $condition = array( 'object' => strtolower($matches[2]), 'state' => strtolower($matches[3]), 'table' => g5_migration_replace_placeholders(trim($matches[4], '`')), 'name' => isset($matches[5]) ? trim($matches[5], '`') : '' ); if ($condition['object'] !== 'table' && $condition['name'] === '') { return array('error' => '컬럼 또는 인덱스 이름이 없습니다: ' . basename($file)); } if (strpos($condition['table'], '{{') !== false || strpos($condition['table'], '}}') !== false) { return array('error' => '지원하지 않는 조건 테이블 자리표시자가 있습니다: ' . basename($file)); } return $condition; } function g5_migration_read_file($file) { $contents = @file_get_contents($file); if ($contents === false) { return array('error' => '마이그레이션 파일을 읽을 수 없습니다: ' . basename($file)); } $migration = array( 'id' => pathinfo($file, PATHINFO_FILENAME), 'description' => '', 'skip_table' => '', 'skip_column' => '', 'checksum' => hash('sha256', $contents), 'file' => $file, 'statements' => array() ); if (!preg_match('/^[0-9]{8}_[0-9]{3}_[a-z0-9_]+$/', $migration['id'])) { return array('error' => '마이그레이션 파일명이 규칙에 맞지 않습니다: ' . basename($file)); } if (preg_match('/^--\s*@description\s+(.+)$/mi', $contents, $matches)) { $migration['description'] = trim($matches[1]); } $migration['contents'] = $contents; return $migration; } function g5_migration_parse_file($file) { $migration = g5_migration_read_file($file); if (isset($migration['error'])) return $migration; $contents = $migration['contents']; unset($migration['contents']); if (preg_match('/^--[\t ]*@skip-if-column[\t ]+(\S+)[\t ]+(\S+)[\t ]*\r?$/mi', $contents, $matches)) { $migration['skip_table'] = g5_migration_replace_placeholders(trim($matches[1], '`')); $migration['skip_column'] = trim($matches[2], '`'); } $conditions = array(); $foreach_write_column = ''; $foreach_write_table = false; $foreach_content_seo = false; $buffer = ''; foreach (preg_split('/\r?\n/', $contents) as $line) { if (preg_match('/^\s*--\s*@skip-if-column\s+/i', $line)) { continue; } if (preg_match('/^\s*--\s*@foreach-write-table-if-column-missing\s+(\S+)\s*$/i', $line, $matches)) { $foreach_write_column = trim($matches[1], '`'); $foreach_write_table = true; continue; } if (preg_match('/^\s*--\s*@foreach-write-table\s*$/i', $line)) { $foreach_write_table = true; continue; } if (preg_match('/^\s*--\s*@foreach-content-missing-seo\s*$/i', $line)) { $foreach_content_seo = true; continue; } if (preg_match('/^\s*--\s*@(if|skip-if)-/i', $line)) { $parsed_condition = g5_migration_parse_condition(trim($line), $file); if (isset($parsed_condition['error'])) { return $parsed_condition; } $conditions[] = $parsed_condition; continue; } if (preg_match('/^\s*--/', $line)) { continue; } $buffer .= $line . "\n"; while (($position = strpos($buffer, ';')) !== false) { $statement = trim(substr($buffer, 0, $position)); $buffer = substr($buffer, $position + 1); if ($statement === '') { continue; } if (!$foreach_write_table && !$foreach_content_seo) { $statement = g5_migration_replace_placeholders($statement); } $allowed_dynamic_placeholder = strpos($statement, '{{write_table}}') !== false || ($foreach_content_seo && strpos($statement, '{{content_table}}') !== false); if ((strpos($statement, '{{') !== false || strpos($statement, '}}') !== false) && !$allowed_dynamic_placeholder) { return array('error' => '지원하지 않는 테이블 자리표시자가 있습니다: ' . basename($file)); } $migration['statements'][] = array('sql' => $statement, 'conditions' => $conditions, 'foreach_write_column' => $foreach_write_column, 'foreach_write_table' => $foreach_write_table, 'foreach_content_seo' => $foreach_content_seo); $conditions = array(); $foreach_write_column = ''; $foreach_write_table = false; $foreach_content_seo = false; } } if (trim($buffer) !== '') { return array('error' => 'SQL 문 끝에 세미콜론이 없습니다: ' . basename($file)); } if (!$migration['statements']) { return array('error' => '실행할 SQL이 없습니다: ' . basename($file)); } return $migration; } function g5_migration_discover() { $files = glob(g5_migration_path() . '/*.sql'); $migrations = array(); if (!$files) { return $migrations; } sort($files, SORT_STRING); foreach ($files as $file) { $migration = g5_migration_parse_file($file); if (isset($migration['error'])) { return array($migration); } $migrations[] = $migration; } return $migrations; } function g5_migration_table_exists($table) { $result = sql_query("SHOW TABLES LIKE '" . sql_real_escape_string($table) . "'", false); if (!$result) { return false; } while ($row = sql_fetch_array($result)) { if (reset($row) === $table) { return true; } } return false; } function g5_migration_column_exists($table, $column) { $result = sql_query("SHOW COLUMNS FROM `" . str_replace('`', '``', $table) . "` LIKE '" . sql_real_escape_string($column) . "'", false); return $result && sql_num_rows($result) > 0; } function g5_migration_index_exists($table, $index) { if (!g5_migration_table_exists($table)) { return false; } $result = sql_query("SHOW INDEX FROM `" . str_replace('`', '``', $table) . "` WHERE Key_name = '" . sql_real_escape_string($index) . "'", false); return $result && sql_num_rows($result) > 0; } function g5_migration_should_run_statement($conditions) { if (!$conditions) { return true; } foreach ($conditions as $condition) { if ($condition['object'] === 'table') { $exists = g5_migration_table_exists($condition['table']); } elseif ($condition['object'] === 'column') { $exists = g5_migration_column_exists($condition['table'], $condition['name']); } else { $exists = g5_migration_index_exists($condition['table'], $condition['name']); } if (($condition['state'] === 'exists') !== $exists) { return false; } } return true; } function g5_migration_execute_statement($statement) { global $g5; if ($statement['foreach_content_seo']) { if (!function_exists('exist_seo_title_recursive') || !function_exists('generate_seo_title')) { return array('error' => 'SEO 제목 생성 함수를 사용할 수 없습니다.', 'executed' => false); } $result = sql_query("SELECT co_id, co_subject FROM `{$g5['content_table']}` WHERE co_seo_title = ''", false); if (!$result) { return array('error' => sql_error_info(), 'executed' => false); } $executed = false; while ($content = sql_fetch_array($result)) { $seo_title = exist_seo_title_recursive('content', generate_seo_title($content['co_subject']), $g5['content_table'], $content['co_id']); $sql = str_replace( array('{{content_table}}', '{{content_id}}', '{{seo_title}}'), array($g5['content_table'], sql_real_escape_string($content['co_id']), sql_real_escape_string($seo_title)), $statement['sql'] ); if (!sql_query($sql, false)) { return array('error' => $content['co_id'] . ': ' . sql_error_info(), 'executed' => $executed); } $executed = true; } return array('error' => '', 'executed' => $executed); } if (!$statement['foreach_write_table']) { return array('error' => sql_query($statement['sql'], false) ? '' : sql_error_info(), 'executed' => true); } $result = sql_query("SELECT bo_table FROM `{$g5['board_table']}`", false); if (!$result) { return array('error' => sql_error_info(), 'executed' => false); } $executed = false; while ($board = sql_fetch_array($result)) { $write_table = $g5['write_prefix'] . $board['bo_table']; if (!g5_migration_table_exists($write_table) || ($statement['foreach_write_column'] !== '' && g5_migration_column_exists($write_table, $statement['foreach_write_column']))) { continue; } $sql = str_replace('{{write_table}}', $write_table, $statement['sql']); if (!sql_query($sql, false)) { return array('error' => $write_table . ': ' . sql_error_info(), 'executed' => $executed); } $executed = true; } return array('error' => '', 'executed' => $executed); } function g5_migration_get_records(&$error = null) { $table = g5_migration_table_name(); $records = array(); $error = ''; $tables = sql_query("SHOW TABLES LIKE '" . sql_real_escape_string($table) . "'", false); if (!$tables) { $error = '마이그레이션 이력 테이블을 조회하지 못했습니다: ' . sql_error_info(); return $records; } $exists = false; while ($row = sql_fetch_array($tables)) { if (reset($row) === $table) $exists = true; } if (!$exists) { return $records; } $error = g5_migration_validate_table(); if ($error !== '') return $records; $result = sql_query("SELECT migration_id, checksum, status, error_message, applied_at FROM `{$table}` ORDER BY migration_id", false); if (!$result) { $error = '마이그레이션 이력을 읽지 못했습니다: ' . sql_error_info(); return $records; } while ($row = sql_fetch_array($result)) { $records[$row['migration_id']] = $row; } return $records; } // 검사 실패는 객체 없음과 구분한다. 잘못된 건너뛰기 이력을 만들지 않는다. function g5_migration_inspect_object($object, $table, $name, &$cache) { $key = $object . ':' . $table; if (!array_key_exists($key, $cache)) { $quoted = '`' . str_replace('`', '``', $table) . '`'; if ($object === 'table') { $sql = "SHOW TABLES LIKE '" . sql_real_escape_string($table) . "'"; } elseif ($object === 'column') { $sql = 'SHOW COLUMNS FROM ' . $quoted; } else { $sql = 'SHOW INDEX FROM ' . $quoted; } $result = sql_query($sql, false); $cache[$key] = $result ? array() : null; if ($result) { while ($row = sql_fetch_array($result)) { $value = $object === 'table' ? reset($row) : $row[$object === 'column' ? 'Field' : 'Key_name']; $cache[$key][$value] = true; } } } return $cache[$key] === null ? null : isset($cache[$key][$object === 'table' ? $table : $name]); } // SQL을 실행하지 않고, 실행기의 모든 문이 건너뛰어질 때만 true를 반환한다. function g5_migration_needs_no_execution($migration, &$cache) { global $g5; if (isset($migration['error'])) { return false; } if ($migration['skip_table'] && $migration['skip_column'] && g5_migration_inspect_object('column', $migration['skip_table'], $migration['skip_column'], $cache) === true) { return true; } foreach ($migration['statements'] as $statement) { $skip = false; foreach ($statement['conditions'] as $condition) { $exists = g5_migration_inspect_object($condition['object'], $condition['table'], $condition['name'], $cache); if ($exists === null) { return false; } if (($condition['state'] === 'exists') !== $exists) { $skip = true; break; } } if ($skip) { continue; } if (!$statement['foreach_write_table']) { // 데이터 보정 및 조건 없는 DDL은 완료로 추정하지 않는다. return false; } $result = sql_query("SELECT bo_table FROM `" . str_replace('`', '``', $g5['board_table']) . "`", false); if (!$result) { return false; } while ($board = sql_fetch_array($result)) { $table = $g5['write_prefix'] . $board['bo_table']; if (g5_migration_inspect_object('table', $table, '', $cache) !== true || $statement['foreach_write_column'] === '' || g5_migration_inspect_object('column', $table, $statement['foreach_write_column'], $cache) !== true) { return false; } } } return true; } function g5_migration_status() { $migrations = g5_migration_discover(); $records = g5_migration_get_records($history_error); if ($history_error !== '') return array(array('error' => $history_error)); $status = array(); $inspection_cache = array(); foreach ($migrations as $migration) { if (isset($migration['error'])) { $status[] = $migration; continue; } $record = isset($records[$migration['id']]) ? $records[$migration['id']] : null; $migration['status'] = $record ? $record['status'] : 'pending'; if (!$record && g5_migration_needs_no_execution($migration, $inspection_cache)) { $migration['status'] = 'unrecorded'; } $migration['checksum_changed'] = $record && $record['status'] === 'success' && $record['checksum'] !== $migration['checksum']; $migration['error_message'] = $record ? $record['error_message'] : ''; $migration['applied_at'] = $record ? $record['applied_at'] : ''; $status[] = $migration; } return $status; } function g5_migration_status_label($status, $checksum_changed = false) { if ($checksum_changed) { return '적용 후 파일 변경됨'; } $labels = array( 'pending' => '대기', 'unrecorded' => '실행 불필요 (미기록)', 'success' => '성공', 'failed' => '실패' ); return isset($labels[$status]) ? $labels[$status] : '알 수 없음'; } function g5_migration_validate_target_dependencies($migrations, $records, $target_id) { $target_index = -1; foreach ($migrations as $index => $migration) { if (isset($migration['error'])) { return $migration['error']; } if ($migration['id'] === $target_id) { $target_index = $index; break; } } if ($target_index < 0) { return '요청한 마이그레이션을 찾을 수 없습니다: ' . $target_id; } for ($index = 0; $index < $target_index; $index++) { $migration = $migrations[$index]; $record = isset($records[$migration['id']]) ? $records[$migration['id']] : null; if (!$record || $record['status'] !== 'success') { return $target_id . ' 실행 전에 선행 마이그레이션을 먼저 실행해야 합니다: ' . $migration['id']; } if ($record['checksum'] !== $migration['checksum']) { return '선행 마이그레이션 파일이 적용 후 변경되었습니다: ' . $migration['id']; } } return ''; } // 조회에서는 구조만 검사하고, 복구를 위한 DDL은 자동 실행하지 않는다. function g5_migration_validate_table($table = null) { if ($table === null) $table = g5_migration_table_name(); $columns = 'migration_id, description, checksum, status, error_message, execution_ms, applied_at'; if (!sql_query("SELECT {$columns} FROM `{$table}` LIMIT 0", false)) { return "마이그레이션 이력 테이블 `{$table}`의 구조를 확인하지 못했습니다: " . sql_error_info() . ' (docs/database-migrations.md의 이력 테이블 복구 절차를 확인해 주십시오.)'; } $indexes = sql_query("SHOW INDEX FROM `{$table}` WHERE Key_name = 'PRIMARY'", false); if (!$indexes) return '마이그레이션 이력 기본키를 확인하지 못했습니다: ' . sql_error_info(); $primary = array(); while ($row = sql_fetch_array($indexes)) $primary[] = $row['Column_name']; if ($primary !== array('migration_id')) { return "마이그레이션 이력 테이블 `{$table}`의 기본키가 migration_id 단일 컬럼이 아닙니다. docs/database-migrations.md의 이력 테이블 복구 절차를 확인해 주십시오."; } return ''; } function g5_migration_ensure_table() { $table = g5_migration_table_name(); $sql = "CREATE TABLE IF NOT EXISTS `{$table}` ( `migration_id` varchar(100) NOT NULL, `description` varchar(255) NOT NULL DEFAULT '', `checksum` char(64) NOT NULL DEFAULT '', `status` varchar(20) NOT NULL DEFAULT 'pending', `error_message` text NOT NULL, `execution_ms` int(11) NOT NULL DEFAULT '0', `applied_at` datetime NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (`migration_id`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8"; return (bool) sql_query($sql, false); } function g5_migration_save_record($migration, $status, $error_message, $execution_ms, $table = null) { if ($table === null) $table = g5_migration_table_name(); $id = sql_real_escape_string($migration['id']); $description = sql_real_escape_string($migration['description']); $checksum = sql_real_escape_string($migration['checksum']); $status = sql_real_escape_string($status); $error_message = sql_real_escape_string($error_message); $execution_ms = (int) $execution_ms; $sql = "INSERT INTO `{$table}` SET migration_id = '{$id}', description = '{$description}', checksum = '{$checksum}', status = '{$status}', error_message = '{$error_message}', execution_ms = '{$execution_ms}', applied_at = '" . G5_TIME_YMDHIS . "' ON DUPLICATE KEY UPDATE description = VALUES(description), checksum = VALUES(checksum), status = VALUES(status), error_message = VALUES(error_message), execution_ms = VALUES(execution_ms), applied_at = VALUES(applied_at)"; return (bool) sql_query($sql, false); } function g5_migration_run($target_id = '', $record_existing = false) { $result = array('success' => false, 'applied' => 0, 'skipped' => 0, 'errors' => array()); if (!g5_migration_ensure_table()) { $result['errors'][] = '마이그레이션 이력 테이블을 생성하지 못했습니다: ' . sql_error_info(); return $result; } $lock_name = 'g5_migration_' . substr(md5(G5_TABLE_PREFIX), 0, 16); $lock = sql_fetch("SELECT GET_LOCK('{$lock_name}', 10) AS acquired", false); if (!$lock || (int) $lock['acquired'] !== 1) { $result['errors'][] = '다른 DB 업그레이드가 실행 중이거나 실행 잠금을 얻지 못했습니다.'; return $result; } $migrations = g5_migration_discover(); $records = g5_migration_get_records($history_error); if ($history_error !== '') { $result['errors'][] = $history_error; sql_query("SELECT RELEASE_LOCK('{$lock_name}')", false); return $result; } $target_found = $target_id === ''; if ($record_existing) { $history = sql_fetch('SELECT COUNT(*) AS total FROM `' . g5_migration_table_name() . '`', false); if (!$history || !isset($history['total']) || (int) $history['total'] !== count($records)) { $result['errors'][] = '기존 마이그레이션 이력을 확인하지 못했습니다.'; sql_query("SELECT RELEASE_LOCK('{$lock_name}')", false); return $result; } } if ($target_id !== '') { $dependency_error = g5_migration_validate_target_dependencies($migrations, $records, $target_id); if ($dependency_error !== '') { $result['errors'][] = $dependency_error; sql_query("SELECT RELEASE_LOCK('{$lock_name}')", false); return $result; } } foreach ($migrations as $migration) { if (isset($migration['error'])) { $result['errors'][] = $migration['error']; break; } if ($target_id !== '' && $migration['id'] !== $target_id) { continue; } $target_found = true; $record = isset($records[$migration['id']]) ? $records[$migration['id']] : null; if ($record && $record['status'] === 'success') { if ($record['checksum'] !== $migration['checksum']) { $result['errors'][] = $migration['id'] . ' 파일이 적용 후 변경되었습니다.'; break; } continue; } $started_at = get_microtime(); if ($record_existing) { $inspection_cache = array(); // 앞선 변경이 후속 실행 조건을 바꿀 수 있으므로 연속된 항목만 등록한다. if ($record || !g5_migration_needs_no_execution($migration, $inspection_cache)) { break; } if (!g5_migration_save_record($migration, 'success', '', 0)) { $result['errors'][] = $migration['id'] . ' 기존 상태 확인 이력을 저장하지 못했습니다: ' . sql_error_info(); break; } $result['skipped']++; continue; } $skip = $migration['skip_table'] && $migration['skip_column'] && g5_migration_column_exists($migration['skip_table'], $migration['skip_column']); $migration_error = ''; $executed = false; if (!$skip) { foreach ($migration['statements'] as $statement) { if (!g5_migration_should_run_statement($statement['conditions'])) { continue; } $statement_result = g5_migration_execute_statement($statement); $executed = $executed || $statement_result['executed']; if ($statement_result['error'] !== '') { $migration_error = $statement_result['error']; break; } } } $execution_ms = (int) round((get_microtime() - $started_at) * 1000); if ($migration_error !== '') { g5_migration_save_record($migration, 'failed', $migration_error, $execution_ms); $result['errors'][] = $migration['id'] . ': ' . $migration_error; break; } if (!g5_migration_save_record($migration, 'success', '', $execution_ms)) { $result['errors'][] = $migration['id'] . ' 실행 이력을 저장하지 못했습니다: ' . sql_error_info(); break; } if ($skip || !$executed) { $result['skipped']++; } else { $result['applied']++; } } if (!$target_found) { $result['errors'][] = '요청한 마이그레이션을 찾을 수 없습니다: ' . $target_id; } sql_query("SELECT RELEASE_LOCK('{$lock_name}')", false); $result['success'] = !$result['errors']; return $result; }