yearning-cli 0.2.0__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.
- {yearning_cli-0.2.0 → yearning_cli-0.2.1}/.github/workflows/docker-image.yml +1 -1
- {yearning_cli-0.2.0 → yearning_cli-0.2.1}/.github/workflows/publish-pypi.yml +1 -1
- {yearning_cli-0.2.0 → yearning_cli-0.2.1}/PKG-INFO +7 -1
- {yearning_cli-0.2.0 → yearning_cli-0.2.1}/README.md +6 -0
- {yearning_cli-0.2.0 → yearning_cli-0.2.1}/mysql_proxy/README.md +6 -0
- {yearning_cli-0.2.0 → yearning_cli-0.2.1}/mysql_proxy/server.py +196 -14
- {yearning_cli-0.2.0 → yearning_cli-0.2.1}/pyproject.toml +1 -1
- yearning_cli-0.2.1/tests/test_proxy_routing.py +98 -0
- {yearning_cli-0.2.0 → yearning_cli-0.2.1}/uv.lock +1 -1
- {yearning_cli-0.2.0 → yearning_cli-0.2.1}/.dockerignore +0 -0
- {yearning_cli-0.2.0 → yearning_cli-0.2.1}/.gitignore +0 -0
- {yearning_cli-0.2.0 → yearning_cli-0.2.1}/.python-version +0 -0
- {yearning_cli-0.2.0 → yearning_cli-0.2.1}/Dockerfile +0 -0
- {yearning_cli-0.2.0 → yearning_cli-0.2.1}/docker-compose.yml +0 -0
- {yearning_cli-0.2.0 → yearning_cli-0.2.1}/mysql_proxy/__init__.py +0 -0
- {yearning_cli-0.2.0 → yearning_cli-0.2.1}/mysql_proxy/protocol.py +0 -0
- {yearning_cli-0.2.0 → yearning_cli-0.2.1}/renovate.json +0 -0
- {yearning_cli-0.2.0 → yearning_cli-0.2.1}/yearning_cli/__init__.py +0 -0
- {yearning_cli-0.2.0 → yearning_cli-0.2.1}/yearning_cli/__main__.py +0 -0
- {yearning_cli-0.2.0 → yearning_cli-0.2.1}/yearning_cli/cli.py +0 -0
- {yearning_cli-0.2.0 → yearning_cli-0.2.1}/yearning_cli/client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: yearning-cli
|
|
3
|
-
Version: 0.2.
|
|
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
|
| 参数 | 说明 | 默认值 |
|
|
@@ -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.
|
|
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 =
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|