yearning-cli 0.1.9__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,23 +17,23 @@ jobs:
17
17
 
18
18
  steps:
19
19
  - name: Checkout
20
- uses: actions/checkout@v6
20
+ uses: actions/checkout@v7
21
21
 
22
22
  - name: Set up QEMU
23
- uses: docker/setup-qemu-action@v3
23
+ uses: docker/setup-qemu-action@v4
24
24
 
25
25
  - name: Set up Docker Buildx
26
- uses: docker/setup-buildx-action@v3
26
+ uses: docker/setup-buildx-action@v4
27
27
 
28
28
  - name: Login to Docker Hub
29
- uses: docker/login-action@v3
29
+ uses: docker/login-action@v4
30
30
  with:
31
31
  username: ${{ secrets.DOCKERHUB_USERNAME }}
32
32
  password: ${{ secrets.DOCKERHUB_TOKEN }}
33
33
 
34
34
  - name: Docker metadata
35
35
  id: meta
36
- uses: docker/metadata-action@v5
36
+ uses: docker/metadata-action@v6
37
37
  with:
38
38
  images: ${{ env.IMAGE_NAME }}
39
39
  tags: |
@@ -41,7 +41,7 @@ jobs:
41
41
  type=raw,value=latest
42
42
 
43
43
  - name: Build and push
44
- uses: docker/build-push-action@v6
44
+ uses: docker/build-push-action@v7
45
45
  with:
46
46
  context: .
47
47
  file: ./Dockerfile
@@ -15,7 +15,7 @@ jobs:
15
15
 
16
16
  steps:
17
17
  - name: Checkout
18
- uses: actions/checkout@v6
18
+ uses: actions/checkout@v7
19
19
 
20
20
  - name: Set up Python
21
21
  uses: actions/setup-python@v6
@@ -2,6 +2,7 @@ FROM python:3.14-slim
2
2
 
3
3
  ENV PYTHONUNBUFFERED=1 \
4
4
  PIP_NO_CACHE_DIR=1 \
5
+ TZ=Asia/Shanghai \
5
6
  SQL_PROXY_HOST=0.0.0.0 \
6
7
  SQL_PROXY_PORT=3307
7
8
 
@@ -18,7 +19,7 @@ EXPOSE 3307
18
19
  HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
19
20
  CMD python -c "import os, socket; s=socket.create_connection(('127.0.0.1', int(os.getenv('SQL_PROXY_PORT', '3307'))), 3); s.close()"
20
21
 
21
- CMD sql proxy \
22
+ CMD sql proxy -v \
22
23
  --host "${SQL_PROXY_HOST}" \
23
24
  --port "${SQL_PROXY_PORT}" \
24
25
  ${SQL_PROXY_SOURCE:+--source "$SQL_PROXY_SOURCE"} \
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yearning-cli
3
- Version: 0.1.9
3
+ Version: 0.2.1
4
4
  Summary: Yearning MySQL Audit Platform CLI Tool
5
5
  Project-URL: Homepage, https://github.com/la3rence/yearning-cli
6
6
  Project-URL: Repository, https://github.com/la3rence/yearning-cli
@@ -148,6 +148,12 @@ mysql -h 127.0.0.1 -P 3307 -u 数据源名 数据库名
148
148
  # Host: 127.0.0.1 / 机器局域网 IP / 反代入口 Port: 3307 Username: 数据源名称 Password: 留空
149
149
  ```
150
150
 
151
+ 代理会根据数据库名自动切换 Yearning 数据源:
152
+
153
+ - 执行 `USE app_billing` 或客户端发送 COM_INIT_DB 时,代理会在可用数据源中查找包含该库的数据源并切换
154
+ - 查询中出现 `db.table` 形式(例如 `SELECT count(1) FROM app_billing.invoice`)时,也会先按 `db` 解析数据源
155
+ - 如果同名数据库存在于多个数据源,代理会优先选择数据源名称与库名更匹配的源(例如 `app_billing` 优先匹配名称包含 `billing` 的数据源);无法判断时保留当前数据源
156
+
151
157
  代理参数:
152
158
 
153
159
  | 参数 | 说明 | 默认值 |
@@ -133,6 +133,12 @@ mysql -h 127.0.0.1 -P 3307 -u 数据源名 数据库名
133
133
  # Host: 127.0.0.1 / 机器局域网 IP / 反代入口 Port: 3307 Username: 数据源名称 Password: 留空
134
134
  ```
135
135
 
136
+ 代理会根据数据库名自动切换 Yearning 数据源:
137
+
138
+ - 执行 `USE app_billing` 或客户端发送 COM_INIT_DB 时,代理会在可用数据源中查找包含该库的数据源并切换
139
+ - 查询中出现 `db.table` 形式(例如 `SELECT count(1) FROM app_billing.invoice`)时,也会先按 `db` 解析数据源
140
+ - 如果同名数据库存在于多个数据源,代理会优先选择数据源名称与库名更匹配的源(例如 `app_billing` 优先匹配名称包含 `billing` 的数据源);无法判断时保留当前数据源
141
+
136
142
  代理参数:
137
143
 
138
144
  | 参数 | 说明 | 默认值 |
@@ -12,6 +12,7 @@ services:
12
12
  YEARNING_URL: "${YEARNING_URL:-http://192.168.1.135}"
13
13
  YEARNING_USER: "${YEARNING_USER:-}"
14
14
  YEARNING_PASS: "${YEARNING_PASS:-}"
15
+ TZ: "Asia/Shanghai"
15
16
  SQL_PROXY_HOST: "0.0.0.0"
16
17
  SQL_PROXY_PORT: "${SQL_PROXY_PORT:-3307}"
17
18
  SQL_PROXY_SOURCE: "${SQL_PROXY_SOURCE:-}"
@@ -26,6 +26,12 @@ MySQL 协议代理模块,让 DBeaver、Navicat、MySQL CLI 等客户端通过
26
26
 
27
27
  密码字段无需填写,任意值均可。
28
28
 
29
+ 连接后也支持按数据库名自动切换数据源:
30
+
31
+ - `USE dbname` 和客户端的切换数据库命令会查找包含该库的数据源
32
+ - 查询中的 `dbname.table_name` 会按 `dbname` 自动路由到对应数据源
33
+ - 同名数据库出现在多个数据源时,会优先选择数据源名称与库名更匹配的源;无法判断时继续使用当前数据源
34
+
29
35
  ## 前提条件
30
36
 
31
37
  1. 已运行 `sql login` 登录 Yearning,或已配置 `YEARNING_USER`/`YEARNING_PASS`、`~/.sqlrc` 的 `yearning_user`/`yearning_pass` 供 token 失效时自动登录
@@ -4,6 +4,7 @@ import hashlib
4
4
  import logging
5
5
  import os
6
6
  import random
7
+ import re
7
8
  import socket
8
9
  import sys
9
10
  import threading
@@ -42,9 +43,22 @@ class ConnectionHandler:
42
43
  _lock = threading.Lock()
43
44
  _cache_lock = threading.Lock()
44
45
  _source_cache = {}
46
+ _database_source_cache = {}
45
47
  _query_order_cache = {}
46
48
  SOURCE_CACHE_TTL = 60
49
+ DATABASE_SOURCE_CACHE_TTL = 300
47
50
  QUERY_ORDER_TTL = 600
51
+ _QUALIFIED_TABLE_RE = re.compile(
52
+ r"\b(?:FROM|JOIN|UPDATE|INTO|TABLE|DESC|DESCRIBE)\s+"
53
+ r"`?([A-Za-z_][\w$]*)`?\s*\.\s*`?([A-Za-z_][\w$]*)`?",
54
+ re.IGNORECASE,
55
+ )
56
+ _SYSTEM_SCHEMAS = {
57
+ "information_schema",
58
+ "mysql",
59
+ "performance_schema",
60
+ "sys",
61
+ }
48
62
 
49
63
  def __init__(self, conn: socket.socket, addr, config: dict):
50
64
  self.conn = conn
@@ -202,7 +216,9 @@ class ConnectionHandler:
202
216
  self._init_yearning()
203
217
  return self.yearning and self.yearning.token
204
218
 
205
- def _ensure_query_order(self):
219
+ def _ensure_query_order(self, source_id=None):
220
+ if source_id:
221
+ self.source_id = source_id
206
222
  if not self._ensure_yearning_initialized() or not self.source_id:
207
223
  return False
208
224
 
@@ -316,8 +332,7 @@ class ConnectionHandler:
316
332
  self._handle_query(sql)
317
333
  elif cmd == 0x02: # COM_INIT_DB (USE database)
318
334
  db = payload[1:].decode("utf-8", errors="replace").strip()
319
- self.current_db = db
320
- self._send(make_ok(message=f"Changed to database '{db}'"))
335
+ self._handle_use_database(db)
321
336
  elif cmd == 0x0E: # COM_PING
322
337
  self._send(make_ok())
323
338
  elif cmd == 0x01: # COM_QUIT
@@ -358,9 +373,11 @@ class ConnectionHandler:
358
373
  self._send(make_ok(message="OK"))
359
374
  return
360
375
  if upper.startswith("USE "):
361
- db = sql[3:].strip().strip("`\"'")
362
- self.current_db = db
363
- self._send(make_ok(message=f"Changed to database '{db}'"))
376
+ db = self._parse_use_database(sql)
377
+ if not db:
378
+ self._send(make_err(1064, "Syntax error"))
379
+ return
380
+ self._handle_use_database(db)
364
381
  return
365
382
  # SELECT @@version, SELECT @@version_comment, etc.
366
383
  if upper.startswith("SELECT @@"):
@@ -404,13 +421,14 @@ class ConnectionHandler:
404
421
  self._send(make_err(1046, "No data source configured. Set source_id in config."))
405
422
  return
406
423
 
407
- if not self.current_db:
424
+ source_id, database = self._resolve_query_route(sql)
425
+ if not database:
408
426
  self._send(make_err(1046, "No database selected"))
409
427
  return
410
428
 
411
- self._ensure_query_order()
429
+ self._ensure_query_order(source_id)
412
430
  try:
413
- result = self.yearning.execute_query(self.source_id, self.current_db, sql, timeout=120)
431
+ result = self.yearning.execute_query(source_id, database, sql, timeout=120)
414
432
  except Exception as e:
415
433
  logger.error("Query error: %s", e)
416
434
  self._send(make_err(1105, str(e), "HY000"))
@@ -422,9 +440,9 @@ class ConnectionHandler:
422
440
  if "工单" in error_msg or "expired" in error_msg.lower():
423
441
  logger.info("Query order expired, recreating...")
424
442
  try:
425
- if self.yearning.create_query_order(self.source_id, verify_wait=0.1):
443
+ if self.yearning.create_query_order(source_id, verify_wait=0.1):
426
444
  self._mark_query_order_ready()
427
- result = self.yearning.execute_query(self.source_id, self.current_db, sql, timeout=120)
445
+ result = self.yearning.execute_query(source_id, database, sql, timeout=120)
428
446
  if result["success"]:
429
447
  self._mark_query_order_ready()
430
448
  self._send_result(result)
@@ -437,6 +455,169 @@ class ConnectionHandler:
437
455
  self._mark_query_order_ready()
438
456
  self._send_result(result)
439
457
 
458
+ @staticmethod
459
+ def _parse_use_database(sql: str) -> str:
460
+ """Return database from a USE statement."""
461
+ value = sql.strip().rstrip(";").strip()
462
+ if not value[:3].upper() == "USE":
463
+ return ""
464
+ db = value[3:].strip()
465
+ if not db:
466
+ return ""
467
+ return db.strip("`\"'")
468
+
469
+ def _handle_use_database(self, db: str):
470
+ """Switch the active database and source when the database is known elsewhere."""
471
+ db = db.strip().rstrip(";").strip("`\"'")
472
+ if not db:
473
+ self._send(make_err(1064, "Syntax error"))
474
+ return
475
+ self._ensure_yearning_initialized()
476
+ if self.yearning and self.yearning.token:
477
+ source_id = self._resolve_source_for_database(db)
478
+ if source_id:
479
+ self._set_route(source_id, db)
480
+ self.current_db = db
481
+ self._send(make_ok(message=f"Changed to database '{db}'"))
482
+
483
+ def _set_route(self, source_id: str, database: str):
484
+ """Update the connection route when it changes."""
485
+ if source_id and source_id != self.source_id:
486
+ logger.info(
487
+ "Switching source for database '%s': %s -> %s",
488
+ database,
489
+ self.source_id[:12] if self.source_id else "NONE",
490
+ source_id[:12],
491
+ )
492
+ self.source_id = source_id
493
+ self.current_db = database
494
+
495
+ def _resolve_query_route(self, sql: str):
496
+ """Resolve the source/database for a query before sending it to Yearning."""
497
+ explicit_db = self._first_explicit_database(sql)
498
+ database = explicit_db or self.current_db
499
+ if database:
500
+ source_id = self._resolve_source_for_database(database)
501
+ if source_id:
502
+ self._set_route(source_id, database)
503
+ return self.source_id, database
504
+
505
+ def _first_explicit_database(self, sql: str) -> str:
506
+ """Find the first non-system schema in a qualified table reference."""
507
+ for match in self._QUALIFIED_TABLE_RE.finditer(sql):
508
+ schema = match.group(1)
509
+ if schema.lower() not in self._SYSTEM_SCHEMAS:
510
+ return schema
511
+ return ""
512
+
513
+ def _resolve_source_for_database(self, database: str):
514
+ """Find the Yearning source that contains a database."""
515
+ if not database or not self._ensure_yearning_initialized():
516
+ return ""
517
+
518
+ key = self._cache_key("database_source", database)
519
+ now = time.monotonic()
520
+ with self._cache_lock:
521
+ entry = self._database_source_cache.get(key)
522
+ if entry and entry["expires_at"] > now:
523
+ return entry["source_id"]
524
+
525
+ matches = []
526
+ for source in self._list_sources_cached():
527
+ source_id = source.get("source_id", "")
528
+ if not source_id:
529
+ continue
530
+ if self._source_has_database(source_id, database):
531
+ matches.append(source)
532
+
533
+ source_id = ""
534
+ if len(matches) == 1:
535
+ source_id = matches[0].get("source_id", "")
536
+ elif len(matches) > 1:
537
+ source_id = self._choose_source_for_database(database, matches)
538
+ if len(matches) > 1 and not source_id:
539
+ logger.warning(
540
+ "Database '%s' exists in multiple sources; keeping current source",
541
+ database,
542
+ )
543
+ self._cache_database_source(database, source_id)
544
+ return source_id
545
+
546
+ def _choose_source_for_database(self, database: str, sources: list):
547
+ """Choose the most specific source when a database exists in multiple sources."""
548
+ ranked = [
549
+ (self._database_source_score(database, source), source)
550
+ for source in sources
551
+ ]
552
+ ranked = [item for item in ranked if item[0] > 0]
553
+ if not ranked:
554
+ return ""
555
+ ranked.sort(key=lambda item: item[0], reverse=True)
556
+ best_score, best_source = ranked[0]
557
+ if len(ranked) > 1 and ranked[1][0] == best_score:
558
+ logger.warning(
559
+ "Database '%s' has equally specific source matches: %s",
560
+ database,
561
+ ", ".join(
562
+ source.get("source", "")
563
+ for score, source in ranked
564
+ if score == best_score
565
+ ),
566
+ )
567
+ return ""
568
+ source_id = best_source.get("source_id", "")
569
+ logger.info(
570
+ "Resolved database '%s' to source '%s' by source-name match",
571
+ database,
572
+ best_source.get("source", source_id),
573
+ )
574
+ return source_id
575
+
576
+ @classmethod
577
+ def _database_source_score(cls, database: str, source: dict) -> int:
578
+ source_name = source.get("source", "")
579
+ db_norm = cls._normalize_identifier(database)
580
+ source_norm = cls._normalize_identifier(source_name)
581
+ if not db_norm or not source_norm:
582
+ return 0
583
+ if source_norm == db_norm:
584
+ return 100
585
+ if db_norm in source_norm:
586
+ return 90
587
+
588
+ db_tokens = [token for token in db_norm.split("_") if token]
589
+ source_tokens = set(token for token in source_norm.split("_") if token)
590
+ score = 0
591
+ if db_tokens and db_tokens[-1] in source_tokens:
592
+ score += 70
593
+ score += 10 * len(set(db_tokens) & source_tokens)
594
+ return score
595
+
596
+ @staticmethod
597
+ def _normalize_identifier(value: str) -> str:
598
+ return re.sub(r"_+", "_", re.sub(r"[^a-z0-9]+", "_", value.lower())).strip("_")
599
+
600
+ def _source_has_database(self, source_id: str, database: str) -> bool:
601
+ try:
602
+ return database in self.yearning.list_databases(source_id)
603
+ except Exception as e:
604
+ logger.debug(
605
+ "Failed to list databases for source %s: %s",
606
+ source_id[:12] if source_id else "NONE",
607
+ e,
608
+ )
609
+ return False
610
+
611
+ def _cache_database_source(self, database: str, source_id: str):
612
+ if not source_id:
613
+ return
614
+ key = self._cache_key("database_source", database)
615
+ with self._cache_lock:
616
+ self._database_source_cache[key] = {
617
+ "source_id": source_id,
618
+ "expires_at": time.monotonic() + self.DATABASE_SOURCE_CACHE_TTL,
619
+ }
620
+
440
621
  def _fake_sysvar(self, var: str) -> str:
441
622
  """Return fake system variable values for client compatibility."""
442
623
  var = var.upper().lstrip("@")
@@ -530,12 +711,13 @@ class ConnectionHandler:
530
711
  if not self.source_id:
531
712
  self._send(make_err(1046, "No data source configured"))
532
713
  return
533
- if not self.current_db:
714
+ source_id, database = self._resolve_query_route(sql)
715
+ if not database:
534
716
  self._send(make_err(1046, "No database selected"))
535
717
  return
536
- self._ensure_query_order()
718
+ self._ensure_query_order(source_id)
537
719
  try:
538
- result = self.yearning.execute_query(self.source_id, self.current_db, sql, timeout=120)
720
+ result = self.yearning.execute_query(source_id, database, sql, timeout=120)
539
721
  except Exception as e:
540
722
  self._send(make_err(1105, str(e)))
541
723
  return
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "yearning-cli"
3
- version = "0.1.9"
3
+ version = "0.2.1"
4
4
  description = "Yearning MySQL Audit Platform CLI Tool"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -0,0 +1,98 @@
1
+ import unittest
2
+
3
+ from mysql_proxy.server import ConnectionHandler
4
+
5
+
6
+ class FakeYearning:
7
+ token = "token"
8
+
9
+ def __init__(self):
10
+ self.databases = {
11
+ "source-shared": ["app_billing", "app_orders"],
12
+ "source-billing": ["app_billing"],
13
+ "source-orders": ["app_orders"],
14
+ "source-a": ["shared"],
15
+ "source-b": ["shared"],
16
+ }
17
+
18
+ def list_sources(self):
19
+ return [
20
+ {"source": "shared-apps", "source_id": "source-shared"},
21
+ {"source": "prod-billing", "source_id": "source-billing"},
22
+ {"source": "readonly-orders", "source_id": "source-orders"},
23
+ {"source": "a", "source_id": "source-a"},
24
+ {"source": "b", "source_id": "source-b"},
25
+ ]
26
+
27
+ def list_databases(self, source_id):
28
+ return self.databases[source_id]
29
+
30
+
31
+ def make_handler():
32
+ handler = ConnectionHandler.__new__(ConnectionHandler)
33
+ handler.config = {"base_url": "http://yearning.local"}
34
+ handler.yearning = FakeYearning()
35
+ handler.source_id = "source-orders"
36
+ handler.current_db = "app_orders"
37
+ return handler
38
+
39
+
40
+ class ProxyRoutingTest(unittest.TestCase):
41
+ def test_explicit_database_routes_to_matching_source(self):
42
+ handler = make_handler()
43
+
44
+ source_id, database = handler._resolve_query_route(
45
+ "select count(1) from app_orders.order_record"
46
+ )
47
+
48
+ self.assertEqual(source_id, "source-orders")
49
+ self.assertEqual(database, "app_orders")
50
+ self.assertEqual(handler.source_id, "source-orders")
51
+ self.assertEqual(handler.current_db, "app_orders")
52
+
53
+ def test_specific_source_beats_broad_source_when_database_is_shared(self):
54
+ handler = make_handler()
55
+
56
+ source_id, database = handler._resolve_query_route(
57
+ "select * from app_billing.invoice_config"
58
+ )
59
+
60
+ self.assertEqual(source_id, "source-billing")
61
+ self.assertEqual(database, "app_billing")
62
+ self.assertEqual(handler.source_id, "source-billing")
63
+ self.assertEqual(handler.current_db, "app_billing")
64
+
65
+ def test_current_database_routes_before_unqualified_query(self):
66
+ handler = make_handler()
67
+ handler.current_db = "app_orders"
68
+
69
+ source_id, database = handler._resolve_query_route(
70
+ "select count(1) from order_record"
71
+ )
72
+
73
+ self.assertEqual(source_id, "source-orders")
74
+ self.assertEqual(database, "app_orders")
75
+
76
+ def test_column_reference_is_not_treated_as_database(self):
77
+ handler = make_handler()
78
+
79
+ source_id, database = handler._resolve_query_route(
80
+ "select t.id from order_record t"
81
+ )
82
+
83
+ self.assertEqual(source_id, "source-orders")
84
+ self.assertEqual(database, "app_orders")
85
+
86
+ def test_ambiguous_database_keeps_current_source(self):
87
+ handler = make_handler()
88
+
89
+ source_id, database = handler._resolve_query_route(
90
+ "select count(1) from shared.some_table"
91
+ )
92
+
93
+ self.assertEqual(source_id, "source-orders")
94
+ self.assertEqual(database, "shared")
95
+
96
+
97
+ if __name__ == "__main__":
98
+ unittest.main()
@@ -218,7 +218,7 @@ wheels = [
218
218
 
219
219
  [[package]]
220
220
  name = "yearning-cli"
221
- version = "0.1.9"
221
+ version = "0.2.1"
222
222
  source = { editable = "." }
223
223
  dependencies = [
224
224
  { name = "msgpack" },
@@ -1,6 +1,7 @@
1
1
  """CLI entry point for yearning-cli."""
2
2
 
3
3
  import argparse
4
+ from datetime import datetime, timedelta, timezone
4
5
  import logging
5
6
  import os
6
7
  import sys
@@ -12,6 +13,17 @@ from mysql_proxy.server import serve
12
13
 
13
14
  BASE_URL = os.environ.get("YEARNING_URL", "http://192.168.1.135")
14
15
  CONFIG_FILE = os.path.expanduser("~/.sqlrc")
16
+ EAST_8 = timezone(timedelta(hours=8))
17
+
18
+
19
+ class East8Formatter(logging.Formatter):
20
+ """Format log timestamps with a fixed UTC+8 timezone."""
21
+
22
+ def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str:
23
+ dt = datetime.fromtimestamp(record.created, tz=EAST_8)
24
+ if datefmt:
25
+ return dt.strftime(datefmt)
26
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
15
27
 
16
28
 
17
29
  def load_config() -> dict:
@@ -359,10 +371,17 @@ def cmd_shell(args: argparse.Namespace) -> None:
359
371
  def cmd_proxy(args: argparse.Namespace) -> None:
360
372
  """Start MySQL protocol proxy server."""
361
373
  level = logging.DEBUG if VERBOSE else logging.INFO
374
+ handler = logging.StreamHandler()
375
+ handler.setFormatter(
376
+ East8Formatter(
377
+ fmt="%(asctime)s [%(levelname)s] %(message)s",
378
+ datefmt="%Y-%m-%d %H:%M:%S",
379
+ )
380
+ )
362
381
  logging.basicConfig(
363
382
  level=level,
364
- format="%(asctime)s [%(levelname)s] %(message)s",
365
- datefmt="%H:%M:%S",
383
+ handlers=[handler],
384
+ force=True,
366
385
  )
367
386
  if not VERBOSE:
368
387
  logging.getLogger("websocket").setLevel(logging.WARNING)
File without changes
File without changes
File without changes