sqlsaber 0.11.0__tar.gz → 0.13.0__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.
Potentially problematic release.
This version of sqlsaber might be problematic. Click here for more details.
- sqlsaber-0.13.0/.github/workflows/claude-code-review.yml +78 -0
- sqlsaber-0.13.0/.github/workflows/claude.yml +64 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/CHANGELOG.md +15 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/PKG-INFO +10 -1
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/README.md +9 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/pyproject.toml +1 -1
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/cli/commands.py +18 -29
- sqlsaber-0.13.0/src/sqlsaber/database/resolver.py +96 -0
- sqlsaber-0.13.0/tests/test_database_resolver.py +126 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/uv.lock +1 -1
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/.github/workflows/publish.yml +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/.gitignore +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/.python-version +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/AGENT.md +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/CLAUDE.md +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/LICENSE +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/pytest.ini +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/sqlsaber.svg +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/__init__.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/__main__.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/agents/__init__.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/agents/anthropic.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/agents/base.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/agents/mcp.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/agents/streaming.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/cli/__init__.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/cli/auth.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/cli/completers.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/cli/database.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/cli/display.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/cli/interactive.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/cli/memory.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/cli/models.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/cli/streaming.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/clients/__init__.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/clients/anthropic.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/clients/base.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/clients/exceptions.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/clients/models.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/clients/streaming.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/config/__init__.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/config/api_keys.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/config/auth.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/config/database.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/config/oauth_flow.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/config/oauth_tokens.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/config/settings.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/database/__init__.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/database/connection.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/database/schema.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/mcp/__init__.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/mcp/mcp.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/memory/__init__.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/memory/manager.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/memory/storage.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/models/__init__.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/models/events.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/src/sqlsaber/models/types.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/tests/__init__.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/tests/conftest.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/tests/test_agents/test_anthropic_oauth.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/tests/test_cli/__init__.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/tests/test_cli/test_commands.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/tests/test_clients/test_anthropic_client.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/tests/test_clients/test_streaming.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/tests/test_config/__init__.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/tests/test_config/test_database.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/tests/test_config/test_oauth.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/tests/test_config/test_settings.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/tests/test_database/__init__.py +0 -0
- {sqlsaber-0.11.0 → sqlsaber-0.13.0}/tests/test_database/test_connection.py +0 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
name: Claude Code Review
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
types: [opened, synchronize]
|
|
6
|
+
# Optional: Only run on specific file changes
|
|
7
|
+
# paths:
|
|
8
|
+
# - "src/**/*.ts"
|
|
9
|
+
# - "src/**/*.tsx"
|
|
10
|
+
# - "src/**/*.js"
|
|
11
|
+
# - "src/**/*.jsx"
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
claude-review:
|
|
15
|
+
# Optional: Filter by PR author
|
|
16
|
+
# if: |
|
|
17
|
+
# github.event.pull_request.user.login == 'external-contributor' ||
|
|
18
|
+
# github.event.pull_request.user.login == 'new-developer' ||
|
|
19
|
+
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
|
20
|
+
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
permissions:
|
|
23
|
+
contents: read
|
|
24
|
+
pull-requests: read
|
|
25
|
+
issues: read
|
|
26
|
+
id-token: write
|
|
27
|
+
|
|
28
|
+
steps:
|
|
29
|
+
- name: Checkout repository
|
|
30
|
+
uses: actions/checkout@v4
|
|
31
|
+
with:
|
|
32
|
+
fetch-depth: 1
|
|
33
|
+
|
|
34
|
+
- name: Run Claude Code Review
|
|
35
|
+
id: claude-review
|
|
36
|
+
uses: anthropics/claude-code-action@beta
|
|
37
|
+
with:
|
|
38
|
+
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
39
|
+
|
|
40
|
+
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
|
|
41
|
+
# model: "claude-opus-4-20250514"
|
|
42
|
+
|
|
43
|
+
# Direct prompt for automated review (no @claude mention needed)
|
|
44
|
+
direct_prompt: |
|
|
45
|
+
Please review this pull request and provide feedback on:
|
|
46
|
+
- Code quality and best practices
|
|
47
|
+
- Potential bugs or issues
|
|
48
|
+
- Performance considerations
|
|
49
|
+
- Security concerns
|
|
50
|
+
- Test coverage
|
|
51
|
+
|
|
52
|
+
Be constructive and helpful in your feedback.
|
|
53
|
+
|
|
54
|
+
# Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
|
|
55
|
+
# use_sticky_comment: true
|
|
56
|
+
|
|
57
|
+
# Optional: Customize review based on file types
|
|
58
|
+
# direct_prompt: |
|
|
59
|
+
# Review this PR focusing on:
|
|
60
|
+
# - For TypeScript files: Type safety and proper interface usage
|
|
61
|
+
# - For API endpoints: Security, input validation, and error handling
|
|
62
|
+
# - For React components: Performance, accessibility, and best practices
|
|
63
|
+
# - For tests: Coverage, edge cases, and test quality
|
|
64
|
+
|
|
65
|
+
# Optional: Different prompts for different authors
|
|
66
|
+
# direct_prompt: |
|
|
67
|
+
# ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
|
|
68
|
+
# 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||
|
|
69
|
+
# 'Please provide a thorough code review focusing on our coding standards and best practices.' }}
|
|
70
|
+
|
|
71
|
+
# Optional: Add specific tools for running tests or linting
|
|
72
|
+
# allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
|
|
73
|
+
|
|
74
|
+
# Optional: Skip review for certain conditions
|
|
75
|
+
# if: |
|
|
76
|
+
# !contains(github.event.pull_request.title, '[skip-review]') &&
|
|
77
|
+
# !contains(github.event.pull_request.title, '[WIP]')
|
|
78
|
+
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
name: Claude Code
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
issue_comment:
|
|
5
|
+
types: [created]
|
|
6
|
+
pull_request_review_comment:
|
|
7
|
+
types: [created]
|
|
8
|
+
issues:
|
|
9
|
+
types: [opened, assigned]
|
|
10
|
+
pull_request_review:
|
|
11
|
+
types: [submitted]
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
claude:
|
|
15
|
+
if: |
|
|
16
|
+
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
17
|
+
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
18
|
+
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
|
19
|
+
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
permissions:
|
|
22
|
+
contents: read
|
|
23
|
+
pull-requests: read
|
|
24
|
+
issues: read
|
|
25
|
+
id-token: write
|
|
26
|
+
actions: read # Required for Claude to read CI results on PRs
|
|
27
|
+
steps:
|
|
28
|
+
- name: Checkout repository
|
|
29
|
+
uses: actions/checkout@v4
|
|
30
|
+
with:
|
|
31
|
+
fetch-depth: 1
|
|
32
|
+
|
|
33
|
+
- name: Run Claude Code
|
|
34
|
+
id: claude
|
|
35
|
+
uses: anthropics/claude-code-action@beta
|
|
36
|
+
with:
|
|
37
|
+
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
38
|
+
|
|
39
|
+
# This is an optional setting that allows Claude to read CI results on PRs
|
|
40
|
+
additional_permissions: |
|
|
41
|
+
actions: read
|
|
42
|
+
|
|
43
|
+
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
|
|
44
|
+
# model: "claude-opus-4-20250514"
|
|
45
|
+
|
|
46
|
+
# Optional: Customize the trigger phrase (default: @claude)
|
|
47
|
+
# trigger_phrase: "/claude"
|
|
48
|
+
|
|
49
|
+
# Optional: Trigger when specific user is assigned to an issue
|
|
50
|
+
# assignee_trigger: "claude-bot"
|
|
51
|
+
|
|
52
|
+
# Optional: Allow Claude to run specific commands
|
|
53
|
+
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
|
|
54
|
+
|
|
55
|
+
# Optional: Add custom instructions for Claude to customize its behavior for your project
|
|
56
|
+
# custom_instructions: |
|
|
57
|
+
# Follow our coding standards
|
|
58
|
+
# Ensure all new code has tests
|
|
59
|
+
# Use TypeScript for new files
|
|
60
|
+
|
|
61
|
+
# Optional: Custom environment variables for Claude
|
|
62
|
+
# claude_env: |
|
|
63
|
+
# NODE_ENV: test
|
|
64
|
+
|
|
@@ -4,6 +4,21 @@ All notable changes to SQLSaber will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.13.0] - 2025-07-26
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Database resolver abstraction for unified connection handling
|
|
12
|
+
- Extended `-d` flag to accept PostgreSQL and MySQL connection strings (e.g., `postgresql://user:pass@host:5432/db`)
|
|
13
|
+
- Support for direct connection strings alongside existing file path and configured database support
|
|
14
|
+
- Examples: `saber -d "postgresql://user:pass@host:5432/db" "show users"`
|
|
15
|
+
|
|
16
|
+
## [0.12.0] - 2025-07-23
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Add support for ad-hoc SQLite files via `--database`/`-d` flag
|
|
21
|
+
|
|
7
22
|
## [0.11.0] - 2025-07-09
|
|
8
23
|
|
|
9
24
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlsaber
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: SQLSaber - Agentic SQL assistant like Claude Code
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.12
|
|
@@ -21,6 +21,15 @@ Description-Content-Type: text/markdown
|
|
|
21
21
|
|
|
22
22
|
# SQLSaber
|
|
23
23
|
|
|
24
|
+
```
|
|
25
|
+
███████ ██████ ██ ███████ █████ ██████ ███████ ██████
|
|
26
|
+
██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
|
|
27
|
+
███████ ██ ██ ██ ███████ ███████ ██████ █████ ██████
|
|
28
|
+
██ ██ ▄▄ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
|
|
29
|
+
███████ ██████ ███████ ███████ ██ ██ ██████ ███████ ██ ██
|
|
30
|
+
▀▀
|
|
31
|
+
```
|
|
32
|
+
|
|
24
33
|
> Use the agent Luke!
|
|
25
34
|
|
|
26
35
|
SQLSaber is an agentic SQL assistant. Think Claude Code but for SQL.
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# SQLSaber
|
|
2
2
|
|
|
3
|
+
```
|
|
4
|
+
███████ ██████ ██ ███████ █████ ██████ ███████ ██████
|
|
5
|
+
██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
|
|
6
|
+
███████ ██ ██ ██ ███████ ███████ ██████ █████ ██████
|
|
7
|
+
██ ██ ▄▄ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
|
|
8
|
+
███████ ██████ ███████ ███████ ██ ██ ██████ ███████ ██ ██
|
|
9
|
+
▀▀
|
|
10
|
+
```
|
|
11
|
+
|
|
3
12
|
> Use the agent Luke!
|
|
4
13
|
|
|
5
14
|
SQLSaber is an agentic SQL assistant. Think Claude Code but for SQL.
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import sys
|
|
5
|
-
from pathlib import Path
|
|
6
5
|
from typing import Annotated
|
|
7
6
|
|
|
8
7
|
import cyclopts
|
|
@@ -17,6 +16,7 @@ from sqlsaber.cli.models import create_models_app
|
|
|
17
16
|
from sqlsaber.cli.streaming import StreamingQueryHandler
|
|
18
17
|
from sqlsaber.config.database import DatabaseConfigManager
|
|
19
18
|
from sqlsaber.database.connection import DatabaseConnection
|
|
19
|
+
from sqlsaber.database.resolver import DatabaseResolutionError, resolve_database
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
class CLIError(Exception):
|
|
@@ -43,7 +43,7 @@ def meta_handler(
|
|
|
43
43
|
str | None,
|
|
44
44
|
cyclopts.Parameter(
|
|
45
45
|
["--database", "-d"],
|
|
46
|
-
help="Database connection name (uses default if not specified)",
|
|
46
|
+
help="Database connection name, file path (CSV/SQLite), or connection string (postgresql://, mysql://) (uses default if not specified)",
|
|
47
47
|
),
|
|
48
48
|
] = None,
|
|
49
49
|
):
|
|
@@ -54,6 +54,10 @@ def meta_handler(
|
|
|
54
54
|
saber # Start interactive mode
|
|
55
55
|
saber "show me all users" # Run a single query with default database
|
|
56
56
|
saber -d mydb "show me users" # Run a query with specific database
|
|
57
|
+
saber -d data.csv "show me users" # Run a query with ad-hoc CSV file
|
|
58
|
+
saber -d data.db "show me users" # Run a query with ad-hoc SQLite file
|
|
59
|
+
saber -d "postgresql://user:pass@host:5432/db" "show users" # PostgreSQL connection string
|
|
60
|
+
saber -d "mysql://user:pass@host:3306/db" "show users" # MySQL connection string
|
|
57
61
|
echo "show me all users" | saber # Read query from stdin
|
|
58
62
|
cat query.txt | saber # Read query from file via stdin
|
|
59
63
|
"""
|
|
@@ -73,7 +77,7 @@ def query(
|
|
|
73
77
|
str | None,
|
|
74
78
|
cyclopts.Parameter(
|
|
75
79
|
["--database", "-d"],
|
|
76
|
-
help="Database connection name (uses default if not specified)",
|
|
80
|
+
help="Database connection name, file path (CSV/SQLite), or connection string (postgresql://, mysql://) (uses default if not specified)",
|
|
77
81
|
),
|
|
78
82
|
] = None,
|
|
79
83
|
):
|
|
@@ -88,6 +92,10 @@ def query(
|
|
|
88
92
|
Examples:
|
|
89
93
|
saber # Start interactive mode
|
|
90
94
|
saber "show me all users" # Run a single query
|
|
95
|
+
saber -d data.csv "show users" # Run a query with ad-hoc CSV file
|
|
96
|
+
saber -d data.db "show users" # Run a query with ad-hoc SQLite file
|
|
97
|
+
saber -d "postgresql://user:pass@host:5432/db" "show users" # PostgreSQL connection string
|
|
98
|
+
saber -d "mysql://user:pass@host:3306/db" "show users" # MySQL connection string
|
|
91
99
|
echo "show me all users" | saber # Read query from stdin
|
|
92
100
|
"""
|
|
93
101
|
|
|
@@ -101,32 +109,13 @@ def query(
|
|
|
101
109
|
# If stdin was empty, fall back to interactive mode
|
|
102
110
|
actual_query = None
|
|
103
111
|
|
|
104
|
-
#
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
connection_string = f"csv:///{csv_path}"
|
|
112
|
-
db_name = csv_path.stem
|
|
113
|
-
else:
|
|
114
|
-
# Look up configured database connection
|
|
115
|
-
db_config = config_manager.get_database(database)
|
|
116
|
-
if not db_config:
|
|
117
|
-
raise CLIError(
|
|
118
|
-
f"Database connection '{database}' not found. Use 'sqlsaber db list' to see available connections."
|
|
119
|
-
)
|
|
120
|
-
connection_string = db_config.to_connection_string()
|
|
121
|
-
db_name = db_config.name
|
|
122
|
-
else:
|
|
123
|
-
db_config = config_manager.get_default_database()
|
|
124
|
-
if not db_config:
|
|
125
|
-
raise CLIError(
|
|
126
|
-
"No database connections configured. Use 'sqlsaber db add <name>' to add a database connection."
|
|
127
|
-
)
|
|
128
|
-
connection_string = db_config.to_connection_string()
|
|
129
|
-
db_name = db_config.name
|
|
112
|
+
# Resolve database from CLI input
|
|
113
|
+
try:
|
|
114
|
+
resolved = resolve_database(database, config_manager)
|
|
115
|
+
connection_string = resolved.connection_string
|
|
116
|
+
db_name = resolved.name
|
|
117
|
+
except DatabaseResolutionError as e:
|
|
118
|
+
raise CLIError(str(e))
|
|
130
119
|
|
|
131
120
|
# Create database connection
|
|
132
121
|
try:
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Database connection resolution from CLI input."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
from sqlsaber.config.database import DatabaseConfig, DatabaseConfigManager
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DatabaseResolutionError(Exception):
|
|
13
|
+
"""Exception raised when database resolution fails."""
|
|
14
|
+
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ResolvedDatabase:
|
|
20
|
+
"""Result of database resolution containing canonical connection info."""
|
|
21
|
+
|
|
22
|
+
name: str # Human-readable name for display/logging
|
|
23
|
+
connection_string: str # Canonical connection string for DatabaseConnection factory
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
SUPPORTED_SCHEMES = {"postgresql", "mysql", "sqlite", "csv"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_connection_string(s: str) -> bool:
|
|
30
|
+
"""Check if string looks like a connection string with supported scheme."""
|
|
31
|
+
try:
|
|
32
|
+
scheme = urlparse(s).scheme
|
|
33
|
+
return scheme in SUPPORTED_SCHEMES
|
|
34
|
+
except Exception:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_database(
|
|
39
|
+
spec: str | None, config_mgr: DatabaseConfigManager
|
|
40
|
+
) -> ResolvedDatabase:
|
|
41
|
+
"""Turn user CLI input into resolved database connection info.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
spec: User input - None (default), configured name, connection string, or file path
|
|
45
|
+
config_mgr: Database configuration manager for looking up configured connections
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
ResolvedDatabase with name and canonical connection string
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
DatabaseResolutionError: If the spec cannot be resolved to a valid database connection
|
|
52
|
+
"""
|
|
53
|
+
if spec is None:
|
|
54
|
+
db_cfg = config_mgr.get_default_database()
|
|
55
|
+
if not db_cfg:
|
|
56
|
+
raise DatabaseResolutionError(
|
|
57
|
+
"No database connections configured. "
|
|
58
|
+
"Use 'sqlsaber db add <name>' to add one."
|
|
59
|
+
)
|
|
60
|
+
return ResolvedDatabase(
|
|
61
|
+
name=db_cfg.name,
|
|
62
|
+
connection_string=db_cfg.to_connection_string(),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# 1. Connection string?
|
|
66
|
+
if _is_connection_string(spec):
|
|
67
|
+
scheme = urlparse(spec).scheme
|
|
68
|
+
if scheme in {"postgresql", "mysql"}:
|
|
69
|
+
db_name = urlparse(spec).path.lstrip("/") or "database"
|
|
70
|
+
elif scheme in {"sqlite", "csv"}:
|
|
71
|
+
db_name = Path(urlparse(spec).path).stem
|
|
72
|
+
else: # should not happen because of SUPPORTED_SCHEMES
|
|
73
|
+
db_name = "database"
|
|
74
|
+
return ResolvedDatabase(name=db_name, connection_string=spec)
|
|
75
|
+
|
|
76
|
+
# 2. Raw file path?
|
|
77
|
+
path = Path(spec).expanduser().resolve()
|
|
78
|
+
if path.suffix.lower() == ".csv":
|
|
79
|
+
if not path.exists():
|
|
80
|
+
raise DatabaseResolutionError(f"CSV file '{spec}' not found.")
|
|
81
|
+
return ResolvedDatabase(name=path.stem, connection_string=f"csv:///{path}")
|
|
82
|
+
if path.suffix.lower() in {".db", ".sqlite", ".sqlite3"}:
|
|
83
|
+
if not path.exists():
|
|
84
|
+
raise DatabaseResolutionError(f"SQLite file '{spec}' not found.")
|
|
85
|
+
return ResolvedDatabase(name=path.stem, connection_string=f"sqlite:///{path}")
|
|
86
|
+
|
|
87
|
+
# 3. Must be a configured name
|
|
88
|
+
db_cfg: DatabaseConfig | None = config_mgr.get_database(spec)
|
|
89
|
+
if not db_cfg:
|
|
90
|
+
raise DatabaseResolutionError(
|
|
91
|
+
f"Database connection '{spec}' not found. "
|
|
92
|
+
"Use 'sqlsaber db list' to see available connections."
|
|
93
|
+
)
|
|
94
|
+
return ResolvedDatabase(
|
|
95
|
+
name=db_cfg.name, connection_string=db_cfg.to_connection_string()
|
|
96
|
+
)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Tests for database resolver functionality."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from unittest.mock import Mock, patch
|
|
5
|
+
from sqlsaber.database.resolver import resolve_database, DatabaseResolutionError
|
|
6
|
+
from sqlsaber.config.database import DatabaseConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestDatabaseResolver:
|
|
10
|
+
"""Test cases for database resolution logic."""
|
|
11
|
+
|
|
12
|
+
def test_resolve_connection_strings(self):
|
|
13
|
+
"""Test that connection strings are handled correctly."""
|
|
14
|
+
config_mgr = Mock()
|
|
15
|
+
|
|
16
|
+
# PostgreSQL connection string
|
|
17
|
+
result = resolve_database("postgresql://user:pass@host:5432/testdb", config_mgr)
|
|
18
|
+
assert result.name == "testdb"
|
|
19
|
+
assert result.connection_string == "postgresql://user:pass@host:5432/testdb"
|
|
20
|
+
|
|
21
|
+
# MySQL connection string
|
|
22
|
+
result = resolve_database("mysql://user:pass@host:3306/mydb", config_mgr)
|
|
23
|
+
assert result.name == "mydb"
|
|
24
|
+
assert result.connection_string == "mysql://user:pass@host:3306/mydb"
|
|
25
|
+
|
|
26
|
+
# SQLite connection string
|
|
27
|
+
result = resolve_database("sqlite:///test.db", config_mgr)
|
|
28
|
+
assert result.name == "test"
|
|
29
|
+
assert result.connection_string == "sqlite:///test.db"
|
|
30
|
+
|
|
31
|
+
# CSV connection string
|
|
32
|
+
result = resolve_database("csv:///data.csv", config_mgr)
|
|
33
|
+
assert result.name == "data"
|
|
34
|
+
assert result.connection_string == "csv:///data.csv"
|
|
35
|
+
|
|
36
|
+
@patch("pathlib.Path.exists")
|
|
37
|
+
def test_resolve_file_paths(self, mock_exists):
|
|
38
|
+
"""Test that file paths are resolved correctly."""
|
|
39
|
+
mock_exists.return_value = True
|
|
40
|
+
config_mgr = Mock()
|
|
41
|
+
|
|
42
|
+
# CSV file
|
|
43
|
+
result = resolve_database("data.csv", config_mgr)
|
|
44
|
+
assert result.name == "data"
|
|
45
|
+
assert result.connection_string.startswith("csv:///")
|
|
46
|
+
assert result.connection_string.endswith("data.csv")
|
|
47
|
+
|
|
48
|
+
# SQLite file
|
|
49
|
+
result = resolve_database("test.db", config_mgr)
|
|
50
|
+
assert result.name == "test"
|
|
51
|
+
assert result.connection_string.startswith("sqlite:///")
|
|
52
|
+
assert result.connection_string.endswith("test.db")
|
|
53
|
+
|
|
54
|
+
@patch("pathlib.Path.exists")
|
|
55
|
+
def test_file_not_found_error(self, mock_exists):
|
|
56
|
+
"""Test that missing files raise appropriate errors."""
|
|
57
|
+
mock_exists.return_value = False
|
|
58
|
+
config_mgr = Mock()
|
|
59
|
+
|
|
60
|
+
with pytest.raises(
|
|
61
|
+
DatabaseResolutionError, match="CSV file 'missing.csv' not found"
|
|
62
|
+
):
|
|
63
|
+
resolve_database("missing.csv", config_mgr)
|
|
64
|
+
|
|
65
|
+
with pytest.raises(
|
|
66
|
+
DatabaseResolutionError, match="SQLite file 'missing.db' not found"
|
|
67
|
+
):
|
|
68
|
+
resolve_database("missing.db", config_mgr)
|
|
69
|
+
|
|
70
|
+
def test_resolve_configured_database(self):
|
|
71
|
+
"""Test that configured database names are resolved."""
|
|
72
|
+
config_mgr = Mock()
|
|
73
|
+
db_config = Mock(spec=DatabaseConfig)
|
|
74
|
+
db_config.name = "mydb"
|
|
75
|
+
db_config.to_connection_string.return_value = "postgresql://localhost:5432/mydb"
|
|
76
|
+
config_mgr.get_database.return_value = db_config
|
|
77
|
+
|
|
78
|
+
result = resolve_database("mydb", config_mgr)
|
|
79
|
+
assert result.name == "mydb"
|
|
80
|
+
assert result.connection_string == "postgresql://localhost:5432/mydb"
|
|
81
|
+
|
|
82
|
+
def test_configured_database_not_found(self):
|
|
83
|
+
"""Test error when configured database doesn't exist."""
|
|
84
|
+
config_mgr = Mock()
|
|
85
|
+
config_mgr.get_database.return_value = None
|
|
86
|
+
|
|
87
|
+
with pytest.raises(
|
|
88
|
+
DatabaseResolutionError, match="Database connection 'unknown' not found"
|
|
89
|
+
):
|
|
90
|
+
resolve_database("unknown", config_mgr)
|
|
91
|
+
|
|
92
|
+
def test_resolve_default_database(self):
|
|
93
|
+
"""Test that None resolves to default database."""
|
|
94
|
+
config_mgr = Mock()
|
|
95
|
+
db_config = Mock(spec=DatabaseConfig)
|
|
96
|
+
db_config.name = "default"
|
|
97
|
+
db_config.to_connection_string.return_value = (
|
|
98
|
+
"postgresql://localhost:5432/default"
|
|
99
|
+
)
|
|
100
|
+
config_mgr.get_default_database.return_value = db_config
|
|
101
|
+
|
|
102
|
+
result = resolve_database(None, config_mgr)
|
|
103
|
+
assert result.name == "default"
|
|
104
|
+
assert result.connection_string == "postgresql://localhost:5432/default"
|
|
105
|
+
|
|
106
|
+
def test_no_default_database_error(self):
|
|
107
|
+
"""Test error when no default database is configured."""
|
|
108
|
+
config_mgr = Mock()
|
|
109
|
+
config_mgr.get_default_database.return_value = None
|
|
110
|
+
|
|
111
|
+
with pytest.raises(
|
|
112
|
+
DatabaseResolutionError, match="No database connections configured"
|
|
113
|
+
):
|
|
114
|
+
resolve_database(None, config_mgr)
|
|
115
|
+
|
|
116
|
+
def test_connection_string_edge_cases(self):
|
|
117
|
+
"""Test edge cases in connection string parsing."""
|
|
118
|
+
config_mgr = Mock()
|
|
119
|
+
|
|
120
|
+
# PostgreSQL without database name
|
|
121
|
+
result = resolve_database("postgresql://user:pass@host:5432/", config_mgr)
|
|
122
|
+
assert result.name == "database" # fallback name
|
|
123
|
+
|
|
124
|
+
# PostgreSQL with no path at all
|
|
125
|
+
result = resolve_database("postgresql://user:pass@host:5432", config_mgr)
|
|
126
|
+
assert result.name == "database" # fallback name
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|