diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..36b3b1a9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,103 @@ +--- +name: Bug Report +about: Report a bug in rhosocial ActiveRecord PostgreSQL Backend +title: '[POSTGRES-BUG] ' +labels: 'bug, postgres' +assignees: '' +--- + +## Before Submitting + +Please ensure this bug is specific to the PostgreSQL backend implementation. If the issue occurs across all backends (involving ActiveRecord/ActiveQuery functionality), please submit the issue at https://github.com/rhosocial/python-activerecord/issues instead. + +## Description + +A clear and concise description of the bug in the PostgreSQL backend. + +## Environment + +- **rhosocial ActiveRecord PostgreSQL Version**: [e.g. 1.0.0.dev13] +- **rhosocial ActiveRecord Core Version**: [e.g. 1.0.0.dev13] +- **Python Version**: [e.g. 3.13] +- **PostgreSQL Version**: [e.g. 15.x, 14.x, 13.x] +- **psycopg Version**: [e.g. psycopg 3.2.0] +- **OS**: [e.g. Linux, macOS, Windows] + +## Steps to Reproduce + +1. +2. +3. + +## Expected Behavior + +A clear and concise description of what you expected to happen. + +## Actual Behavior + +What actually happened instead of the expected behavior. + +## Database Query + +If applicable, provide the generated SQL query that causes the issue: + +```sql +-- Your problematic SQL query here +``` + +## Model Definition + +If the issue is related to a specific model, please share your model definition: + +```python +# Example model definition +class User(ActiveRecord): + __table_name__ = 'users' + + id: Optional[int] = None + name: str + email: EmailStr +``` + +## PostgreSQL-Specific Configuration + +Any PostgreSQL-specific configuration that might be relevant: + +```python +config = PostgreSQLConnectionConfig( + host='localhost', + port=5432, + database='test', + username='user', + password='password', + # Add your specific options here +) +``` + +## PostgreSQL Features Used + +Which PostgreSQL-specific features are involved in this bug? (Select all that apply) + +- [ ] JSON/JSONB operations +- [ ] Array operations +- [ ] Window functions +- [ ] Common Table Expressions (CTEs) +- [ ] Recursive CTEs +- [ ] Full-text search +- [ ] Geometric types +- [ ] Network address types +- [ ] Range types +- [ ] UUID type +- [ ] Custom types + +## Error Details + +If you're getting an error, include the full error message and stack trace: + +``` +Paste the full error message here +``` + +## Additional Context + +Any other context about the problem here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..4d9c2e5d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,67 @@ +--- +name: Feature Request +about: Suggest a feature for rhosocial ActiveRecord PostgreSQL Backend +title: '[POSTGRES-FEATURE] ' +labels: 'enhancement, postgres' +assignees: '' +--- + +## Before Submitting + +Please ensure this feature request is specific to the PostgreSQL backend implementation. If the feature is related to ActiveRecord/ActiveQuery functionality that should be consistent across all backends, please submit the feature request at https://github.com/rhosocial/python-activerecord/issues instead. + +## Description + +A clear and concise description of the desired feature for the PostgreSQL backend. + +## Problem Statement + +Is your feature request related to a specific problem? Please describe. + +## Proposed Solution + +Describe the solution you'd like to see implemented in the PostgreSQL backend. + +## PostgreSQL-Specific Use Cases + +What specific PostgreSQL use cases would this feature address? + +1. +2. + +## PostgreSQL Features & Capabilities + +Does this feature relate to specific PostgreSQL capabilities? (Select all that apply) + +- [ ] JSON/JSONB operations +- [ ] Array operations +- [ ] Window functions +- [ ] Common Table Expressions (CTEs) +- [ ] Recursive CTEs +- [ ] Full-text search +- [ ] Geometric types (PostGIS integration) +- [ ] Network address types +- [ ] Range types +- [ ] UUID type +- [ ] Custom enumerations (ENUM) +- [ ] Custom data types +- [ ] Advanced aggregation functions +- [ ] Materialized views support + +## Technical Considerations + +Are there any technical constraints or considerations specific to PostgreSQL for implementing this feature? + +## Comparison with Alternatives + +How does this feature compare to alternative approaches in the PostgreSQL ecosystem? + +## Impact Assessment + +- **Compatibility**: Will this feature be backward compatible with existing PostgreSQL installations? +- **Performance**: Are there any performance implications for PostgreSQL queries? +- **Migration**: Would this require database schema changes or migration steps? + +## Additional Context + +Add any other context or examples about the feature request here. \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2a6fe94f..dddbdec0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,6 +1,14 @@ name: Python Package Publish on: + # 当测试工作流成功完成时自动触发发布审批流程 + workflow_run: + workflows: ["Python Test and Coverage"] + types: + - completed + branches: + - main + # 手动触发发布工作流 workflow_dispatch: inputs: @@ -18,10 +26,23 @@ permissions: packages: write jobs: + # 检查测试工作流是否成功完成 + check-tests: + runs-on: ubuntu-latest + if: > + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') || + github.event_name == 'workflow_dispatch' + steps: + - name: Success check + run: echo "前置测试通过,可以继续执行发布流程" + # 发布到TestPyPI publish-testpypi: name: Publish to TestPyPI - if: github.event.inputs.destination == 'testpypi' + needs: check-tests + if: > + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') || + (github.event_name == 'workflow_dispatch' && github.event.inputs.destination == 'testpypi') runs-on: ubuntu-latest environment: publish-testpypi # 使用需要审批的环境 permissions: @@ -55,7 +76,8 @@ jobs: # 发布到PyPI publish-pypi: name: Publish to PyPI - if: github.event.inputs.destination == 'pypi' + needs: check-tests + if: github.event_name == 'workflow_dispatch' && github.event.inputs.destination == 'pypi' runs-on: ubuntu-latest environment: publish-pypi # 使用需要严格审批的环境 permissions: diff --git a/README.md b/README.md index f3b9a0a4..9703dff7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# rhosocial ActiveRecord postgres Backend ($\rho_{\mathbf{AR}}^{postgres}$) +# rhosocial-activerecord-postgres ($\rho_{\mathbf{AR}\text{-postgres}}$) [](https://badge.fury.io/py/rhosocial-activerecord-postgres) [](https://pypi.org/project/rhosocial-activerecord-postgres/) @@ -12,7 +12,7 @@
postgres backend implementation for rhosocial-activerecord, providing a robust and optimized postgres database support.
-# postgres Backend Implementation Guide +# Postgres Backend Implementation Guide ## Overview diff --git a/changelog.d/1.added.md b/changelog.d/1.added.md new file mode 100644 index 00000000..3a712c00 --- /dev/null +++ b/changelog.d/1.added.md @@ -0,0 +1 @@ +Add backend CLI tool functionality for database operations \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/README.md b/src/rhosocial/activerecord/backend/impl/postgres/README.md index a11e60af..7342669b 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/README.md +++ b/src/rhosocial/activerecord/backend/impl/postgres/README.md @@ -1,235 +1,91 @@ -# $\rho_{AR}^{postgres}$ - PostgreSQL Backend Quick Execution Tool +# $\mathcal{B}_{\text{postgres}}^{\rho}$ - PostgreSQL Backend Quick Execution Tool -This `__main__.py` script provides a command-line interface to quickly execute SQL queries against a PostgreSQL database using the `rhosocial-activerecord` PostgreSQL backend implementation. It supports both synchronous and asynchronous execution modes. +This `__main__.py` script provides a command-line interface to quickly execute a single SQL query against a PostgreSQL database using the `rhosocial-activerecord` PostgreSQL backend implementation. ## Purpose This tool is designed for: * Rapid testing of PostgreSQL backend connectivity and query execution. * Debugging specific SQL queries or backend behaviors. -* Performing quick database operations (DDL/DML) directly from the command line. +* Performing quick, single-statement database operations (DDL/DML) directly from the command line. ## Usage To run the script, navigate to the root directory of the project (where the `src` folder is located). Then, execute the module using `python -m` followed by the module path. -The SQL query is a **positional argument** and should be the last argument after all optional flags. +SQL queries can be provided as a positional argument, from a file using the `--file` flag, or piped via standard input (stdin). ```bash -python -m rhosocial.activerecord.backend.impl.postgres [OPTIONAL_FLAGS] "YOUR_SQL_QUERY;" +python -m rhosocial.activerecord.backend.impl.postgres [OPTIONAL_FLAGS] [YOUR_SQL_QUERY] ``` ## Arguments -| Argument | Default | Description | -| :--------------- | :------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `--host` | `POSTGRES_HOST` env var or `localhost` | Database host. | -| `--port` | `POSTGRES_PORT` env var or `5432` | Database port. | -| `--database` | `POSTGRES_DATABASE` env var or _None_ | Database name to connect to. **Important**: If not provided, it defaults to the `username`. For example, a `root` user will attempt to connect to a `root` database. | -| `--user` | `POSTGRES_USER` env var or `postgres` | Database user. | -| `--password` | `POSTGRES_PASSWORD` env var or _empty string_ | Database password. | -| `query` | _Required_ (positional) | **SQL query to execute.** Must be enclosed in quotes. | -| `--use-async` | _False_ | Use the asynchronous backend (`AsyncPostgresBackend`). If omitted, the synchronous backend (`PostgresBackend`) will be used. | -| `--log-level` | `INFO` | Set the logging level (e.g., `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`). | -| `--rich-ascii` | _False_ | Use ASCII characters for table borders. Recommended for terminals that have trouble rendering Unicode box characters. | - -## Pretty Output with `rich` - -This tool integrates with the [rich](https://github.com/Textualize/rich) library to provide beautified, color-coded output and logging. This is an optional feature. - -### Activation - -To enable this feature, simply install `rich` in your Python environment: -```bash -pip install rich -``` -The script will automatically detect its presence and enhance the output. If `rich` is not found, the script will fall back to standard plain text output. - -### Rendering Issues in Terminals - -Some terminals may not correctly render the box-drawing characters used in tables by default. To fix this, you can use the `--rich-ascii` flag. - -```bash -python -m rhosocial.activerecord.backend.impl.postgres ... --rich-ascii "SELECT 1;" -``` -This forces the table borders to be rendered using only ASCII characters, which is compatible with all terminals. - -## Using Environment Variables for Connection Parameters - -To simplify command-line execution, you can set connection parameters as environment variables. Command-line arguments will always take precedence over environment variables. - -**Supported Environment Variables:** -* `POSTGRES_HOST` -* `POSTGRES_PORT` -* `POSTGRES_DATABASE` -* `POSTGRES_USER` -* `POSTGRES_PASSWORD` - -**Examples for setting environment variables and running commands:** - -### Bash (Linux/macOS) - -```bash -# Set environment variables for the current session -export POSTGRES_HOST="your_postgres_host" -export POSTGRES_PORT="5432" -export POSTGRES_USER="root" -export POSTGRES_PASSWORD="your_secure_password" -export POSTGRES_DATABASE="postgres" # Recommended to set a default DB - -# Run a query using environment variables -python -m rhosocial.activerecord.backend.impl.postgres "SELECT NOW();" --use-async - -# Override user via command-line -python -m rhosocial.activerecord.backend.impl.postgres "SELECT * FROM your_table;" --user guest -``` - -### PowerShell (Windows) - -```powershell -# Set environment variables for the current session -$env:POSTGRES_HOST="your_postgres_host" -$env:POSTGRES_PORT="5432" -$env:POSTGRES_USER="root" -$env:POSTGRES_PASSWORD="your_secure_password" -$env:POSTGRES_DATABASE="postgres" # Recommended to set a default DB - -# Run a query using environment variables -python -m rhosocial.activerecord.backend.impl.postgres "SELECT NOW();" --use-async -``` +| Argument | Default | Description | +| :---------------- | :------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `query` | _None_ | **SQL query to execute.** If not provided, reads from `--file` or stdin. | +| `-f, --file` | _None_ | Path to a file containing a **single** SQL query to execute. | +| `--host` | `POSTGRES_HOST` env var or `localhost` | Database host. | +| `--port` | `POSTGRES_PORT` env var or `5432` | Database port. | +| `--database` | `POSTGRES_DATABASE` env var or _None_ | Database name to connect to. | +| `--user` | `POSTGRES_USER` env var or `postgres` | Database user. | +| `--password` | `POSTGRES_PASSWORD` env var or _empty string_ | Database password. | +| `--use-async` | _False_ | Use the asynchronous backend. | +| `--output` | `table` (if rich available) or `json` (fallback) | Output format. Choices are `table` (rich formatted), `json`, `csv`, `tsv`. | +| `--log-level` | `INFO` | Set the logging level (e.g., `DEBUG`, `INFO`). | +| `--rich-ascii` | _False_ | Use ASCII characters for table borders. | + +## Important Notes + +* **Single Statement Only**: This tool does not support multi-statement execution. Any input provided (via argument, file, or stdin) **must contain only a single SQL statement**. +* **Project Root Execution**: Ensure you execute these commands from the root directory of your project for module path resolution. +* **Environment Variables**: Connection parameters can be set via environment variables (e.g., `POSTGRES_HOST`). Command-line arguments will override them. ## Examples -This section provides a complete lifecycle example of database operations, from creating a database to cleaning it up. - -**Note**: -* The following examples assume you are running the commands from the root of this project. -* For a multi-repository setup, you must set your `PYTHONPATH` to include the `src` directories of all relevant projects. -* Replace placeholder credentials (`your_postgres_host`, etc.) with your actual PostgreSQL server credentials, or set the corresponding environment variables. - ---- - -### System Information Queries - -These queries help you retrieve basic information about the PostgreSQL server and your current connection. - -#### Query PostgreSQL Version - -```bash -python -m rhosocial.activerecord.backend.impl.postgres \ - --host your_postgres_host --user your_postgres_user --password your_secure_password \ - "SELECT version();" -``` - -#### Query Current User Details - -```bash -python -m rhosocial.activerecord.backend.impl.postgres \ - --host your_postgres_host --user your_postgres_user --password your_secure_password \ - "SELECT usename, client_addr, application_name FROM pg_stat_activity WHERE pid = pg_backend_pid();" -``` - -#### Check Detailed TLS/SSL Status - -```bash -python -m rhosocial.activerecord.backend.impl.postgres \ - --host your_postgres_host --user your_postgres_user --password your_secure_password \ - "SELECT * FROM pg_stat_ssl WHERE pid = pg_backend_pid();" -``` - ---- - -### Step 1: Create a Database - -First, we'll create a new database named `test_db`. Note that we connect to the default `postgres` database to perform this action. - -```bash -python -m rhosocial.activerecord.backend.impl.postgres \ - --host your_postgres_host --user your_postgres_user --password your_secure_password \ - --database postgres \ - "CREATE DATABASE test_db;" -``` - -### Step 2: Create a Table - -Now, let's create a `users` table within our new database. We'll specify `--database test_db` for this and all subsequent table-level commands. Note the use of `SERIAL PRIMARY KEY` for auto-incrementing IDs in PostgreSQL. - -```bash -python -m rhosocial.activerecord.backend.impl.postgres \ - --host your_postgres_host --user your_postgres_user --password your_secure_password \ - --database test_db \ - "CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(100), email VARCHAR(100));" -``` - -### Step 3: Insert Records - -Let's add two users, 'Alice' and 'Bob', to our `users` table. +#### 1. Execute Query from Argument ```bash -# Insert Alice -python -m rhosocial.activerecord.backend.impl.postgres \ - --host your_postgres_host --user your_postgres_user --password your_secure_password \ - --database test_db \ - "INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com');" +# Set PYTHONPATH to include both this project's and the core project's src +export PYTHONPATH="src:../python-activerecord/src" -# Insert Bob +# Run a query with credentials python -m rhosocial.activerecord.backend.impl.postgres \ - --host your_postgres_host --user your_postgres_user --password your_secure_password \ - --database test_db \ - "INSERT INTO users (name, email) VALUES ('Bob', 'bob@example.com');" + --host your_host --user your_user --password "your_pass" --database "postgres" \ + "SELECT version();" ``` -### Step 4: Query Records - -Retrieve all records from the `users` table. +#### 2. Execute Query from Stdin with CSV Output ```bash +echo "SELECT usename, client_addr FROM pg_stat_activity WHERE pid = pg_backend_pid();" | \ python -m rhosocial.activerecord.backend.impl.postgres \ - --host your_postgres_host --user your_postgres_user --password your_secure_password \ - --database test_db \ - "SELECT * FROM users;" + --host your_host --user your_user --password "your_pass" --database "postgres" --output csv ``` - -### Step 5: Update a Record - -Let's update Alice's email address. - -```bash -python -m rhosocial.activerecord.backend.impl.postgres \ - --host your_postgres_host --user your_postgres_user --password your_secure_password \ - --database test_db \ - "UPDATE users SET email = 'alice_updated@example.com' WHERE name = 'Alice';" +**Expected Output (to stdout):** +```csv +usename,client_addr +your_user,127.0.0.1 ``` -### Step 6: Delete a Record - -Now, let's remove Bob from the table. +#### 3. Execute Query from File with JSON Output -```bash -python -m rhosocial.activerecord.backend.impl.postgres \ - --host your_postgres_host --user your_postgres_user --password your_secure_password \ - --database test_db \ - "DELETE FROM users WHERE name = 'Bob';" +Create a file `get_version.sql` with the content: +```sql +SELECT version(); ``` -### Step 7: Clean Up by Dropping the Table - -After our operations are complete, we can clean up by dropping the `users` table. - +Then run the command: ```bash python -m rhosocial.activerecord.backend.impl.postgres \ - --host your_postgres_host --user your_postgres_user --password your_secure_password \ - --database test_db \ - "DROP TABLE users;" + --host your_host --user your_user --password "your_pass" --database "postgres" \ + --file get_version.sql --output json ``` - -### Step 8: Final Cleanup by Dropping the Database - -Finally, we remove the test database itself. - -```bash -python -m rhosocial.activerecord.backend.impl.postgres \ - --host your_postgres_host --user your_postgres_user --password your_secure_password \ - --database postgres \ - "DROP DATABASE test_db;" +**Expected Output (to stdout):** +```json +[ + { + "version": "PostgreSQL 14.2 on x86_64-apple-darwin21.3.0, compiled by Apple clang version 13.1.6 (clang-1316.0.21.2.5), 64-bit" + } +] ``` \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/__main__.py b/src/rhosocial/activerecord/backend/impl/postgres/__main__.py index 315d58ad..b1c6acb9 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/__main__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/__main__.py @@ -8,50 +8,94 @@ from .backend import PostgresBackend, AsyncPostgresBackend from .config import PostgresConnectionConfig from rhosocial.activerecord.backend.errors import ConnectionError, QueryError -from .output import PlainTextOutputProvider +from rhosocial.activerecord.backend.output import JsonOutputProvider, CsvOutputProvider, TsvOutputProvider -# Attempt to import rich and the RichOutputProvider +# Attempt to import rich for formatted output try: from rich.console import Console from rich.logging import RichHandler - from .output import RichOutputProvider + from rhosocial.activerecord.backend.output_rich import RichOutputProvider RICH_AVAILABLE = True except ImportError: RICH_AVAILABLE = False + RichOutputProvider = None logger = logging.getLogger(__name__) def parse_args(): parser = argparse.ArgumentParser( - description="Execute SQL queries against a PostgreSQL backend.", + description="Execute a single SQL query against a PostgreSQL backend.", formatter_class=argparse.RawTextHelpFormatter ) - + # Input source arguments + parser.add_argument( + 'query', + nargs='?', + default=None, + help='SQL query to execute. If not provided, reads from --file or stdin.' + ) + parser.add_argument( + '-f', '--file', + default=None, + help='Path to a file containing a single SQL query to execute.' + ) # Connection parameters parser.add_argument('--host', default=os.getenv('POSTGRES_HOST', 'localhost'), help='Database host') parser.add_argument('--port', type=int, default=int(os.getenv('POSTGRES_PORT', 5432)), help='Database port') parser.add_argument('--database', default=os.getenv('POSTGRES_DATABASE'), help='Database name') parser.add_argument('--user', default=os.getenv('POSTGRES_USER', 'postgres'), help='Database user') parser.add_argument('--password', default=os.getenv('POSTGRES_PASSWORD', ''), help='Database password') - + # Execution options - parser.add_argument('query', help='SQL query to execute. Must be enclosed in quotes.') parser.add_argument('--use-async', action='store_true', help='Use asynchronous backend') - + # Output and logging options + parser.add_argument( + '--output', + choices=['table', 'json', 'csv', 'tsv'], + default='table', + help='Output format. Defaults to "table" if rich is installed, otherwise "json".' + ) parser.add_argument('--log-level', default='INFO', help='Set logging level (e.g., DEBUG, INFO)') - parser.add_argument('--plain', action='store_true', help='Use plain text output even if rich is installed.') parser.add_argument('--rich-ascii', action='store_true', help='Use ASCII characters for rich table borders.') return parser.parse_args() -def execute_query_sync(args, backend, provider): + +def get_provider(args): + """Factory function to get the correct output provider.""" + output_format = args.output + if output_format == 'table' and not RICH_AVAILABLE: + output_format = 'json' + + if output_format == 'table': + return RichOutputProvider(console=Console(), ascii_borders=args.rich_ascii) + if output_format == 'json': + return JsonOutputProvider() + if output_format == 'csv': + return CsvOutputProvider() + if output_format == 'tsv': + return TsvOutputProvider() + + return JsonOutputProvider() + + +def execute_query_sync(sql_query: str, backend: PostgresBackend, provider: 'OutputProvider', **kwargs): try: backend.connect() - provider.display_query(args.query, is_async=False) - result = backend.execute(args.query) - handle_result(result, provider, use_ascii=args.rich_ascii) + provider.display_query(sql_query, is_async=False) + result = backend.execute(sql_query) + + if not result: + provider.display_no_result_object() + else: + provider.display_success(result.affected_rows, result.duration) + if result.data: + provider.display_results(result.data, **kwargs) + else: + provider.display_no_data() + except ConnectionError as e: provider.display_connection_error(e) sys.exit(1) @@ -66,12 +110,22 @@ def execute_query_sync(args, backend, provider): backend.disconnect() provider.display_disconnect(is_async=False) -async def execute_query_async(args, backend, provider): + +async def execute_query_async(sql_query: str, backend: AsyncPostgresBackend, provider: 'OutputProvider', **kwargs): try: await backend.connect() - provider.display_query(args.query, is_async=True) - result = await backend.execute(args.query) - handle_result(result, provider, use_ascii=args.rich_ascii) + provider.display_query(sql_query, is_async=True) + result = await backend.execute(sql_query) + + if not result: + provider.display_no_result_object() + else: + provider.display_success(result.affected_rows, result.duration) + if result.data: + provider.display_results(result.data, **kwargs) + else: + provider.display_no_data() + except ConnectionError as e: provider.display_connection_error(e) sys.exit(1) @@ -87,55 +141,63 @@ async def execute_query_async(args, backend, provider): provider.display_disconnect(is_async=True) -def handle_result(result, provider, **kwargs): - if not result: - provider.display_no_result_object() - return - - provider.display_success(result.affected_rows, result.duration) - if result.data: - provider.display_results(result.data, **kwargs) - else: - provider.display_no_data() - def main(): args = parse_args() - # Setup logging numeric_level = getattr(logging, args.log_level.upper(), None) if not isinstance(numeric_level, int): raise ValueError(f'Invalid log level: {args.log_level}') - # Choose output provider - if RICH_AVAILABLE and not args.plain: - provider = RichOutputProvider(console=Console(), ascii_borders=args.rich_ascii) + provider = get_provider(args) + + if isinstance(provider, RichOutputProvider): logging.basicConfig( - level=numeric_level, - format="%(message)s", - datefmt="[%X]", - handlers=[RichHandler(rich_tracebacks=True, show_path=False)] + level=numeric_level, format="%(message)s", datefmt="[%X]", + handlers=[RichHandler(rich_tracebacks=True, show_path=False, console=Console(stderr=True))] ) else: - provider = PlainTextOutputProvider() - logging.basicConfig(level=numeric_level, format='%(asctime)s - %(levelname)s - %(message)s') + logging.basicConfig( + level=numeric_level, format='%(asctime)s - %(levelname)s - %(message)s', + stream=sys.stderr + ) provider.display_greeting() - # Setup backend + sql_source = None + if args.query: + sql_source = args.query + elif args.file: + try: + with open(args.file, 'r', encoding='utf-8') as f: + sql_source = f.read() + except FileNotFoundError: + logger.error(f"Error: File not found at {args.file}") + sys.exit(1) + elif not sys.stdin.isatty(): + sql_source = sys.stdin.read() + + if not sql_source: + print("Error: No SQL query provided. Use the query argument, --file flag, or pipe from stdin.", file=sys.stderr) + sys.exit(1) + + # Ensure only one statement is provided, as multi-statement execution is not supported + if ';' in sql_source.strip().rstrip(';'): + logger.error("Error: Multiple SQL statements are not supported. Please provide a single query.") + sys.exit(1) + config = PostgresConnectionConfig( - host=args.host, - port=args.port, - database=args.database, - username=args.user, - password=args.password, + host=args.host, port=args.port, database=args.database, + username=args.user, password=args.password ) + kwargs = {'use_ascii': args.rich_ascii} if args.use_async: backend = AsyncPostgresBackend(connection_config=config) - asyncio.run(execute_query_async(args, backend, provider)) + asyncio.run(execute_query_async(sql_source, backend, provider, **kwargs)) else: backend = PostgresBackend(connection_config=config) - execute_query_sync(args, backend, provider) + execute_query_sync(sql_source, backend, provider, **kwargs) + if __name__ == "__main__": main() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend.py b/src/rhosocial/activerecord/backend/impl/postgres/backend.py index 9e8608e6..81722133 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend.py @@ -478,6 +478,24 @@ def connect(self) -> None: self.log(logging.INFO, f"Connected to postgres version {'.'.join(map(str, version))}") + # --- ADDED SECTION for SSL/TLS Status Logging --- + cursor = self._connection.cursor() + cursor.execute("SELECT ssl, version, cipher FROM pg_stat_ssl s JOIN pg_stat_activity a ON s.pid = a.pid WHERE a.pid = pg_backend_pid();") + result = cursor.fetchone() + cursor.close() + + if result: + ssl_enabled = result[0] + ssl_version = result[1] + ssl_cipher = result[2] + if ssl_enabled: + self.log(logging.INFO, f"SSL/TLS is ENABLED for this connection. Version: {ssl_version}, Cipher: {ssl_cipher}") + else: + self.log(logging.INFO, "SSL/TLS is DISABLED for this connection.") + else: + self.log(logging.INFO, "Could not retrieve SSL/TLS status from pg_stat_ssl.") + # --- END ADDITION --- + except PsycopgError as e: error_msg = f"Failed to connect to postgres: {str(e)}" self.log(logging.ERROR, error_msg) @@ -665,6 +683,24 @@ async def connect(self) -> None: self.log(logging.INFO, f"Connected to postgres version {'.'.join(map(str, version))}") + # --- ADDED SECTION for SSL/TLS Status Logging --- + cursor = self._connection.cursor() + await cursor.execute("SELECT ssl, version, cipher FROM pg_stat_ssl s JOIN pg_stat_activity a ON s.pid = a.pid WHERE a.pid = pg_backend_pid();") + result = await cursor.fetchone() + await cursor.close() + + if result: + ssl_enabled = result[0] + ssl_version = result[1] + ssl_cipher = result[2] + if ssl_enabled: + self.log(logging.INFO, f"SSL/TLS is ENABLED for this connection. Version: {ssl_version}, Cipher: {ssl_cipher}") + else: + self.log(logging.INFO, "SSL/TLS is DISABLED for this connection.") + else: + self.log(logging.INFO, "Could not retrieve SSL/TLS status from pg_stat_ssl.") + # --- END ADDITION --- + except PsycopgError as e: error_msg = f"Failed to connect to postgres: {str(e)}" self.log(logging.ERROR, error_msg) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/output.py b/src/rhosocial/activerecord/backend/impl/postgres/output.py deleted file mode 100644 index 55e47132..00000000 --- a/src/rhosocial/activerecord/backend/impl/postgres/output.py +++ /dev/null @@ -1,179 +0,0 @@ -# src/rhosocial/activerecord/backend/impl/postgres/output.py -import datetime -import decimal -import json -import logging -from abc import ABC, abstractmethod -from typing import Any, List, Dict - -try: - from rich.console import Console - from rich.table import Table - from rich.panel import Panel - from rich import box - RICH_AVAILABLE = True -except ImportError: - RICH_AVAILABLE = False - -logger = logging.getLogger(__name__) - - -class OutputProvider(ABC): - """Abstract base class for different output strategies.""" - - @abstractmethod - def display_query(self, query: str, is_async: bool): - """Display the query being executed.""" - pass - - @abstractmethod - def display_success(self, affected_rows: int, duration: float): - """Display a successful execution message.""" - pass - - @abstractmethod - def display_results(self, data: List[Dict[str, Any]], **kwargs): - """Display query results.""" - pass - - @abstractmethod - def display_no_data(self): - """Display a message when no data is returned.""" - pass - - @abstractmethod - def display_no_result_object(self): - """Display a message when the query returns no result object.""" - pass - - @abstractmethod - def display_connection_error(self, error: Exception): - """Display a connection error.""" - pass - - @abstractmethod - def display_query_error(self, error: Exception): - """Display a query error.""" - pass - - @abstractmethod - def display_unexpected_error(self, error: Exception, is_async: bool): - """Display an unexpected error.""" - pass - - @abstractmethod - def display_disconnect(self, is_async: bool): - """Display a disconnect message.""" - pass - - @abstractmethod - def display_greeting(self): - """Display a greeting message.""" - pass - - -class PlainTextOutputProvider(OutputProvider): - """Output provider for plain text logging.""" - - def _json_serializer(self, obj: Any) -> str: - if isinstance(obj, (datetime.datetime, datetime.date)): - return obj.isoformat() - if isinstance(obj, datetime.timedelta): - return str(obj) - if isinstance(obj, decimal.Decimal): - return str(obj) - raise TypeError(f"Object of type {obj.__class__.__name__} is not JSON serializable") - - def display_query(self, query: str, is_async: bool): - mode = "asynchronous" if is_async else "synchronous" - logger.info(f"Executing {mode} query: {query}") - - def display_success(self, affected_rows: int, duration: float): - logger.info(f"Query executed successfully. Affected rows: {affected_rows}, Duration: {duration:.4f}s") - - def display_results(self, data: List[Dict[str, Any]], **kwargs): - logger.info("Results:") - for row in data: - print(json.dumps(row, indent=2, ensure_ascii=False, default=self._json_serializer)) - - def display_no_data(self): - logger.info("No data returned.") - - def display_no_result_object(self): - logger.info("Query executed, but no result object returned.") - - def display_connection_error(self, error: Exception): - logger.error(f"Database connection error: {error}") - - def display_query_error(self, error: Exception): - logger.error(f"Database query error: {error}") - - def display_unexpected_error(self, error: Exception, is_async: bool): - mode = "asynchronous" if is_async else "synchronous" - logger.error(f"An unexpected error occurred during {mode} execution: {error}", exc_info=True) - - def display_disconnect(self, is_async: bool): - mode = "asynchronous" if is_async else "synchronous" - logger.info(f"Disconnected from database ({mode}).") - - def display_greeting(self): - print("Rich library not found. We recommend installing rich for a more user-friendly output.") - - -if RICH_AVAILABLE: - class RichOutputProvider(OutputProvider): - """Output provider using the rich library for formatted output.""" - - def __init__(self, console: Console, ascii_borders: bool = False): - self.console = console - self.box_style = box.ASCII if ascii_borders else box.SQUARE - - def display_query(self, query: str, is_async: bool): - mode = "asynchronous" if is_async else "synchronous" - self.console.print(f"Executing {mode} query: [bold cyan]{query}[/bold cyan]") - - def display_success(self, affected_rows: int, duration: float): - self.console.print(f"[bold green]Query executed successfully.[/bold green] " - f"Affected rows: [bold cyan]{affected_rows}[/bold cyan], " - f"Duration: [bold cyan]{duration:.4f}s[/bold cyan]") - - def display_results(self, data: List[Dict[str, Any]], **kwargs): - if not isinstance(data, list) or not data or not all(isinstance(i, dict) for i in data): - self.console.print(data) - return - - table = Table(show_header=True, header_style="bold magenta", box=self.box_style) - headers = data[0].keys() - for header in headers: - table.add_column(header, style="dim", overflow="fold") - - for row in data: - table.add_row(*(str(v) for v in row.values())) - - self.console.print(table) - - def display_no_data(self): - self.console.print("[yellow]No data returned.[/yellow]") - - def display_no_result_object(self): - self.console.print("[yellow]Query executed, but no result object returned.[/yellow]") - - def display_connection_error(self, error: Exception): - self.console.print(Panel(f"[bold]Database Connection Error[/bold]\n[red]{error}[/red]", - title="[bold red]Error[/bold red]", border_style="red")) - - def display_query_error(self, error: Exception): - self.console.print(Panel(f"[bold]Database Query Error[/bold]\n[red]{error}[/red]", - title="[bold red]Error[/bold red]", border_style="red")) - - def display_unexpected_error(self, error: Exception, is_async: bool): - mode = "asynchronous" if is_async else "synchronous" - self.console.print(Panel(f"[bold]An unexpected error occurred during {mode} execution[/bold]\n[red]{error}[/red]", - title="[bold red]Error[/bold red]", border_style="red")) - - def display_disconnect(self, is_async: bool): - mode = "asynchronous" if is_async else "synchronous" - self.console.print(f"[dim]Disconnected from database ({mode}).[/dim]") - - def display_greeting(self): - self.console.print("[bold green]Rich library detected. Logging and output are beautified.[/bold green]")