Commit 952aa695 authored by DevPilot's avatar DevPilot

fix(sql): correct column names that crash screens at runtime, add a schema checker

كشف حساب العضو كان بيقع بـ Unknown column 'phone' — جدول members فيه
phone_mobile/phone_home مش phone. ولما دورت على الغلط ده في باقي الكود
لقيت نفس النوع في 20+ مكان تاني (coaches.name_ar، players.name_ar،
members.member_number، hr_attendance.employee_id، وغيرهم).

tools/sql_schema_check.py بيقارن كل SQL في app/ بالـ schema الحقيقي
ويقع بـ exit 1 لو لقى عمود مش موجود، عشان النوع ده ما يوصلش للـ production تاني.
parent 9c814464
......@@ -48,7 +48,7 @@ final class SettlementService
WHERE sc.academy_id = ?
AND sc.coach_type = 'academy'
AND pr.status IN ('paid', 'completed')
AND DATE_FORMAT(pr.paid_at, '%Y-%m') = ?",
AND DATE_FORMAT(pr.processed_at, '%Y-%m') = ?",
[$academyId, $month]
);
......
......@@ -541,8 +541,11 @@ class ReportController extends Controller
);
if (!$found) {
$found = $db->selectOne(
"SELECT id FROM members WHERE full_name_ar LIKE ? OR full_name_en LIKE ? OR phone LIKE ? LIMIT 1",
["%{$memberSearch}%", "%{$memberSearch}%", "%{$memberSearch}%"]
"SELECT id FROM members
WHERE full_name_ar LIKE ? OR full_name_en LIKE ?
OR phone_mobile LIKE ? OR phone_home LIKE ?
LIMIT 1",
["%{$memberSearch}%", "%{$memberSearch}%", "%{$memberSearch}%", "%{$memberSearch}%"]
);
}
if ($found) {
......
......@@ -69,7 +69,7 @@ final class AlertProcessorService
LEFT JOIN players p ON p.id = pmr.player_id
WHERE pmr.expiry_date IS NOT NULL
AND pmr.expiry_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY)
AND pmr.is_current = 1",
AND pmr.approval_status = 'approved'",
[$days]
);
......@@ -133,8 +133,9 @@ final class AlertProcessorService
"SELECT sa.player_id, p.full_name_ar AS player_name, COUNT(*) AS absence_count
FROM session_attendance sa
LEFT JOIN players p ON p.id = sa.player_id
INNER JOIN training_sessions ts ON ts.id = sa.session_id
WHERE sa.status = 'absent'
AND sa.session_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
AND ts.session_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY sa.player_id
HAVING absence_count >= ?
ORDER BY absence_count DESC",
......
......@@ -89,7 +89,7 @@ class GroupWaitlist extends Model
if (!empty($filters['q'])) {
$search = '%' . $filters['q'] . '%';
$where[] = '(m.name_ar LIKE ? OR m.membership_number LIKE ?)';
$where[] = '(m.full_name_ar LIKE ? OR m.membership_number LIKE ?)';
$params[] = $search;
$params[] = $search;
}
......@@ -111,7 +111,7 @@ class GroupWaitlist extends Model
// Fetch data
$sql = "SELECT gw.*,
m.name_ar AS player_name, m.membership_number,
m.full_name_ar AS player_name, m.membership_number,
tg.name_ar AS group_name
FROM group_waitlist gw
LEFT JOIN members m ON m.id = gw.player_id
......
......@@ -30,7 +30,7 @@ final class FacilityAttendanceController extends Controller
$db = App::getInstance()->db();
$coaches = $db->select(
"SELECT DISTINCT fzs.coach_id, c.name_ar
"SELECT DISTINCT fzs.coach_id, c.full_name_ar
FROM facility_zone_schedules fzs
INNER JOIN facility_grid_zones fgz ON fgz.id = fzs.zone_id
INNER JOIN coaches c ON c.id = fzs.coach_id
......@@ -39,7 +39,7 @@ final class FacilityAttendanceController extends Controller
);
$trainees = $db->select(
"SELECT DISTINCT fzt.player_id, p.name_ar
"SELECT DISTINCT fzt.player_id, p.full_name_ar
FROM facility_zone_trainees fzt
INNER JOIN facility_grid_zones fgz ON fgz.id = fzt.zone_id
INNER JOIN players p ON p.id = fzt.player_id
......
......@@ -99,7 +99,7 @@ final class FacilityAttendanceService
public function sendPaymentReminders(int $gridId): int
{
$trainees = $this->db->select(
"SELECT DISTINCT fzt.player_id, p.name_ar, p.phone
"SELECT DISTINCT fzt.player_id, p.full_name_ar, p.phone
FROM facility_zone_trainees fzt
INNER JOIN facility_grid_zones fgz ON fgz.id = fzt.zone_id
INNER JOIN players p ON p.id = fzt.player_id
......
......@@ -14,9 +14,15 @@ final class AttendanceViolationService
$violations = [];
$attendanceRecords = $db->select(
"SELECT a.*, e.id as emp_id, e.full_name_ar
"SELECT a.*,
ep.employee_id AS employee_id,
a.check_in_time AS check_in,
a.check_out_time AS check_out,
e.id AS emp_id,
e.full_name_ar
FROM hr_attendance a
JOIN employees e ON e.id = a.employee_id
JOIN hr_employee_profiles ep ON ep.id = a.employee_profile_id
JOIN employees e ON e.id = ep.employee_id
WHERE a.attendance_date = ?",
[$date]
);
......@@ -132,13 +138,16 @@ final class AttendanceViolationService
if ($isHoliday) return [];
$onLeave = $db->select(
"SELECT employee_id FROM hr_leave_requests WHERE status = 'approved' AND ? BETWEEN start_date AND end_date",
"SELECT ep.employee_id
FROM hr_leave_requests lr
JOIN hr_employee_profiles ep ON ep.id = lr.employee_profile_id
WHERE lr.status = 'approved' AND ? BETWEEN lr.start_date AND lr.end_date",
[$date]
);
$onLeaveIds = array_column($onLeave, 'employee_id');
$allActive = $db->select(
"SELECT id as employee_id FROM employees WHERE is_archived = 0 AND employment_status = 'active'"
"SELECT id as employee_id FROM employees WHERE is_archived = 0 AND is_active = 1"
);
$absentees = [];
......
......@@ -96,7 +96,7 @@ final class PlayerProgressionService
$targetDate = date('Y-m-d', strtotime('+' . $daysAhead . ' days'));
return $db->select(
"SELECT pmr.*, p.full_name AS player_name, p.phone AS player_phone
"SELECT pmr.*, p.full_name_ar AS player_name, p.phone AS player_phone
FROM player_medical_records pmr
LEFT JOIN players p ON p.id = pmr.player_id
WHERE pmr.expiry_date IS NOT NULL
......
......@@ -93,7 +93,7 @@ final class EnrollmentController extends ApiController
$enrollments = $db->select(
"SELECT gm.id, gm.status, gm.joined_at,
tg.name_ar as group_name, tg.group_type, tg.day_of_week, tg.start_time, tg.end_time,
c.name_ar as coach_name,
c.full_name_ar as coach_name,
f.name_ar as facility_name,
sd.name_ar as discipline_name
FROM group_memberships gm
......@@ -118,7 +118,7 @@ final class EnrollmentController extends ApiController
"SELECT pe.id, pe.skill_level, pe.scores_json, pe.strengths_ar, pe.weaknesses_ar,
pe.status, pe.created_at,
sd.name_ar as discipline_name,
c.name_ar as evaluator_name
c.full_name_ar as evaluator_name
FROM player_evaluations pe
LEFT JOIN sport_disciplines sd ON sd.id = pe.discipline_id
LEFT JOIN coaches c ON c.id = pe.evaluator_coach_id
......
......@@ -23,7 +23,7 @@ final class ProfileController extends ApiController
$db = App::getInstance()->db();
$full = $db->selectOne(
"SELECT id, name_ar, name_en, phone, date_of_birth, gender, national_id,
"SELECT id, full_name_ar AS name_ar, full_name_en AS name_en, phone, date_of_birth, gender, national_id,
member_id, player_type, medical_status, medical_expiry_date,
guardian_name, guardian_phone, guardian_relationship,
card_status, created_at
......
......@@ -65,7 +65,7 @@ final class SportBrowseController extends ApiController
"SELECT tg.id, tg.name_ar, tg.group_type, tg.day_of_week, tg.start_time, tg.end_time,
tg.min_capacity, tg.max_capacity, tg.current_count, tg.gender_restriction,
tg.age_from, tg.age_to, tg.pricing_tier,
c.name_ar as coach_name,
c.full_name_ar as coach_name,
f.name_ar as facility_name,
al.name_ar as level_name
FROM training_groups tg
......@@ -86,7 +86,7 @@ final class SportBrowseController extends ApiController
$groupId = (int) $id;
$group = $db->selectOne(
"SELECT tg.*, c.name_ar as coach_name, c.phone as coach_phone,
"SELECT tg.*, c.full_name_ar as coach_name, c.phone as coach_phone,
f.name_ar as facility_name, f.facility_type,
al.name_ar as level_name,
a.name_ar as academy_name
......
......@@ -18,7 +18,7 @@ final class PlayerAuthService
public function findPlayerByPhone(string $phone): ?array
{
return $this->db->selectOne(
"SELECT id, name_ar, phone, password_hash, is_archived FROM players WHERE phone = ? AND is_archived = 0",
"SELECT id, full_name_ar AS name_ar, phone, password_hash, is_archived FROM players WHERE phone = ? AND is_archived = 0",
[$phone]
);
}
......
......@@ -30,7 +30,7 @@ final class PlaygroundAttendanceController extends Controller
$date = $request->get('date', date('Y-m-d'));
$slots = $db->select(
"SELECT pss.*, c.name_ar as coach_name
"SELECT pss.*, c.full_name_ar as coach_name
FROM playground_schedule_slots pss
INNER JOIN playground_monthly_schedules pms ON pms.id = pss.schedule_id
LEFT JOIN coaches c ON c.id = pss.coach_id
......@@ -108,7 +108,7 @@ final class PlaygroundAttendanceController extends Controller
}
$players = $db->select(
"SELECT DISTINCT pss.coach_id, c.name_ar, c.phone
"SELECT DISTINCT pss.coach_id, c.full_name_ar, c.phone
FROM playground_schedule_slots pss
INNER JOIN coaches c ON c.id = pss.coach_id
WHERE pss.schedule_id = ? AND pss.coach_id IS NOT NULL",
......
......@@ -142,7 +142,7 @@ final class PlaygroundScheduleController extends Controller
$playground = $db->selectOne("SELECT * FROM playground_configurations WHERE id = ?", [(int) $id]);
$slots = PlaygroundScheduleSlot::getForDateAndHour((int) $schedId, $date, $hour . ':00');
$coaches = $db->select("SELECT id, name_ar FROM coaches WHERE is_archived = 0 ORDER BY name_ar");
$coaches = $db->select("SELECT id, full_name_ar AS name_ar FROM coaches WHERE is_archived = 0 ORDER BY full_name_ar");
$academies = $db->select("SELECT id, name_ar FROM academies WHERE is_active = 1 ORDER BY name_ar");
return $this->view('PlaygroundAdmin.Views.slot_editor', [
......
......@@ -20,7 +20,7 @@ final class PlaygroundScheduleSlot extends Model
{
$db = \App\Core\App::getInstance()->db();
return $db->select(
"SELECT pss.*, c.name_ar as coach_name, a.name_ar as academy_name
"SELECT pss.*, c.full_name_ar as coach_name, a.name_ar as academy_name
FROM playground_schedule_slots pss
LEFT JOIN coaches c ON c.id = pss.coach_id
LEFT JOIN academies a ON a.id = pss.academy_id
......@@ -34,7 +34,7 @@ final class PlaygroundScheduleSlot extends Model
{
$db = \App\Core\App::getInstance()->db();
return $db->select(
"SELECT pss.*, c.name_ar as coach_name, a.name_ar as academy_name
"SELECT pss.*, c.full_name_ar as coach_name, a.name_ar as academy_name
FROM playground_schedule_slots pss
LEFT JOIN coaches c ON c.id = pss.coach_id
LEFT JOIN academies a ON a.id = pss.academy_id
......
......@@ -26,8 +26,8 @@ final class PlaygroundMirrorService
$reservations = $this->db->select(
"SELECT r.id, r.start_time, r.end_time, r.booker_type, COALESCE(r.player_id, r.member_id) AS booker_id, r.status, r.total_amount,
CASE WHEN r.booker_type = 'player' THEN p.name_ar
WHEN r.booker_type = 'member' THEN m.name_ar
CASE WHEN r.booker_type = 'player' THEN p.full_name_ar
WHEN r.booker_type = 'member' THEN m.full_name_ar
ELSE 'ضيف' END as booker_name
FROM reservations r
LEFT JOIN players p ON r.booker_type = 'player' AND p.id = r.player_id
......@@ -52,7 +52,7 @@ final class PlaygroundMirrorService
);
if ($activeSched) {
$scheduledSlots = $this->db->select(
"SELECT pss.*, c.name_ar as coach_name, a.name_ar as academy_name
"SELECT pss.*, c.full_name_ar as coach_name, a.name_ar as academy_name
FROM playground_schedule_slots pss
LEFT JOIN coaches c ON c.id = pss.coach_id
LEFT JOIN academies a ON a.id = pss.academy_id
......
......@@ -182,7 +182,7 @@ final class RegulatoryDiscountService
{
$db = App::getInstance()->db();
$member = $db->selectOne(
"SELECT m.id, m.member_number, m.full_name_ar, m.branch_id, b.name_ar as branch_name FROM members m LEFT JOIN branches b ON b.id = m.branch_id WHERE m.id = ? AND m.branch_id = ? AND m.status = 'active'",
"SELECT m.id, m.membership_number, m.full_name_ar, m.branch_id, b.name_ar as branch_name FROM members m LEFT JOIN branches b ON b.id = m.branch_id WHERE m.id = ? AND m.branch_id = ? AND m.status = 'active'",
[$memberId, $sourceBranchId]
);
......
......@@ -138,7 +138,7 @@ final class DashboardMetricsService
$db = App::getInstance()->db();
return $db->select("
SELECT DISTINCT c.id, c.name_ar, c.name_en,
SELECT DISTINCT c.id, c.full_name_ar, c.full_name_en,
(SELECT COUNT(*) FROM facility_zone_schedules fzs2 WHERE fzs2.coach_id = c.id AND fzs2.schedule_date BETWEEN ? AND ?) AS session_count
FROM coaches c
INNER JOIN facility_zone_schedules fzs ON fzs.coach_id = c.id
......
......@@ -30,7 +30,7 @@ final class CoachAuthController extends ApiController
$db = App::getInstance()->db();
$coach = $db->selectOne(
"SELECT id, name_ar, phone, password_hash, is_archived FROM coaches WHERE phone = ? AND is_archived = 0",
"SELECT id, full_name_ar AS name_ar, phone, password_hash, is_archived FROM coaches WHERE phone = ? AND is_archived = 0",
[$data['phone']]
);
......
......@@ -104,13 +104,13 @@ final class CoachScheduleController extends ApiController
}
$players = $db->select(
"SELECT p.id, p.name_ar, p.phone, p.date_of_birth, p.gender,
"SELECT p.id, p.full_name_ar, p.phone, p.date_of_birth, p.gender,
p.medical_status, p.medical_expiry_date,
gm.joined_at, gm.status as membership_status
FROM group_memberships gm
INNER JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = ? AND gm.status = 'active'
ORDER BY p.name_ar",
ORDER BY p.full_name_ar",
[(int) $id]
);
......
......@@ -44,7 +44,7 @@ final class CoachDayOffService
$requestId = (int) $this->db->selectOne("SELECT LAST_INSERT_ID() as id")['id'];
$coach = $this->db->selectOne("SELECT name_ar FROM coaches WHERE id = ?", [$coachId]);
$coach = $this->db->selectOne("SELECT full_name_ar AS name_ar FROM coaches WHERE id = ?", [$coachId]);
EventBus::dispatch('coach.day_off_requested', [
'coach_id' => $coachId,
......@@ -75,7 +75,7 @@ final class CoachDayOffService
public function getRequestDetails(int $requestId, int $coachId): ?array
{
$request = $this->db->selectOne(
"SELECT dor.*, c.name_ar as replacement_name
"SELECT dor.*, c.full_name_ar as replacement_name
FROM coach_day_off_requests dor
LEFT JOIN coaches c ON c.id = dor.replacement_coach_id
WHERE dor.id = ? AND dor.coach_id = ?",
......
......@@ -19,7 +19,7 @@ final class ReplacementService
{
return $this->db->select(
"SELECT dor.id, dor.coach_id, dor.request_date, dor.affected_sessions_json, dor.created_at,
c.name_ar as coach_name
c.full_name_ar as coach_name
FROM coach_day_off_requests dor
INNER JOIN coaches c ON c.id = dor.coach_id
WHERE dor.status = 'pending' AND dor.coach_id != ? AND dor.replacement_coach_id IS NULL
......@@ -68,8 +68,8 @@ final class ReplacementService
], 'id = ? AND status = ?', [(int) $session['id'], 'scheduled']);
}
$volunteerCoach = $this->db->selectOne("SELECT name_ar FROM coaches WHERE id = ?", [$volunteerCoachId]);
$originalCoach = $this->db->selectOne("SELECT name_ar FROM coaches WHERE id = ?", [(int) $request['coach_id']]);
$volunteerCoach = $this->db->selectOne("SELECT full_name_ar AS name_ar FROM coaches WHERE id = ?", [$volunteerCoachId]);
$originalCoach = $this->db->selectOne("SELECT full_name_ar AS name_ar FROM coaches WHERE id = ?", [(int) $request['coach_id']]);
EventBus::dispatch('coach.replacement_confirmed', [
'original_coach_id' => (int) $request['coach_id'],
......
......@@ -32,7 +32,7 @@ class SessionFeedback extends Model
$db = App::getInstance()->db();
return $db->select(
"SELECT sf.*, m.name_ar AS player_name
"SELECT sf.*, m.full_name_ar AS player_name
FROM session_feedback sf
LEFT JOIN members m ON m.id = sf.player_id AND sf.is_anonymous = 0
WHERE sf.session_id = ?
......
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""اتصال بقاعدة البيانات الحية بالإعدادات اللي في .env — لأدوات الفحص."""
import os
import pymysql
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def env(path=None):
path = path or os.path.join(ROOT, '.env')
out = {}
with open(path, encoding='utf-8') as fh:
for line in fh:
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
k, v = line.split('=', 1)
out[k.strip()] = v.strip().strip('"').strip("'")
return out
def connect():
e = env()
return pymysql.connect(
host=e['DB_HOST'], port=int(e.get('DB_PORT', 3306)),
user=e['DB_USER'], password=e.get('DB_PASS', ''),
database=e['DB_NAME'], charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor,
)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
فاحص أعمدة الـ SQL — بيقارن كل `alias.column` في كود PHP بالـ schema الحقيقي.
الفكرة: أغلب الـ 500 اللي بتطلع فجأة في الشاشات سببها عمود اتغير اسمه أو
ما اتعملش أصلًا، والكود بيفضل شايل الاسم القديم لحد ما حد يفتح الشاشة.
الفحص ده بيمسك النوع ده من الغلط من غير ما نفتح كل شاشة بإيدينا.
python3 tools/sql_schema_check.py # يفحص كل الكود
python3 tools/sql_schema_check.py app/Modules/Accounting
بيرجّع exit code 1 لو لقى مشاكل، عشان ينفع في أي CI بعدين.
"""
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from db_conn import connect # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# كلمات محجوزة ما ينفعش نعتبرها أسماء جداول/أعمدة
SQL_KEYWORDS = {
'select', 'from', 'where', 'and', 'or', 'not', 'in', 'is', 'null', 'as', 'on',
'join', 'left', 'right', 'inner', 'outer', 'cross', 'group', 'by', 'order',
'having', 'limit', 'offset', 'union', 'all', 'distinct', 'case', 'when',
'then', 'else', 'end', 'insert', 'into', 'values', 'update', 'set', 'delete',
'count', 'sum', 'avg', 'min', 'max', 'coalesce', 'ifnull', 'if', 'concat',
'date', 'now', 'curdate', 'year', 'month', 'day', 'desc', 'asc', 'like',
'between', 'exists', 'interval', 'cast', 'convert', 'round', 'abs', 'nullif',
'greatest', 'least', 'date_format', 'date_add', 'date_sub', 'datediff',
'timestampdiff', 'lower', 'upper', 'trim', 'substring', 'replace', 'length',
'group_concat', 'json_extract', 'json_unquote', 'row_number', 'over',
'partition', 'with', 'recursive', 'true', 'false', 'default', 'duplicate',
'key', 'ignore', 'straight_join', 'using', 'natural', 'for', 'share', 'lock',
'asc', 'binary', 'collate', 'div', 'mod', 'xor', 'rlike', 'regexp', 'last_insert_id',
# وحدات الوقت اللي بتيجي بعد INTERVAL
'second', 'minute', 'hour', 'week', 'quarter', 'microsecond', 'dayofweek',
'unsigned', 'signed', 'char', 'decimal', 'integer', 'separator', 'both', 'leading', 'trailing',
}
def load_schema(cur):
"""{table: {column, ...}} من الـ schema الحقيقي."""
cur.execute("""SELECT TABLE_NAME, COLUMN_NAME
FROM information_schema.columns
WHERE table_schema = DATABASE()""")
schema = {}
for r in cur.fetchall():
schema.setdefault(r['TABLE_NAME'].lower(), set()).add(r['COLUMN_NAME'].lower())
return schema
def php_strings(src):
"""كل الـ string literals في ملف PHP (مفرد/مزدوج/heredoc)."""
out = []
for m in re.finditer(r'<<<[\'"]?(\w+)[\'"]?\r?\n(.*?)\r?\n\s*\1', src, re.S):
out.append((m.start(), m.group(2)))
for m in re.finditer(r'"((?:[^"\\]|\\.)*)"', src, re.S):
out.append((m.start(), m.group(1)))
for m in re.finditer(r"'((?:[^'\\]|\\.)*)'", src, re.S):
out.append((m.start(), m.group(1)))
return out
def looks_like_sql(s):
"""لازم يبقى SQL فعلي — جمل إنجليزي فيها كلمة from كانت بتتحسب غلط."""
if len(s) < 18:
return False
if not re.search(r'^\s*\(?\s*(SELECT|INSERT|UPDATE|DELETE|REPLACE)\b', s, re.I):
return False
return bool(re.search(r'\b(FROM|JOIN|INSERT\s+INTO|UPDATE)\s+[`\w]', s, re.I))
def alias_map(sql, schema):
"""
alias -> table لكل جدول حقيقي في الاستعلام.
الجداول المشتقة (SELECT ...) t بترجع None عشان نتجاهل أعمدتها.
"""
aliases = {}
derived = set()
# (SELECT ...) alias → جدول مشتق، أعمدته محسوبة مش من الـ schema
for m in re.finditer(r'\)\s*(?:AS\s+)?([a-zA-Z_]\w*)', sql, re.I):
name = m.group(1).lower()
if name not in SQL_KEYWORDS:
derived.add(name)
# ON DUPLICATE KEY UPDATE / ON UPDATE CURRENT_TIMESTAMP مش أسماء جداول
sql = re.sub(r'ON\s+DUPLICATE\s+KEY\s+UPDATE', ' ', sql, flags=re.I)
sql = re.sub(r'ON\s+UPDATE\s+CURRENT_TIMESTAMP', ' ', sql, flags=re.I)
pattern = r'\b(?:FROM|JOIN|UPDATE|INSERT\s+INTO)\s+`?([a-zA-Z_]\w*)`?(?:\s+(?:AS\s+)?`?([a-zA-Z_]\w*)`?)?'
for m in re.finditer(pattern, sql, re.I):
table = m.group(1).lower()
alias = (m.group(2) or '').lower()
if table in SQL_KEYWORDS or table not in schema:
continue
aliases[table] = table
if alias and alias not in SQL_KEYWORDS:
aliases[alias] = table
for d in derived:
aliases.pop(d, None)
return aliases, derived
def check_sql(sql, schema):
"""بيرجّع list من (alias.column, table) اللي مش موجودة."""
problems = []
aliases, derived = alias_map(sql, schema)
if not aliases:
return problems
for m in re.finditer(r'\b([a-zA-Z_]\w*)\.([a-zA-Z_]\w*)\b', sql):
alias, col = m.group(1).lower(), m.group(2).lower()
if alias in derived or alias in SQL_KEYWORDS or col in SQL_KEYWORDS:
continue
table = aliases.get(alias)
if table is None:
continue # alias مش معروف (subquery أو CTE) — نتجاهله
if col not in schema.get(table, set()):
problems.append((f'{alias}.{col}', table))
# الأعمدة من غير alias: نتأكد منها بس لما يكون في جدول واحد بالظبط
# في الاستعلام، وساعتها ما فيش لبس في اسم العمود.
tables = set(aliases.values())
if len(tables) == 1 and not derived and not re.search(r'\bSELECT\b.*\bSELECT\b', sql, re.I | re.S):
table = next(iter(tables))
cols = schema.get(table, set())
body = re.sub(r"'[^']*'", "''", sql) # شيل النصوص عشان ما نقراش جواها
body = re.sub(r'\b\w+\s*\(', ' ( ', body) # شيل أسماء الدوال
for m in re.finditer(r'\b([a-zA-Z_]\w*)\b', body):
col = m.group(1).lower()
# الاختصارات القصيرة دي بتبقى aliases لجداول تانية غالبًا، مش أعمدة
if len(col) <= 2:
continue
if col in SQL_KEYWORDS or col == table or col in aliases:
continue
if col in cols:
continue
# بس اللي في سياق عمود فعلي (بعد SELECT/WHERE/AND/ON/BY/SET/,)
before = body[max(0, m.start() - 14):m.start()]
if not re.search(r'(?:SELECT|WHERE|AND|OR|ON|BY|SET|,|\()\s*$', before, re.I):
continue
problems.append((f'(bare) {col}', table))
return problems
def unknown_tables(sql, schema):
out = []
sql = re.sub(r'ON\s+DUPLICATE\s+KEY\s+UPDATE', ' ', sql, flags=re.I)
sql = re.sub(r'ON\s+UPDATE\s+CURRENT_TIMESTAMP', ' ', sql, flags=re.I)
pattern = r'\b(?:FROM|JOIN|INSERT\s+INTO|UPDATE)\s+`?([a-zA-Z_]\w*)`?'
for m in re.finditer(pattern, sql, re.I):
t = m.group(1).lower()
if t in SQL_KEYWORDS or t in schema:
continue
# الجداول المؤقتة والمشتقة بتتكتب كده كتير — نتأكد إنه اسم جدول فعلًا
if re.match(r'^(dual|information_schema)$', t):
continue
out.append(t)
return out
def line_of(src, pos):
return src.count('\n', 0, pos) + 1
def main():
targets = sys.argv[1:] or ['app']
with connect() as conn, conn.cursor() as cur:
schema = load_schema(cur)
print(f'schema: {len(schema)} tables\n')
files = []
for t in targets:
p = os.path.join(ROOT, t) if not os.path.isabs(t) else t
if os.path.isfile(p):
files.append(p)
continue
for dirpath, _dirs, names in os.walk(p):
for n in names:
if n.endswith('.php'):
files.append(os.path.join(dirpath, n))
total = 0
seen = set()
for f in sorted(files):
try:
src = open(f, encoding='utf-8').read()
except Exception:
continue
rel = os.path.relpath(f, ROOT)
for pos, s in php_strings(src):
if not looks_like_sql(s):
continue
probs = check_sql(s, schema)
for tbl in unknown_tables(s, schema):
probs.append((f'(table) {tbl}', '—'))
for ref, table in probs:
key = (rel, ref, table)
if key in seen:
continue
seen.add(key)
total += 1
print(f'{rel}:{line_of(src, pos)} {ref} [{table}]')
print(f'\n{total} suspicious reference(s)')
return 1 if total else 0
if __name__ == '__main__':
sys.exit(main())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment