diff --git a/changelog.d/138.added.md b/changelog.d/138.added.md new file mode 100644 index 00000000..256f24ec --- /dev/null +++ b/changelog.d/138.added.md @@ -0,0 +1 @@ +Added the model command-line interface (`rhosocial.activerecord.cli`): `dml`, `dql`, `set`, and `cte` command groups, JSON/JSONL bulk input, default pagination for queries, Rich-optional output, stable exit codes, translated model/backend errors, and topic-based English/Chinese documentation with runnable examples. Also hardened `find_one_or_create` race re-reads and savepoint probing, and clarified the ambiguous `StaleRecordError` message for zero-row updates. diff --git a/docs/en_US/SUMMARY.md b/docs/en_US/SUMMARY.md index e565d9f5..4fcc5403 100644 --- a/docs/en_US/SUMMARY.md +++ b/docs/en_US/SUMMARY.md @@ -149,6 +149,17 @@ * [FTS5](backend/sqlite/backend_specific_features/fts5.md) * [Extension](backend/sqlite/backend_specific_features/extension.md) +## Command-Line Interface + +* [Model Command-Line Interface](cli/README.md) + * [DML](cli/dml.md) + * [Bulk DML](cli/bulk.md) + * [DQL](cli/dql.md) + * [Set operations and CTEs](cli/set-cte.md) + * [Connections](cli/connections.md) + * [Output and pipes](cli/output.md) + * [Errors](cli/errors.md) + ## Testing * [Overview](testing/README.md) diff --git a/docs/en_US/cli/README.md b/docs/en_US/cli/README.md new file mode 100644 index 00000000..46410c8f --- /dev/null +++ b/docs/en_US/cli/README.md @@ -0,0 +1,129 @@ +# Model Command-Line Interface + +`rhosocial-activerecord` is the **model** entry point. It is not the same +command as `rhosocial-activerecord-` (for example +`rhosocial-activerecord-sqlite`): the backend command executes SQL and +introspects the database, while this command operates on ActiveRecord model +classes through the framework APIs. + +The CLI is a thin adapter. It never builds SQL strings: DML calls ActiveRecord +methods, DQL calls `ActiveQuery`, and `set` / `cte` execute user-supplied +`SetOperationQuery` / `CTEQuery` objects. + +## Pages + +| Page | Contents | +|------|----------| +| [DML](dml.md) | `dml create/update/delete`, dry runs, soft delete, read-only models | +| [Bulk DML](bulk.md) | `dml bulk create/update/delete`, JSON and JSONL input, batching | +| [DQL](dql.md) | `dql get/list/count/exists/aggregate/run`, filters, pagination, aggregates | +| [Set operations and CTEs](set-cte.md) | `set union/union-all/intersect/except`, `set run`, `cte run` | +| [Connections](connections.md) | Model references, backend/config classes, named connections, async models | +| [Output and pipes](output.md) | Formats, fields, pipes, logging, exit codes | +| [Errors](errors.md) | Exception mapping, validation semantics, cancellation | + +Runnable examples live in +[`docs/examples/chapter_13_cli`](../../examples/chapter_13_cli/README.md). + +## Installation + +The CLI ships with the core package. `table` output needs the optional `[cli]` +extra: + +```bash +pip install "rhosocial-activerecord[cli]" +``` + +Without `rich`, `-o table` falls back to JSON instead of failing. + +## Command tree + +```text +rhosocial-activerecord +├── dml +│ ├── create +│ ├── update +│ ├── delete +│ └── bulk +│ ├── create +│ ├── update +│ └── delete +├── dql +│ ├── get +│ ├── list +│ ├── count +│ ├── exists +│ ├── aggregate +│ └── run +├── set +│ ├── union +│ ├── union-all +│ ├── intersect +│ ├── except +│ └── run +└── cte + └── run +``` + +## Feature summary + +| Command | Purpose | Main options | Typical output | +|---------|---------|--------------|----------------| +| `dml create` | Insert one record | `--data` | the created record | +| `dml update` | Update one record by primary key | `--pk`, `--data` | the updated record | +| `dml delete` | Delete one record | `--pk`, `--yes`, `--dry-run` | `action`, `pk`, `affected` | +| `dml bulk create` | Insert many records | `--data`, `--input-format`, `--batch-size` | created records | +| `dml bulk update` | Update many records | `--data`, `--update-fields`, `--batch-size` | `action`, `affected` | +| `dml bulk delete` | Delete many records by primary key | `--data`, `--yes`, `--dry-run` | `action`, `affected` | +| `dql get` | Fetch one record by primary key | `--pk` | one record | +| `dql list` | Filter, sort, and page records | `--where`, `--order-by`, `--select`, pagination | records | +| `dql count` | Count matching records | `--where` | scalar | +| `dql exists` | Test whether any record matches | `--where` | scalar | +| `dql aggregate` | Scalar or grouped aggregate | `--fn`, `--field`, `--group-by`, `--distinct` | scalar or rows | +| `dql run` | Execute a user-supplied `ActiveQuery` | `--query`, `--mode` | depends on `--mode` | +| `set union` etc. | Combine two model queries | `--left-model`, `--right-model`, `--left-where`, `--right-where` | rows | +| `set run` | Execute a user-supplied `SetOperationQuery` | `--query` | rows | +| `cte run` | Execute a user-supplied `CTEQuery` | `--query` | rows | + +Rules that apply to every command: + +- stdout carries data only; notes, logs, and errors go to stderr. +- `-o` defaults to `table` on a TTY and `json` otherwise. +- Exit codes are stable; see [Output and pipes](output.md#exit-codes). +- Destructive commands never prompt in a non-interactive context; they fail + with exit code 5 unless `--yes` is given. + +## Quick start + +```bash +# One record +rhosocial-activerecord dml create \ + --model app.models:User \ + --backend-class mypkg.backend:MyBackend \ + --config-class mypkg.config:MyConnectionConfig \ + --config database=app.db \ + --data '{"name": "Alice", "age": 30}' -o json + +# Filtered list +rhosocial-activerecord dql list \ + --model app.models:User \ + --where "status=active" --order-by "created_at desc" --limit 20 -o json + +# Count as a bare value +rhosocial-activerecord dql count --model app.models:User -o value +``` + +The connection flags are only needed when the model module does not call +`configure()` itself. See [Connections](connections.md). + +## Using these commands from an AI agent + +- Prefer `-o json` or `-o jsonl`: the payload is parseable and never mixed with + log lines. +- Always pass `--yes` to destructive commands; prompts would block the agent. +- Branch on exit codes, not on message text. The codes are stable; messages are + meant for humans. +- Prefer `dql run` with a small Python query factory when `--where` is not + expressive enough: it avoids shell-quoting complex predicates. +- Read the per-page "Using these commands from an AI agent" section for + command-specific advice. diff --git a/docs/en_US/cli/bulk.md b/docs/en_US/cli/bulk.md new file mode 100644 index 00000000..2c89997a --- /dev/null +++ b/docs/en_US/cli/bulk.md @@ -0,0 +1,106 @@ +# Bulk DML + +Bulk commands call `Model.bulk_create()`, `bulk_update()`, and `bulk_delete()`. +Validation, events, transactions, batching, and soft delete keep their +framework semantics; the CLI only parses the payload. + +## Input + +`--data` accepts a JSON array, a single object, an `@file`, stdin (`@-` / `-`), +and JSONL (one object per line). `--input-format json|jsonl` forces the parser +when auto-detection is ambiguous: + +```bash +# JSON array +rhosocial-activerecord dml bulk create --model app.models:User \ + --data '[{"name": "A"}, {"name": "B"}]' -o json + +# JSONL from stdin +cat users.jsonl | rhosocial-activerecord dml bulk create --model app.models:User \ + --data @- --input-format jsonl -o json +``` + +Auto-detection reads the first non-space character: `[` means a JSON array, +`{` means one object or a JSONL stream, anything else is parsed as JSONL. + +## Bulk create + +```bash +rhosocial-activerecord dml bulk create --model app.models:User \ + --data '[{"name": "Grace", "age": 26}, {"name": "Heidi", "age": 33}]' \ + --batch-size 2 -o json +``` + +- Output is the list of created records, in input order. +- `--batch-size N` splits the work into batches of `N` records. +- A validation failure names the item: + +```text +Error: Invalid data for User (item 2): name: String should have at least 2 characters +``` + +Nothing is written when any item fails validation. + +## Bulk update + +```bash +rhosocial-activerecord dml bulk update --model app.models:User \ + --data '[{"id": 1, "age": 31}, {"id": 2, "age": 26}]' \ + --update-fields age -o json +``` + +- Every object must carry its primary key plus the update fields. +- Without `--update-fields`, every object must contain the same non-key field + set; otherwise the command refuses and asks for `--update-fields`. +- `--update-fields a,b` selects the fields explicitly and is validated against + the model fields before any SQL runs. +- A missing record fails the whole batch with the item index and primary key: + +```text +Error: User bulk update item 2 with pk=2 was not found +``` + +- A validation failure names the item and primary key: + +```text +Error: Bulk update failed at item 2 (pk=2): age: Input should be <= 100 +``` + +- Output: `{"action": "update", "affected": 2}`. + +## Bulk delete + +```bash +rhosocial-activerecord dml bulk delete --model app.models:User \ + --data '[1, 2]' --yes -o json +``` + +- `--data` holds primary keys: scalars, JSON objects for composite keys, or + JSONL. +- Confirmation uses the same rules as single delete: `--yes` is required when + non-interactive, `--dry-run` previews. +- Soft-delete models report `soft_delete`. + +## Output shape + +| Command | JSON output | +|---------|-------------| +| `bulk create` | array of created records | +| `bulk update` | `{"action": "update", "affected": N}` | +| `bulk delete` | `{"action": "delete"\|"soft_delete", "affected": N}` | + +`-o csv` / `-o tsv` render the summary rows with a header (`action,affected`); +`--no-header` omits the header. `-o value` prints only the affected count for +update and delete. + +## Using these commands from an AI agent + +- Chunk large payloads with `--batch-size` (for example 100–500 rows) instead of + sending one enormous array; the CLI keeps framework validation per batch. +- Prefer `-o json` for create (the created rows carry database-generated keys) + and `-o value` for update/delete when only the affected count matters. +- Payloads are validated before SQL, so a failure means "nothing was written"; + do not attempt compensating deletes. +- Every object in `bulk update` needs the same fields. If your data is ragged, + pass `--update-fields` explicitly. +- Feed JSONL through stdin to avoid command-line length limits on large batches. diff --git a/docs/en_US/cli/connections.md b/docs/en_US/cli/connections.md new file mode 100644 index 00000000..07e390a5 --- /dev/null +++ b/docs/en_US/cli/connections.md @@ -0,0 +1,94 @@ +# Connections and model references + +The CLI operates on model classes. It must import the class and obtain a +connection config and a backend class before any command runs. + +## Model references + +A model reference is `module:Class`, not `module.Class`: + +```bash +rhosocial-activerecord dql list --model app.models:User +``` + +The CLI puts the current working directory and every `--path DIR` (repeatable) +on `sys.path` before importing the model. The class must be an `ActiveRecord` or +`AsyncActiveRecord` subclass; anything else is rejected with a clear message. + +## Backend and connection + +The core package keeps no registry of backends, so name the classes your project +actually uses: + +| Flag | Meaning | +|------|---------| +| `--backend-class MODULE:CLASS` | Backend class; for an async model it must be an `AsyncStorageBackend` subclass | +| `--config-class MODULE:CLASS` | Connection config class | +| `--config KEY=VALUE` | Set a config field; may be repeated | +| `--connection MODULE:CALLABLE` | Named connection callable returning a `ConnectionConfig` | + +Each half resolves independently: + +- **Backend class**: `--backend-class`, otherwise the model's own `configure()`. +- **Connection config**: `--connection`, or `--config-class` with `--config` + values, otherwise the model's own `configure()`. + +So a named connection may supply credentials while the model module supplies the +backend class, or the reverse. + +`--connection` and `--config-class` are mutually exclusive. `--config` values +override the resolved config in all three paths (named connection, config class, +model configuration). Unknown keys and `options.KEY` entries go into +`config.options`. Values are coerced to the config field's type: `true`/`yes`/ +`1`/`on` for booleans, integers, floats, otherwise strings. + +```bash +# Model module does not call configure(): pass both halves +rhosocial-activerecord dql list \ + --model app.models:User \ + --backend-class rhosocial.activerecord.backend.impl.sqlite.backend:SQLiteBackend \ + --config-class rhosocial.activerecord.backend.impl.sqlite.config:SQLiteConnectionConfig \ + --config database=app.db -o json + +# Named connection plus an explicit backend class +rhosocial-activerecord dql list \ + --model app.models:User \ + --backend-class mypkg.backend:MyBackend \ + --connection app.connections:prod_db -o json +``` + +If the model module calls `configure()`, `--backend-class` and `--connection` / +`--config-class` may be omitted; any `--config` values then override the model's +existing configuration. + +## Async models + +The CLI detects the model type automatically. `ActiveRecord` uses the synchronous +backend; `AsyncActiveRecord` uses the asynchronous backend and an event loop +inside the CLI. There is no `--async` flag, but `--backend-class` must name a +class that matches the model: an `AsyncStorageBackend` subclass for an +asynchronous model, a `StorageBackend` subclass otherwise. + +## Python module form + +```bash +python -m rhosocial.activerecord.cli dql list --model app.models:User -o json +``` + +The installed console script (`rhosocial-activerecord`) is the same entry point. +The backend-specific command (for example `rhosocial-activerecord-sqlite`) is a +different tool that executes SQL and introspects the database; it does not know +about model classes. + +## Using these commands from an AI agent + +- Do not guess backend or config class paths. Read them from the project's + settings, or ask the user; a wrong path exits 4 with `Cannot import ...`. +- When the model module already calls `configure()`, pass only `--model` and let + the model supply the connection. +- `--path` is repeatable and is the supported way to point the CLI at a source + tree without installing it. +- The model class must be a real `ActiveRecord` / `AsyncActiveRecord` subclass; + a module-level function or a plain Pydantic model is rejected. +- Use `python -m rhosocial.activerecord.cli` when the console script is not on + `PATH`. diff --git a/docs/en_US/cli/dml.md b/docs/en_US/cli/dml.md new file mode 100644 index 00000000..310f26c4 --- /dev/null +++ b/docs/en_US/cli/dml.md @@ -0,0 +1,89 @@ +# DML: single-record writes + +`dml create`, `dml update`, and `dml delete` operate on exactly one record. +They call the model's own `save()` / `delete()` methods, so events, timestamps, +optimistic locking, soft delete, and validation keep their framework behaviour. + +## Create + +```bash +rhosocial-activerecord dml create --model app.models:User \ + --data '{"name": "Alice", "email": "alice@example.com", "age": 30}' -o json +``` + +`--data` accepts: + +| Form | Meaning | +|------|---------| +| `'{"k": "v"}'` | inline JSON object | +| `@user.json` | read from a file | +| `@-` or `-` | read stdin | + +The payload must be one JSON object. Validation runs when the model instance is +constructed, and again inside `save()` for business rules. A failure writes +nothing and exits 1. See [Errors](errors.md#validation). + +## Update + +```bash +rhosocial-activerecord dml update --model app.models:User \ + --pk 42 --data '{"age": 31}' -o json +``` + +- `--pk` is the primary-key value; composite keys use a JSON object + (`--pk '{"order_id": 1, "product_id": 101}'`, or `--pk-json`). +- Primary-key fields cannot be updated. Delete the record and create a new one + instead; the command refuses before issuing SQL. +- Only the supplied fields change; the record is loaded first. + +## Delete + +```bash +rhosocial-activerecord dml delete --model app.models:User --pk 42 --yes -o json +``` + +Deletion asks for confirmation on a TTY. When stdin or stdout is not a terminal, +pass `--yes`; otherwise the command exits with code 5. `--dry-run` reports what +would happen without writing: + +```bash +rhosocial-activerecord dml delete --model app.models:User --pk 42 --yes --dry-run +``` + +Output: + +```json +{"action": "delete", "pk": 42, "affected": 1, "dry_run": true} +``` + +For a soft-delete model the action is `soft_delete` and the payload names the +timestamp field: + +```json +{"action": "soft_delete", "pk": 42, "affected": 1, "deleted_at_field": "deleted_at"} +``` + +## Read-only models + +A model that declares `__read_only__ = True` (for example through +`ReadOnlyMixin`) rejects every DML command before connecting, with exit code 6: + +```text +Error: ReadOnlyUser is read-only (__read_only__ = True); 'dml create' is not allowed. +``` + +DQL remains available for read-only models. + +## Using these commands from an AI agent + +- Always pass `--yes` to `dml delete`; a prompt is a blocking failure for an + agent. +- Call `--dry-run` first when the goal is to verify the target row and action + (`delete` vs `soft_delete`). +- `dml update` is not idempotent in the presence of concurrent writers: a + version conflict raises `StaleRecordError` and exits 1. Re-read with + `dql get`, then retry. +- Use `-o json` and check the `action` field rather than assuming a physical + delete: soft-delete models keep the row. +- A missing primary key exits 3 and writes nothing. Treat 3 as "the row does + not exist", not as an infrastructure failure. diff --git a/docs/en_US/cli/dql.md b/docs/en_US/cli/dql.md new file mode 100644 index 00000000..6bd21e75 --- /dev/null +++ b/docs/en_US/cli/dql.md @@ -0,0 +1,172 @@ +# DQL: queries, pagination, and aggregates + +`dql` calls `ActiveQuery` methods directly. There is no second filter language: +the CLI maps simple options onto `where()`, `order_by()`, `select()`, `limit()`, +and `offset()`. + +## Commands + +| Command | Purpose | +|---------|---------| +| `dql get` | One record by primary key | +| `dql list` | Filtered, sorted, paginated records | +| `dql count` | Number of matching records | +| `dql exists` | Whether any record matches | +| `dql aggregate` | Scalar or grouped aggregate | +| `dql run` | Execute a user-supplied `ActiveQuery` | + +## Filters + +```bash +rhosocial-activerecord dql list --model app.models:User \ + --where "status=active" --where "age=30" \ + --order-by "created_at desc" --order-by "id asc" \ + --select id,name --limit 20 --offset 0 -o json +``` + +- `--where` supports **equality only**: `field=value`. Clauses may be repeated + and are ANDed. Values are converted to the field type. +- Unknown fields are rejected before SQL runs, and the message lists the + available fields. +- `--order-by` accepts `field` or `field asc|desc`. +- `--select` limits the selected columns and validates the names. +- For comparisons (`>=`, `LIKE`, `IN`, …) use [`dql run`](#run-your-own-query) + with a Python factory. + +Soft-delete models add two visibility flags: + +```bash +rhosocial-activerecord dql list --model app.models:Task --include-deleted +rhosocial-activerecord dql list --model app.models:Task --only-deleted +``` + +They call `query_with_deleted()` / `query_only_deleted()` and are mutually +exclusive. + +## Pagination + +`dql list`, `dql aggregate` with `--group-by`, and `dql run --mode list|aggregate` +apply pagination. `dql get`, `dql count`, `dql exists`, and `dql run --mode +one|count|exists` do not. + +Options: + +| Option | Effect | +|--------|--------| +| `--limit N` | At most `N` rows | +| `--offset N` | Skip `N` rows | +| `--page N --page-size M` | Equivalent to `limit M offset (N-1)*M`; both required, both positive | +| `--no-limit` | Disable automatic injection | +| `--limit 0` | Also disables the limit (no pagination) | +| `--quiet` | Suppress the automatic-pagination note | + +### Automatic injection + +When a paginating command has no explicit limit and the query itself carries no +limit, the CLI injects `DEFAULT_LIST_LIMIT` (100) and explains it on stderr: + +```text +Showing first 100 rows (default pagination); use --limit N or --no-limit to change. +``` + +For grouped aggregates the label changes: + +```text +Showing first 100 groups (default pagination); use --limit N or --no-limit to change. +``` + +The note goes to **stderr only**; stdout stays machine-readable. `--quiet` +removes the note. `--no-limit` (a guard against accidentally loading an entire +table) keeps the query unbounded. + +Scalar aggregates are never paginated: `dql aggregate --fn count` runs a +`COUNT(*)` and returns one value. + +## Count and existence + +```bash +rhosocial-activerecord dql count --model app.models:User --where "status=active" -o value +rhosocial-activerecord dql exists --model app.models:User --where "status=active" -o value +``` + +`-o value` prints `4` or `true` / `false`; `-o json` prints a JSON scalar. + +## Aggregates + +```bash +# Scalar: one value +rhosocial-activerecord dql aggregate --model app.models:Order \ + --fn sum --field total -o value + +# Grouped: one row per group +rhosocial-activerecord dql aggregate --model app.models:Order \ + --fn count --field '*' --group-by status -o json +``` + +- `--fn` is one of `count`, `sum`, `avg`, `min`, `max`. +- `--field '*'` is only valid for `count`. +- `--field` must name a model field; unknown names are rejected before SQL runs. +- `--group-by` may be repeated; each name is validated. +- `--distinct` applies `DISTINCT` inside the aggregate. +- A grouped aggregate returns dictionary rows; a scalar aggregate returns one + value. + +Empty grouped results cannot be used to infer columns. Pass `--fields` when the +result may be empty, otherwise the command reports: + +```text +Error: The query returned no rows, so output fields cannot be inferred. Pass --fields explicitly. +``` + +## Run your own query + +`dql run` executes an `ActiveQuery` (or subclass) returned by a +`module:callable` or `module:Class.method` reference: + +```python +# app/queries.py +from app.models import User + +def active_users(): + return User.query().where(User.c.age >= 30).order_by(User.c.age.desc()) +``` + +```bash +rhosocial-activerecord dql run --model app.models:User \ + --query app.queries:active_users -o json + +# A model classmethod that returns a query +rhosocial-activerecord dql run --model app.models:User \ + --query app.models:User.active_users -o json +``` + +- The factory is called **without arguments**. +- The return value must be an `ActiveQuery` or subclass, including business + wrappers and `__query_class__` methods that return `self`. Anything else is + rejected with a message naming the expected type. +- `--mode` selects how the query is executed: + +| Mode | Equivalent call | +|------|----------------| +| `list` (default) | `all()` | +| `one` | `one()`; a missing row exits 3 | +| `count` | `count()` | +| `exists` | `exists()` | +| `aggregate` | `aggregate()`; returns dictionary rows | + +- `list` and `aggregate` modes apply pagination (including automatic injection). +- `one`, `count`, and `exists` ignore pagination options. + +## Using these commands from an AI agent + +- `--where` is equality-only. Do not try `age>=30`; generate a query factory and + call `dql run` instead. +- Automatic pagination is silent on stdout and loud on stderr. Default to + `--limit N` (or `--no-limit`) so the result size is explicit, and pass + `--quiet` to drop the note if you parse stderr. +- Use `dql count` before a large `dql list` to estimate cost. +- Ask for `-o jsonl` when piping row-by-row into another tool. +- `dql run` factories are imported from `--path` directories; write the factory + in the project rather than trying to express complex predicates as strings. +- When a grouped aggregate may be empty, pass `--fields` to avoid the + field-inference error. diff --git a/docs/en_US/cli/errors.md b/docs/en_US/cli/errors.md new file mode 100644 index 00000000..31034d25 --- /dev/null +++ b/docs/en_US/cli/errors.md @@ -0,0 +1,96 @@ +# Errors and validation + +The CLI translates model and backend exceptions into command-level errors. It +never changes framework semantics: models still validate when constructed, and +`save()` still re-validates business rules. + +## Message and exit code mapping + +| Situation | Message starts with | Exit code | +|---|---|---| +| Invalid record data | `Invalid data for Model ...` | 1 | +| Constraint conflict | `Integrity constraint failed ...` | 1 | +| Record not found | `Model with pk=... was not found` | 3 | +| Read-only model | `... is read-only ...` | 6 | +| Confirmation required | `... Pass --yes ...` | 5 | +| Version conflict | `Record version mismatch ...` | 1 | +| Lock or deadlock | `Record is locked ...` / `Deadlock detected ...` | 1 | +| Connection failure | `Database connection failed ...` | 1 | +| Other database error | `Database error ...` | 1 | +| Missing backend or config class | `Cannot import the ...` | 4 | +| Unknown failure | `Unexpected error (TypeName): ...` | 1 | + +Please see [Output and pipes](output.md#exit-codes) for the complete code list. + +## Model exceptions versus CLI errors + +The model layer raises `backend.errors.RecordNotFound` when a query returns no +row. The CLI raises its own `CliNotFoundError` and maps it to exit code 3. They +stay separate classes on purpose: + +- `RecordNotFound` describes a query result and carries no exit code. +- `CliNotFoundError` describes a command outcome. + +Command handlers translate with `raise ... from exc`, so the original exception +stays available in the chain for debugging. + +## Validation + +Validation happens at two points: + +1. When the model instance is constructed (`dml create`, `bulk create`, and the + data loaded by `dml update`). +2. Inside `save()`: `model_validate(model_dump())` plus `validate_record()`, + which runs business rules. + +A failure writes nothing and exits 1. For `dml update` the message names the +model and primary key: + +```text +Error: Invalid data for User pk=42: age: Input should be less than or equal to 100 +``` + +For bulk operations the message names the item (and the primary key when it is +known): + +```text +Error: Invalid data for User (item 2): name: String should have at least 2 characters +Error: Bulk update failed at item 2 (pk=2): age: Input should be <= 100 +``` + +Bulk commands validate the whole batch before issuing SQL, so a failed batch +leaves the table untouched. + +## Cancellation + +Declining a confirmation is not an error: + +- exit code 0; +- stderr shows `Operation cancelled.` without the `Error:` prefix; +- nothing is written. + +In a non-interactive context the CLI never prompts: a destructive command without +`--yes` exits 5 with a message asking for `--yes`. + +## Unexpected failures + +Known exceptions are mapped as above. Anything else prints one line: + +```text +Unexpected error (ValueError): ... +``` + +No traceback is printed. Run with `--log-level DEBUG` to get the full traceback +in the log stream (stderr). + +## Using these commands from an AI agent + +- Branch on exit codes: 3 means "not found", 5 means "confirmation required", + 6 means "read-only", 4 means "bad import path", 1 is a general failure. +- Distinguish `RecordNotFound` (model layer) from `CliNotFoundError` when reading + library code: the CLI error is what a command returns. +- A validation failure guarantees no write; do not issue compensating deletes. +- Capture stderr separately. Error lines start with `Error:` (or `Unexpected + error (...)` for unknown failures) and are not valid JSON. +- Re-run with `--log-level DEBUG` only when diagnosing; it produces verbose + output on stderr. diff --git a/docs/en_US/cli/output.md b/docs/en_US/cli/output.md new file mode 100644 index 00000000..a4374684 --- /dev/null +++ b/docs/en_US/cli/output.md @@ -0,0 +1,88 @@ +# Output, pipes, and exit codes + +Machine formats write only data to stdout. Notes, logs, and errors always go to +stderr, so a payload can be piped or captured without filtering. + +## Formats + +`-o table|json|jsonl|csv|tsv|value`: + +| Format | Use | +|--------|-----| +| `json` | Array for lists, object for one record, scalar for counts | +| `jsonl` | One JSON value per line; ideal for pipes and streaming | +| `csv` / `tsv` | A header row plus data rows; `--no-header` omits the header | +| `value` | A bare scalar for `count`, `exists`, or a scalar aggregate | +| `table` | Human-readable Rich table; falls back to JSON without Rich | + +When stdout is not a TTY and `-o` is omitted, the default is `json`. On a TTY the +default is `table`. + +`--fields id,name` limits the output columns. For list results the names are +validated against the model (or the dictionary keys); an unknown name lists the +available fields. `--fields` on a scalar result is ignored. + +`-o value` is only valid for scalar results. Passing it to a list or a single +record exits 1 with `Error: -o value is only valid for scalar results`. + +## Write results + +DML delete and bulk update/delete return summary objects: + +| Command | JSON | CSV/TSV columns | +|---------|------|-----------------| +| `dml delete` | `{"action", "pk", "affected"}` (+ `deleted_at_field`, `dry_run`) | `action,pk,affected` (+ extras) | +| `dml bulk update` | `{"action", "affected"}` | `action,affected` | +| `dml bulk delete` | `{"action", "affected"}` | `action,affected` | + +`-o value` prints only the affected count for these commands. `dml bulk create` +and `dml create` return the created records, so `json` / `jsonl` / `csv` / `tsv` +describe rows, not an envelope. + +## Pipes + +```bash +cat user.json | rhosocial-activerecord dml create --model app.models:User --data @- + +rhosocial-activerecord dql list --model app.models:User -o jsonl \ + | jq -c 'select(.status == "active")' + +rhosocial-activerecord dql count --model app.models:User -o value +``` + +- `--data @-` and `--data -` read stdin. +- A downstream pipe closing early (for example `| head`) is handled without a + traceback and exits with code 141. +- JSONL is the safest format for long pipelines: each line is a complete value. + +## Logging and notes + +- The default framework log level is `WARNING`; logs go to stderr. +- `--log-level DEBUG` prints backend and model detail useful for diagnosing a + connection or model-loading problem. +- `--quiet` suppresses informational stderr such as the automatic-pagination + note. It does not suppress errors. + +## Exit codes + +| Code | Meaning | +|------|---------| +| 0 | Success, including a declined confirmation | +| 1 | Model, connection, validation, or database error | +| 2 | Command-line usage error (argparse) | +| 3 | Record not found | +| 4 | A backend or connection config class cannot be imported | +| 5 | Destructive command needs `--yes` in a non-interactive context | +| 6 | DML refused on a read-only model | +| 141 | Downstream pipe closed | + +## Using these commands from an AI agent + +- Default to `-o json` (or `jsonl` for streams). Never parse `table` output. +- stdout is payload-only; treat any text on stderr as diagnostics, not data. +- Handle 141 explicitly: it means the consumer stopped reading, not that the + command failed. +- Use `--fields` to keep payloads small and stable when the model has many + columns. +- Use `--quiet` together with `-o json` when you capture stderr for logs; the + pagination note is informational and safe to drop. diff --git a/docs/en_US/cli/set-cte.md b/docs/en_US/cli/set-cte.md new file mode 100644 index 00000000..dc0d1ad7 --- /dev/null +++ b/docs/en_US/cli/set-cte.md @@ -0,0 +1,97 @@ +# Set operations and CTEs + +`set` and `cte` execute existing `SetOperationQuery` / `CTEQuery` objects. +There are two ways to use them: + +1. **Simple arms** (`set union`, `union-all`, `intersect`, `except`) combine two + model queries built from CLI options. +2. **Composed queries** (`set run`, `cte run`) execute a factory that returns + the query object, which covers CTEs, multiple arms, and custom predicates. + +## Simple set operations + +```bash +rhosocial-activerecord set union \ + --left-model app.models:User --right-model app.models:Admin \ + --left-where "status=active" --right-where "status=active" -o json +``` + +- `--left-model` / `--right-model` are required and must both be synchronous or + both asynchronous. +- `--left-where` / `--right-where` use the same equality-only `field=value` + syntax as `dql list`; `--order-by` and `--select` apply to both arms. +- `--limit`, `--offset`, and `--page/--page-size` are applied to each arm. There + is **no automatic default pagination** for set arms. +- `union-all` maps to `UNION ALL`; `intersect` / `except` need backend support. + +Rows come back as dictionaries, because a set operation is not tied to one model +class. Output formatting rules are the same as for plain query rows. + +## Composed queries + +```python +# app/queries.py +from app.models import Admin, User + +def active_users_union(): + return User.query().where(User.c.status == "active").union( + Admin.query().where(Admin.c.status == "active") + ) +``` + +```bash +rhosocial-activerecord set run --model app.models:User \ + --query app.queries:active_users_union -o json +``` + +- `--model` is optional for `set run` / `cte run`: pass it when the factory + needs the model to be configured, omit it when the factory configures its own + connection. +- The factory is called without arguments and must return a `SetOperationQuery` + (`set run`) or `CTEQuery` (`cte run`); a plain `ActiveQuery` is rejected with + a message naming the expected type. +- These commands call `aggregate()`. `SetOperationQuery` and `CTEQuery` rows are + dictionaries; they do not support `all()` or `limit()`. +- `set run` and `cte run` do not add pagination. Put limits inside the query + when needed. + +## CTEs + +```python +# app/queries.py +from rhosocial.activerecord.query import CTEQuery +from app.models import User + +def older_users(): + cte = CTEQuery(User.backend()) + cte.with_cte("older_users", User.query().where(User.c.age >= 30)) + return cte.from_cte("older_users") +``` + +```bash +rhosocial-activerecord cte run --model app.models:User \ + --query app.queries:older_users -o json +``` + +Recursive CTEs and dialect-specific variations are built in Python with the +framework API; the CLI only executes the resulting object. + +## Backend support + +`intersect` / `except` and recursive CTEs depend on the database. When the +dialect does not support a feature the backend raises an error, which the CLI +reports with exit code 1. There is no CLI-level capability switch: use the +framework's dialect protocols to decide at the application level. + +## Using these commands from an AI agent + +- Prefer `set run` / `cte run` for anything beyond two simple arms: the factory + is ordinary Python and avoids CLI quoting problems. +- Pass `--model` when the factory calls `Model.backend()`; omit it only when the + factory configures its own connection. +- Set-operation rows are dictionaries with no model metadata: `--fields` must + name keys that actually exist in the rows. +- For an empty result, pass `--fields` so the output columns are known. +- A rejected query type (for example an `ActiveQuery` passed to `set run`) + exits 1 with a message naming the expected type; fix the factory rather than + retrying the same command. diff --git a/docs/examples/chapter_13_cli/01_dml.py b/docs/examples/chapter_13_cli/01_dml.py new file mode 100644 index 00000000..5ce68049 --- /dev/null +++ b/docs/examples/chapter_13_cli/01_dml.py @@ -0,0 +1,134 @@ +# docs/examples/chapter_13_cli/01_dml.py +"""CLI Chapter: Example 1 - Single-record DML. + +Shows create, update, dry-run, delete, the not-found exit code, the read-only +refusal, and the soft-delete action label. +""" + +import tempfile + +from models import temporary_database + +from cli_runner import base_args, run_cli + + +def main(): + print("=" * 60) + print("Example 1: single-record DML") + print("=" * 60) + + with tempfile.TemporaryDirectory() as tmp: + database = temporary_database(tmp) + base = base_args(database) + + run_cli( + [ + "dml", + "create", + *base, + "--model", + "models:User", + "--data", + '{"id": 5, "name": "Eve", "email": "eve@example.com", "age": 28}', + "-o", + "json", + ] + ) + + run_cli( + [ + "dml", + "update", + *base, + "--model", + "models:User", + "--pk", + "5", + "--data", + '{"age": 29}', + "-o", + "json", + ] + ) + + run_cli( + [ + "dml", + "delete", + *base, + "--model", + "models:User", + "--pk", + "5", + "--yes", + "--dry-run", + "-o", + "json", + ] + ) + + run_cli( + [ + "dml", + "delete", + *base, + "--model", + "models:User", + "--pk", + "5", + "--yes", + "-o", + "json", + ] + ) + + print("A missing primary key exits with code 3:") + run_cli( + [ + "dml", + "delete", + *base, + "--model", + "models:User", + "--pk", + "999", + "--yes", + "-o", + "json", + ] + ) + + print("Read-only models refuse DML before touching the database (code 6):") + run_cli( + [ + "dml", + "create", + *base, + "--model", + "models:ReadOnlyUser", + "--data", + '{"id": 6, "name": "Grace"}', + "-o", + "json", + ] + ) + + print("A soft-delete model reports the soft_delete action instead of delete:") + run_cli( + [ + "dml", + "delete", + *base, + "--model", + "models:Task", + "--pk", + "1", + "--yes", + "-o", + "json", + ] + ) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/chapter_13_cli/02_dql.py b/docs/examples/chapter_13_cli/02_dql.py new file mode 100644 index 00000000..f3dec7bf --- /dev/null +++ b/docs/examples/chapter_13_cli/02_dql.py @@ -0,0 +1,110 @@ +# docs/examples/chapter_13_cli/02_dql.py +"""CLI Chapter: Example 2 - Queries. + +Shows get, list with filters and ordering, count, exists, aggregate, the +default pagination note, and ``dql run`` with a query factory. +""" + +import tempfile + +from models import temporary_database + +from cli_runner import base_args, run_cli + + +def main(): + print("=" * 60) + print("Example 2: DQL") + print("=" * 60) + + with tempfile.TemporaryDirectory() as tmp: + database = temporary_database(tmp) + base = base_args(database) + + run_cli(["dql", "get", *base, "--model", "models:User", "--pk", "1", "-o", "json"]) + + run_cli( + [ + "dql", + "list", + *base, + "--model", + "models:User", + "--where", + "age=30", + "--order-by", + "name desc", + "--limit", + "2", + "-o", + "json", + ] + ) + + print("Without --limit the CLI pages by default and explains it on stderr:") + run_cli(["dql", "list", *base, "--model", "models:User", "-o", "json"]) + print("--no-limit disables the default page size:") + run_cli( + ["dql", "list", *base, "--model", "models:User", "--no-limit", "-o", "jsonl"] + ) + + run_cli( + ["dql", "count", *base, "--model", "models:User", "-o", "value"] + ) + run_cli( + [ + "dql", + "exists", + *base, + "--model", + "models:User", + "--where", + "age=99", + "-o", + "value", + ] + ) + + print("Grouped aggregate returns one row per group:") + run_cli( + [ + "dql", + "aggregate", + *base, + "--model", + "models:User", + "--fn", + "count", + "--field", + "*", + "--group-by", + "age", + "-o", + "json", + ] + ) + print("Scalar aggregate fits ``-o value``:") + run_cli( + [ + "dql", + "aggregate", + *base, + "--model", + "models:User", + "--fn", + "avg", + "--field", + "age", + "-o", + "value", + ] + ) + + print("``dql run`` executes a query factory; comparisons live in Python:") + run_cli( + ["dql", "run", *base, "--model", "models:User", "--query", "models:active_users", "-o", "json"] + ) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/chapter_13_cli/03_bulk_dml.py b/docs/examples/chapter_13_cli/03_bulk_dml.py new file mode 100644 index 00000000..ad85d99d --- /dev/null +++ b/docs/examples/chapter_13_cli/03_bulk_dml.py @@ -0,0 +1,126 @@ +# docs/examples/chapter_13_cli/03_bulk_dml.py +"""CLI Chapter: Example 3 - Bulk DML. + +Shows JSON and JSONL input, ``--input-format``, ``--batch-size``, +``--update-fields``, confirmed bulk delete, and the soft-delete label. +""" + +import json +import tempfile + +from models import temporary_database + +from cli_runner import base_args, run_cli + + +def main(): + print("=" * 60) + print("Example 3: bulk DML") + print("=" * 60) + + with tempfile.TemporaryDirectory() as tmp: + database = temporary_database(tmp) + base = base_args(database) + + run_cli( + [ + "dml", + "bulk", + "create", + *base, + "--model", + "models:User", + "--data", + json.dumps( + [ + {"name": "Grace", "email": "grace@example.com", "age": 26}, + {"name": "Heidi", "email": "heidi@example.com", "age": 33}, + {"name": "Ivan", "email": "ivan@example.com", "age": 41}, + ] + ), + "--batch-size", + "2", + "-o", + "json", + ] + ) + + print("JSONL from stdin keeps one object per line:") + rows = [ + {"name": "Judy", "email": "judy@example.com", "age": 24}, + {"name": "Karl", "email": "karl@example.com", "age": 38}, + ] + payload = "\n".join(json.dumps(row) for row in rows) + "\n" + run_cli( + [ + "dml", + "bulk", + "create", + *base, + "--model", + "models:User", + "--data", + "@-", + "--input-format", + "jsonl", + "-o", + "json", + ], + stdin=payload, + ) + + print("Every object in bulk update must carry the same update fields:") + print("The ids below are the ones the previous bulk creates returned.") + run_cli( + [ + "dml", + "bulk", + "update", + *base, + "--model", + "models:User", + "--data", + json.dumps([{"id": 8, "age": 25}, {"id": 9, "age": 39}]), + "--update-fields", + "age", + "-o", + "json", + ] + ) + + run_cli( + [ + "dml", + "bulk", + "delete", + *base, + "--model", + "models:User", + "--data", + "[2, 4]", + "--yes", + "-o", + "json", + ] + ) + + print("Bulk delete on a soft-delete model marks rows instead of removing them:") + run_cli( + [ + "dml", + "bulk", + "delete", + *base, + "--model", + "models:Task", + "--data", + "[1, 2]", + "--yes", + "-o", + "json", + ] + ) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/chapter_13_cli/04_set_cte.py b/docs/examples/chapter_13_cli/04_set_cte.py new file mode 100644 index 00000000..aff85631 --- /dev/null +++ b/docs/examples/chapter_13_cli/04_set_cte.py @@ -0,0 +1,105 @@ +# docs/examples/chapter_13_cli/04_set_cte.py +"""CLI Chapter: Example 4 - Set operations and CTEs. + +The simple form combines two model queries. The composed form passes a factory +that returns an existing ``SetOperationQuery`` or ``CTEQuery``. +""" + +import tempfile + +from models import temporary_database + +from cli_runner import base_args, run_cli + + +def main(): + print("=" * 60) + print("Example 4: set operations and CTEs") + print("=" * 60) + + with tempfile.TemporaryDirectory() as tmp: + database = temporary_database(tmp) + base = base_args(database) + + run_cli( + [ + "set", + "union", + *base, + "--left-model", + "models:User", + "--right-model", + "models:Admin", + "--left-where", + "age=30", + "--right-where", + "age=30", + "-o", + "json", + ] + ) + + run_cli( + [ + "set", + "intersect", + *base, + "--left-model", + "models:User", + "--right-model", + "models:Admin", + "--left-where", + "age=30", + "--right-where", + "age=35", + "-o", + "json", + ] + ) + + run_cli( + [ + "set", + "except", + *base, + "--left-model", + "models:User", + "--right-model", + "models:Admin", + "-o", + "json", + ] + ) + + print("A factory can compose the arms in Python and return the query object:") + run_cli( + [ + "set", + "run", + *base, + "--model", + "models:User", + "--query", + "models:user_union", + "-o", + "json", + ] + ) + + run_cli( + [ + "cte", + "run", + *base, + "--model", + "models:User", + "--query", + "models:older_users_cte", + "-o", + "json", + ] + ) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/chapter_13_cli/05_output_and_pipes.py b/docs/examples/chapter_13_cli/05_output_and_pipes.py new file mode 100644 index 00000000..6695a131 --- /dev/null +++ b/docs/examples/chapter_13_cli/05_output_and_pipes.py @@ -0,0 +1,97 @@ +# docs/examples/chapter_13_cli/05_output_and_pipes.py +"""CLI Chapter: Example 5 - Output formats and stdin. + +Machine formats write only data to stdout, so they can feed other tools. +Comments and pagination notes go to stderr and never mix into the payload. +""" + +import json +import tempfile + +from models import temporary_database + +from cli_runner import base_args, run_cli + + +def main(): + print("=" * 60) + print("Example 5: output formats and stdin") + print("=" * 60) + + with tempfile.TemporaryDirectory() as tmp: + database = temporary_database(tmp) + base = base_args(database) + + print("``-o table`` uses Rich when installed and falls back to JSON otherwise:") + run_cli(["dql", "list", *base, "--model", "models:User", "-o", "table"]) + + print("CSV keeps a header unless --no-header is given:") + run_cli( + ["dql", "list", *base, "--model", "models:User", "--limit", "2", "-o", "csv"] + ) + run_cli( + [ + "dql", + "list", + *base, + "--model", + "models:User", + "--limit", + "2", + "-o", + "tsv", + "--no-header", + ] + ) + + print("--fields limits the output columns:") + run_cli( + [ + "dql", + "list", + *base, + "--model", + "models:User", + "--limit", + "2", + "--fields", + "id,name", + "-o", + "json", + ] + ) + + run_cli( + ["dql", "count", *base, "--model", "models:User", "-o", "value"] + ) + + print("JSONL is one object per line, ready for a downstream consumer:") + run_cli( + ["dql", "list", *base, "--model", "models:User", "--no-limit", "-o", "jsonl"] + ) + + print("--quiet drops the default-pagination note from stderr:") + run_cli( + ["dql", "list", *base, "--model", "models:User", "--quiet", "-o", "json"] + ) + + print("--data @- reads the record from stdin:") + record = json.dumps({"id": 11, "name": "Leo", "age": 27}) + run_cli( + [ + "dml", + "create", + *base, + "--model", + "models:User", + "--data", + "@-", + "-o", + "json", + ], + stdin=record, + ) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/chapter_13_cli/README.md b/docs/examples/chapter_13_cli/README.md new file mode 100644 index 00000000..3e060458 --- /dev/null +++ b/docs/examples/chapter_13_cli/README.md @@ -0,0 +1,63 @@ +# CLI examples + +Runnable examples for the model command-line interface documented in +[`docs/en_US/cli/README.md`](../../en_US/cli/README.md) and +[`docs/zh_CN/cli/README.md`](../../zh_CN/cli/README.md). + +Each script seeds a temporary SQLite database, runs real CLI commands, and +prints the command, its stdout/stderr, and the exit code. + +## Prerequisites + +Install the package from the repository root: + +```bash +pip install -e . +``` + +The scripts also work from a source checkout without installation, because +`cli_runner.py` adds `src/` to `PYTHONPATH` when it exists. + +## Scripts + +| Script | Shows | +|--------|-------| +| [`01_dml.py`](01_dml.py) | `dml create/update/delete`, `--dry-run`, not-found exit code 3, read-only exit code 6, `soft_delete` label | +| [`02_dql.py`](02_dql.py) | `dql get/list/count/exists/aggregate/run`, default pagination, `--no-limit` | +| [`03_bulk_dml.py`](03_bulk_dml.py) | `dml bulk create/update/delete`, JSON and JSONL input, `--batch-size`, `--update-fields` | +| [`04_set_cte.py`](04_set_cte.py) | `set union/intersect/except`, `set run` and `cte run` factories | +| [`05_output_and_pipes.py`](05_output_and_pipes.py) | `json/jsonl/csv/tsv/table/value`, `--fields`, `--no-header`, `--quiet`, stdin input | + +Run one at a time: + +```bash +python docs/examples/chapter_13_cli/01_dml.py +``` + +## Shared pieces + +- `models.py` defines `User`, `Admin`, `ReadOnlyUser`, and the soft-delete + `Task` model, plus the query factories used by `dql run`, `set run`, and + `cte run`. +- `cli_runner.py` builds the command (`python -m rhosocial.activerecord.cli`), + adds the model search path, and passes the SQLite backend and config classes. + +The examples never call `configure()` inside `models.py`; they pass +`--backend-class`, `--config-class`, and `--config database=...` instead, which +is the pattern for projects that keep configuration outside the model module. + +Printed commands shorten the class references and paths for readability; the +executed command always uses the full values. + +## Exit codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | Model, connection, validation, or database error | +| 2 | Command-line usage error | +| 3 | Record not found | +| 4 | Backend or config class cannot be imported | +| 5 | Destructive command needs `--yes` in a non-interactive context | +| 6 | DML refused on a read-only model | +| 141 | Downstream pipe closed | diff --git a/docs/examples/chapter_13_cli/cli_runner.py b/docs/examples/chapter_13_cli/cli_runner.py new file mode 100644 index 00000000..04a871dc --- /dev/null +++ b/docs/examples/chapter_13_cli/cli_runner.py @@ -0,0 +1,103 @@ +# docs/examples/chapter_13_cli/cli_runner.py +"""Helper that runs the model CLI in a subprocess. + +Examples execute real commands instead of calling ``main()`` in-process, so the +printed command line is the same one a shell would run. The only difference is +``python -m rhosocial.activerecord.cli`` in place of the installed +``rhosocial-activerecord`` console script. +""" + +import os +import shlex +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPO_SRC = HERE.parents[2] / "src" + +SQLITE_BACKEND = "rhosocial.activerecord.backend.impl.sqlite.backend:SQLiteBackend" +SQLITE_CONFIG = "rhosocial.activerecord.backend.impl.sqlite.config:SQLiteConnectionConfig" + + +def connection_args(database): + """Backend and config arguments for a SQLite file database.""" + return [ + "--backend-class", + SQLITE_BACKEND, + "--config-class", + SQLITE_CONFIG, + "--config", + f"database={database}", + ] + + +def base_args(database): + """Arguments shared by every command: model path plus connection.""" + return ["--path", str(HERE), *connection_args(database)] + + +def _shorten(reference): + """Keep only the class name of a ``module:Class`` reference.""" + return reference.rsplit(":", 1)[-1].rsplit(".", 1)[-1] + + +def _display_args(args): + """Shorten absolute paths and class references so commands stay readable.""" + display = [] + index = 0 + while index < len(args): + arg = args[index] + value = args[index + 1] if index + 1 < len(args) else None + if arg == "--path": + display.extend(["--path", "docs/examples/chapter_13_cli"]) + index += 2 + elif arg in ("--backend-class", "--config-class"): + display.extend([arg, _shorten(value)]) + index += 2 + elif arg == "--config": + database = value.partition("=")[2] + display.extend(["--config", f"database={Path(database).name}"]) + index += 2 + else: + display.append(arg) + index += 1 + return display + + +def build_env(): + """Make ``models`` and the package importable from a source checkout.""" + env = os.environ.copy() + entries = [str(HERE)] + if REPO_SRC.is_dir(): + entries.append(str(REPO_SRC)) + existing = env.get("PYTHONPATH") + if existing: + entries.append(existing) + env["PYTHONPATH"] = os.pathsep.join(entries) + return env + + +def run_cli(args, stdin=None, echo=True): + """Run the CLI and return the completed process.""" + argv = [sys.executable, "-m", "rhosocial.activerecord.cli", *args] + completed = subprocess.run( + argv, + cwd=str(HERE), + env=build_env(), + input=stdin, + text=True, + capture_output=True, + ) + if echo: + command = "rhosocial-activerecord " + " ".join( + shlex.quote(arg) for arg in _display_args(args) + ) + print(f"$ {command}") + if completed.stdout.strip(): + print(completed.stdout.rstrip()) + if completed.stderr.strip(): + print(f"[stderr] {completed.stderr.rstrip()}") + print(f"[exit {completed.returncode}]") + print() + return completed diff --git a/docs/examples/chapter_13_cli/models.py b/docs/examples/chapter_13_cli/models.py new file mode 100644 index 00000000..6d3f2109 --- /dev/null +++ b/docs/examples/chapter_13_cli/models.py @@ -0,0 +1,116 @@ +# docs/examples/chapter_13_cli/models.py +"""Models shared by the command-line examples. + +The CLI imports these classes by reference, for example ``models:User``. +Nothing here calls ``configure()``: each example passes the backend and +connection classes on the command line instead, which is the pattern to use +when a project keeps configuration decisions outside the model module. +""" + +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import ClassVar, Optional + +from rhosocial.activerecord.base.field_proxy import FieldProxy +from rhosocial.activerecord.field import ReadOnlyMixin, SoftDeleteMixin +from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.query import CTEQuery + + +class User(ActiveRecord): + __table_name__ = "users" + c: ClassVar[FieldProxy] = FieldProxy() + id: Optional[int] = None + name: str + email: Optional[str] = None + age: int = 0 + + +class Admin(ActiveRecord): + __table_name__ = "admins" + c: ClassVar[FieldProxy] = FieldProxy() + id: Optional[int] = None + name: str + email: Optional[str] = None + age: int = 0 + + +class ReadOnlyUser(ActiveRecord, ReadOnlyMixin): + __table_name__ = "users" + c: ClassVar[FieldProxy] = FieldProxy() + id: Optional[int] = None + name: str + email: Optional[str] = None + age: int = 0 + + +class Task(SoftDeleteMixin, ActiveRecord): + __table_name__ = "tasks" + c: ClassVar[FieldProxy] = FieldProxy() + id: Optional[int] = None + title: str + done: bool = False + deleted_at: Optional[datetime] = None + + +def create_schema(database): + """Create the demo tables and seed rows in a fresh SQLite file.""" + conn = sqlite3.connect(str(database)) + conn.execute( + "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, " + "email TEXT, age INTEGER DEFAULT 0)" + ) + conn.execute( + "CREATE TABLE admins (id INTEGER PRIMARY KEY, name TEXT NOT NULL, " + "email TEXT, age INTEGER DEFAULT 0)" + ) + conn.execute( + "CREATE TABLE tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL, " + "done INTEGER DEFAULT 0, deleted_at TEXT)" + ) + conn.executemany( + "INSERT INTO users (id, name, email, age) VALUES (?, ?, ?, ?)", + [ + (1, "Alice", "alice@example.com", 30), + (2, "Bob", "bob@example.com", 25), + (3, "Carol", "carol@example.com", 40), + (4, "Dan", "dan@example.com", 22), + ], + ) + conn.executemany( + "INSERT INTO admins (id, name, email, age) VALUES (?, ?, ?, ?)", + [(10, "Erin", "erin@example.com", 35), (11, "Frank", "frank@example.com", 30)], + ) + conn.executemany( + "INSERT INTO tasks (id, title, done) VALUES (?, ?, ?)", + [(1, "Write docs", 0), (2, "Review CLI", 1)], + ) + conn.commit() + conn.close() + + +def active_users(): + """Query factory for ``dql run``; returns an ``ActiveQuery``.""" + return User.query().where(User.c.age >= 30) + + +def user_union(): + """Set-operation factory for ``set run``.""" + return User.query().where(User.c.age == 30).union( + User.query().where(User.c.age == 40) + ) + + +def older_users_cte(): + """CTE factory for ``cte run``.""" + cte = CTEQuery(User.backend()) + cte.with_cte("older_users", User.query().where(User.c.age >= 30)) + return cte.from_cte("older_users") + + +def temporary_database(directory): + """Return a database path inside ``directory``, already seeded.""" + database = Path(directory) / "cli_examples.db" + create_schema(database) + return database diff --git a/docs/zh_CN/SUMMARY.md b/docs/zh_CN/SUMMARY.md index c19e3d1d..bbd09aea 100644 --- a/docs/zh_CN/SUMMARY.md +++ b/docs/zh_CN/SUMMARY.md @@ -149,6 +149,17 @@ * [FTS5](backend/sqlite/backend_specific_features/fts5.md) * [扩展](backend/sqlite/backend_specific_features/extension.md) +## 命令行工具 + +* [模型命令行接口](cli/README.md) + * [DML](cli/dml.md) + * [批量 DML](cli/bulk.md) + * [DQL](cli/dql.md) + * [集合操作与 CTE](cli/set-cte.md) + * [连接与模型引用](cli/connections.md) + * [输出与管道](cli/output.md) + * [错误](cli/errors.md) + ## 测试指南 * [概述](testing/README.md) diff --git a/docs/zh_CN/cli/README.md b/docs/zh_CN/cli/README.md new file mode 100644 index 00000000..d38420cb --- /dev/null +++ b/docs/zh_CN/cli/README.md @@ -0,0 +1,119 @@ +# 模型命令行接口 + +`rhosocial-activerecord` 是**模型**入口,与 `rhosocial-activerecord-` +(例如 `rhosocial-activerecord-sqlite`)不是同一个命令:后端命令执行 SQL 并做数据库 +内省,本命令通过框架 API 操作 ActiveRecord 模型类。 + +CLI 只是薄适配层,不拼接 SQL:DML 调用 ActiveRecord 方法,DQL 调用 `ActiveQuery`, +`set` / `cte` 执行用户自己构造的 `SetOperationQuery` / `CTEQuery`。 + +## 文档页面 + +| 页面 | 内容 | +|------|------| +| [DML](dml.md) | `dml create/update/delete`、dry-run、软删除、只读模型 | +| [批量 DML](bulk.md) | `dml bulk create/update/delete`、JSON/JSONL 输入、分批 | +| [DQL](dql.md) | `dql get/list/count/exists/aggregate/run`、过滤、分页、聚合 | +| [集合操作与 CTE](set-cte.md) | `set union/union-all/intersect/except`、`set run`、`cte run` | +| [连接与模型引用](connections.md) | 模型引用、后端/配置类、命名连接、异步模型 | +| [输出与管道](output.md) | 输出格式、字段、管道、日志、退出码 | +| [错误](errors.md) | 异常映射、校验语义、取消操作 | + +可运行示例在 +[`docs/examples/chapter_13_cli`](../../examples/chapter_13_cli/README.md)。 + +## 安装 + +CLI 随核心包提供。`table` 输出需要可选依赖 `[cli]`: + +```bash +pip install "rhosocial-activerecord[cli]" +``` + +没有 `rich` 时 `-o table` 回退为 JSON,不会报错。 + +## 命令树 + +```text +rhosocial-activerecord +├── dml +│ ├── create +│ ├── update +│ ├── delete +│ └── bulk +│ ├── create +│ ├── update +│ └── delete +├── dql +│ ├── get +│ ├── list +│ ├── count +│ ├── exists +│ ├── aggregate +│ └── run +├── set +│ ├── union +│ ├── union-all +│ ├── intersect +│ ├── except +│ └── run +└── cte + └── run +``` + +## 功能汇总 + +| 命令 | 用途 | 主要参数 | 典型输出 | +|------|------|----------|----------| +| `dml create` | 插入一条记录 | `--data` | 新建记录 | +| `dml update` | 按主键更新一条记录 | `--pk`、`--data` | 更新后的记录 | +| `dml delete` | 删除一条记录 | `--pk`、`--yes`、`--dry-run` | `action`、`pk`、`affected` | +| `dml bulk create` | 批量插入 | `--data`、`--input-format`、`--batch-size` | 新建记录列表 | +| `dml bulk update` | 批量更新 | `--data`、`--update-fields`、`--batch-size` | `action`、`affected` | +| `dml bulk delete` | 按主键批量删除 | `--data`、`--yes`、`--dry-run` | `action`、`affected` | +| `dql get` | 按主键取一条记录 | `--pk` | 一条记录 | +| `dql list` | 过滤、排序、分页查询 | `--where`、`--order-by`、`--select`、分页参数 | 记录列表 | +| `dql count` | 统计匹配数量 | `--where` | 标量 | +| `dql exists` | 判断是否存在匹配 | `--where` | 标量 | +| `dql aggregate` | 标量或分组聚合 | `--fn`、`--field`、`--group-by`、`--distinct` | 标量或行 | +| `dql run` | 执行用户传入的 `ActiveQuery` | `--query`、`--mode` | 由 `--mode` 决定 | +| `set union` 等 | 合并两个模型查询 | `--left-model`、`--right-model`、`--left-where`、`--right-where` | 行 | +| `set run` | 执行用户传入的 `SetOperationQuery` | `--query` | 行 | +| `cte run` | 执行用户传入的 `CTEQuery` | `--query` | 行 | + +所有命令都遵循: + +- stdout 只放数据;提示、日志、错误都写 stderr。 +- `-o` 在 TTY 下默认 `table`,非 TTY 默认 `json`。 +- 退出码稳定,见[输出与管道](output.md#退出码)。 +- 非交互环境下的破坏性命令不会弹提示,缺少 `--yes` 时以退出码 5 结束。 + +## 快速开始 + +```bash +# 创建一条记录 +rhosocial-activerecord dml create \ + --model app.models:User \ + --backend-class mypkg.backend:MyBackend \ + --config-class mypkg.config:MyConnectionConfig \ + --config database=app.db \ + --data '{"name": "Alice", "age": 30}' -o json + +# 条件查询 +rhosocial-activerecord dql list \ + --model app.models:User \ + --where "status=active" --order-by "created_at desc" --limit 20 -o json + +# 只输出计数 +rhosocial-activerecord dql count --model app.models:User -o value +``` + +只有模型模块自己不调用 `configure()` 时才需要连接参数,见[连接与模型引用](connections.md)。 + +## 让 AI 使用这些命令 + +- 优先 `-o json` 或 `-o jsonl`:可解析,且不会混入日志行。 +- 破坏性命令始终加 `--yes`,交互提示会阻塞代理。 +- 按退出码分支,不要匹配消息文本:退出码稳定,消息面向人类。 +- `--where` 表达力不够时,用 `dql run` 配合项目里的小型 Python 查询工厂,避免复杂谓词的引号问题。 +- 各章节末尾都有“让 AI 使用这些命令”的小节,按命令类型给出具体建议。 diff --git a/docs/zh_CN/cli/bulk.md b/docs/zh_CN/cli/bulk.md new file mode 100644 index 00000000..9eb0d0dc --- /dev/null +++ b/docs/zh_CN/cli/bulk.md @@ -0,0 +1,94 @@ +# 批量 DML + +批量命令调用 `Model.bulk_create()`、`bulk_update()`、`bulk_delete()`,校验、事件、 +事务、分批和软删除都保持框架语义;CLI 只负责解析载荷。 + +## 输入 + +`--data` 支持 JSON 数组、单个对象、`@file`、stdin(`@-` / `-`)以及 JSONL(每行一个对象)。 +自动识别有歧义时可用 `--input-format json|jsonl` 强制指定: + +```bash +# JSON 数组 +rhosocial-activerecord dml bulk create --model app.models:User \ + --data '[{"name": "A"}, {"name": "B"}]' -o json + +# 从 stdin 读取 JSONL +cat users.jsonl | rhosocial-activerecord dml bulk create --model app.models:User \ + --data @- --input-format jsonl -o json +``` + +自动识别看第一个非空白字符:`[` 为 JSON 数组,`{` 为单个对象或 JSONL 流,其它按 JSONL 解析。 + +## 批量创建 + +```bash +rhosocial-activerecord dml bulk create --model app.models:User \ + --data '[{"name": "Grace", "age": 26}, {"name": "Heidi", "age": 33}]' \ + --batch-size 2 -o json +``` + +- 输出按输入顺序排列的新建记录列表。 +- `--batch-size N` 按 `N` 条一组分批执行。 +- 校验失败会指出第几条: + +```text +Error: Invalid data for User (item 2): name: String should have at least 2 characters +``` + +任意一条校验失败,整批都不会写库。 + +## 批量更新 + +```bash +rhosocial-activerecord dml bulk update --model app.models:User \ + --data '[{"id": 1, "age": 31}, {"id": 2, "age": 26}]' \ + --update-fields age -o json +``` + +- 每个对象都要带主键和更新字段。 +- 不传 `--update-fields` 时,所有对象的非主键字段集合必须一致;否则命令拒绝并要求显式指定。 +- `--update-fields a,b` 会在发出 SQL 前校验字段是否属于模型。 +- 记录缺失会让整批失败,并指出条目和主键: + +```text +Error: User bulk update item 2 with pk=2 was not found +``` + +- 校验失败会指出条目和主键: + +```text +Error: Bulk update failed at item 2 (pk=2): age: Input should be <= 100 +``` + +- 输出:`{"action": "update", "affected": 2}`。 + +## 批量删除 + +```bash +rhosocial-activerecord dml bulk delete --model app.models:User \ + --data '[1, 2]' --yes -o json +``` + +- `--data` 放主键:标量、复合主键的 JSON 对象,或 JSONL。 +- 确认规则与单条删除一致:非交互必须 `--yes`,`--dry-run` 可预览。 +- 软删除模型报告 `soft_delete`。 + +## 输出形态 + +| 命令 | JSON 输出 | +|------|-----------| +| `bulk create` | 新建记录数组 | +| `bulk update` | `{"action": "update", "affected": N}` | +| `bulk delete` | `{"action": "delete"\|"soft_delete", "affected": N}` | + +`-o csv` / `-o tsv` 会输出带表头的摘要行(`action,affected`),`--no-header` 省略表头。 +更新和删除的 `-o value` 只输出受影响行数。 + +## 让 AI 使用这些命令 + +- 大批量用 `--batch-size`(例如 100–500 条)分块发送,CLI 会按块保留框架校验。 +- 创建用 `-o json`(需要数据库生成的主键);更新/删除只看影响行数时用 `-o value`。 +- 载荷在 SQL 之前整体校验,失败即“没有写入”,不要尝试补偿删除。 +- `bulk update` 要求所有对象字段一致;数据不齐时显式传 `--update-fields`。 +- 大载荷通过 stdin 传 JSONL,避免命令行长度限制。 diff --git a/docs/zh_CN/cli/connections.md b/docs/zh_CN/cli/connections.md new file mode 100644 index 00000000..94833812 --- /dev/null +++ b/docs/zh_CN/cli/connections.md @@ -0,0 +1,81 @@ +# 连接与模型引用 + +CLI 操作的是模型类。执行命令前必须导入类,并取得连接配置和后端类。 + +## 模型引用 + +模型引用是 `module:Class`,不是 `module.Class`: + +```bash +rhosocial-activerecord dql list --model app.models:User +``` + +CLI 在导入模型前会把当前工作目录和每个 `--path DIR`(可重复)加入 `sys.path`。 +类必须是 `ActiveRecord` 或 `AsyncActiveRecord` 子类,否则会被明确拒绝。 + +## 后端与连接 + +核心包不保存后端注册表,所以需要显式写出项目实际使用的类: + +| 参数 | 含义 | +|------|------| +| `--backend-class MODULE:CLASS` | 后端类;异步模型必须传 `AsyncStorageBackend` 子类 | +| `--config-class MODULE:CLASS` | 连接配置类 | +| `--config KEY=VALUE` | 设置配置字段;可重复 | +| `--connection MODULE:CALLABLE` | 返回 `ConnectionConfig` 的命名连接可调用对象 | + +两半各自独立解析: + +- **后端类**:优先 `--backend-class`,否则用模型模块自己 `configure()` 的结果。 +- **连接配置**:优先 `--connection`,或 `--config-class` 配合 `--config`,否则用模型模块自己 + `configure()` 的结果。 + +因此可以“命名连接提供凭据、模型模块提供后端类”,也可以反过来。 + +`--connection` 与 `--config-class` 互斥。`--config` 在三种路径下都会覆盖解析出的配置 +(命名连接、配置类、模型配置)。未知键和 `options.KEY` 会写入 `config.options`。 +值会按配置字段类型转换:布尔接受 `true`/`yes`/`1`/`on`,以及整数、浮点,其余按字符串。 + +```bash +# 模型模块不 configure():两半都显式传入 +rhosocial-activerecord dql list \ + --model app.models:User \ + --backend-class rhosocial.activerecord.backend.impl.sqlite.backend:SQLiteBackend \ + --config-class rhosocial.activerecord.backend.impl.sqlite.config:SQLiteConnectionConfig \ + --config database=app.db -o json + +# 命名连接 + 显式后端类 +rhosocial-activerecord dql list \ + --model app.models:User \ + --backend-class mypkg.backend:MyBackend \ + --connection app.connections:prod_db -o json +``` + +如果模型模块自己调用了 `configure()`,可以省略 `--backend-class` 和 +`--connection` / `--config-class`;此时 `--config` 覆盖模型已有配置。 + +## 异步模型 + +CLI 自动识别模型类型。`ActiveRecord` 用同步后端;`AsyncActiveRecord` 用异步后端, +并在 CLI 内部运行事件循环。没有 `--async` 参数,但 `--backend-class` 必须与模型匹配: +异步模型传 `AsyncStorageBackend` 子类,同步模型传 `StorageBackend` 子类。 + +## Python 模块形式 + +```bash +python -m rhosocial.activerecord.cli dql list --model app.models:User -o json +``` + +安装后的控制台脚本(`rhosocial-activerecord`)是同一个入口。后端专用命令 +(例如 `rhosocial-activerecord-sqlite`)是另一个工具,执行 SQL 并做数据库内省, +不认识模型类。 + +## 让 AI 使用这些命令 + +- 不要猜后端类或配置类的路径。从项目配置读取,或询问用户;路径错误会以退出码 4 + 报 `Cannot import ...`。 +- 模型模块已调用 `configure()` 时,只传 `--model`,让模型提供连接。 +- `--path` 可重复,是不安装源码树也能导入模型的正规方式。 +- 模型类必须是真正的 `ActiveRecord` / `AsyncActiveRecord` 子类;模块级函数或普通 + Pydantic 模型会被拒绝。 +- 控制台脚本不在 `PATH` 时用 `python -m rhosocial.activerecord.cli`。 diff --git a/docs/zh_CN/cli/dml.md b/docs/zh_CN/cli/dml.md new file mode 100644 index 00000000..4f8b8be2 --- /dev/null +++ b/docs/zh_CN/cli/dml.md @@ -0,0 +1,79 @@ +# DML:单条记录写入 + +`dml create`、`dml update`、`dml delete` 每次只操作一条记录,直接调用模型的 +`save()` / `delete()`,所以事件、时间戳、乐观锁、软删除和校验都保持框架语义。 + +## 创建 + +```bash +rhosocial-activerecord dml create --model app.models:User \ + --data '{"name": "Alice", "email": "alice@example.com", "age": 30}' -o json +``` + +`--data` 支持: + +| 形式 | 含义 | +|------|------| +| `'{"k": "v"}'` | 内联 JSON 对象 | +| `@user.json` | 从文件读取 | +| `@-` 或 `-` | 从 stdin 读取 | + +载荷必须是单个 JSON 对象。构造模型时执行一次校验,`save()` 里再执行一次业务规则校验。 +失败时不写库,退出码 1。详见[错误](errors.md#校验)。 + +## 更新 + +```bash +rhosocial-activerecord dml update --model app.models:User \ + --pk 42 --data '{"age": 31}' -o json +``` + +- `--pk` 是主键值;复合主键用 JSON 对象 + (`--pk '{"order_id": 1, "product_id": 101}'`,或 `--pk-json`)。 +- 主键字段不能被更新。需要改主键时删除并新建;命令会在发出 SQL 前拒绝。 +- 只修改传入的字段;命令会先读取记录。 + +## 删除 + +```bash +rhosocial-activerecord dml delete --model app.models:User --pk 42 --yes -o json +``` + +在 TTY 上删除会要求确认。stdin 或 stdout 不是终端时必须传 `--yes`,否则以退出码 5 结束。 +`--dry-run` 只报告计划、不写库: + +```bash +rhosocial-activerecord dml delete --model app.models:User --pk 42 --yes --dry-run +``` + +输出: + +```json +{"action": "delete", "pk": 42, "affected": 1, "dry_run": true} +``` + +软删除模型的动作是 `soft_delete`,并带上时间戳字段: + +```json +{"action": "soft_delete", "pk": 42, "affected": 1, "deleted_at_field": "deleted_at"} +``` + +## 只读模型 + +声明 `__read_only__ = True`(例如通过 `ReadOnlyMixin`)的模型会在连接前拒绝所有 DML, +退出码 6: + +```text +Error: ReadOnlyUser is read-only (__read_only__ = True); 'dml create' is not allowed. +``` + +只读模型的 DQL 仍然可用。 + +## 让 AI 使用这些命令 + +- `dml delete` 始终加 `--yes`,提示会阻塞代理。 +- 需要确认目标行和动作(`delete` 还是 `soft_delete`)时先跑 `--dry-run`。 +- 有并发写入时 `dml update` 不是幂等的:版本冲突会抛 `StaleRecordError`、退出码 1, + 先用 `dql get` 重读,再重试。 +- 用 `-o json` 并检查 `action`,不要假设一定是物理删除:软删除模型会保留行。 +- 主键不存在时退出码 3 且不写库;把 3 当作“记录不存在”,而不是基础设施故障。 diff --git a/docs/zh_CN/cli/dql.md b/docs/zh_CN/cli/dql.md new file mode 100644 index 00000000..f9944fb0 --- /dev/null +++ b/docs/zh_CN/cli/dql.md @@ -0,0 +1,158 @@ +# DQL:查询、分页与聚合 + +`dql` 直接调用 `ActiveQuery` 方法。CLI 没有第二套过滤语法:它把简单参数映射到 +`where()`、`order_by()`、`select()`、`limit()`、`offset()`。 + +## 命令 + +| 命令 | 用途 | +|------|------| +| `dql get` | 按主键取一条记录 | +| `dql list` | 过滤、排序、分页的列表 | +| `dql count` | 匹配记录数 | +| `dql exists` | 是否存在匹配 | +| `dql aggregate` | 标量或分组聚合 | +| `dql run` | 执行用户传入的 `ActiveQuery` | + +## 过滤 + +```bash +rhosocial-activerecord dql list --model app.models:User \ + --where "status=active" --where "age=30" \ + --order-by "created_at desc" --order-by "id asc" \ + --select id,name --limit 20 --offset 0 -o json +``` + +- `--where` **只支持等值**:`field=value`。可重复,多个条件按 AND 组合,值会转换成字段类型。 +- 未知字段在 SQL 之前被拒绝,错误消息会列出可用字段。 +- `--order-by` 接受 `field` 或 `field asc|desc`。 +- `--select` 限制查询列,并校验字段名。 +- 需要比较运算(`>=`、`LIKE`、`IN` 等)时,用 [`dql run`](#执行自定义查询)。 + +软删除模型额外提供两个可见性参数: + +```bash +rhosocial-activerecord dql list --model app.models:Task --include-deleted +rhosocial-activerecord dql list --model app.models:Task --only-deleted +``` + +它们分别调用 `query_with_deleted()` / `query_only_deleted()`,不能同时使用。 + +## 分页 + +会应用分页的命令:`dql list`、带 `--group-by` 的 `dql aggregate`、 +`dql run --mode list|aggregate`。 +不会应用分页的命令:`dql get`、`dql count`、`dql exists`、 +`dql run --mode one|count|exists`。 + +参数: + +| 参数 | 作用 | +|------|------| +| `--limit N` | 最多 `N` 行 | +| `--offset N` | 跳过 `N` 行 | +| `--page N --page-size M` | 等价于 `limit M offset (N-1)*M`;两者必须同时给出且为正数 | +| `--no-limit` | 关闭自动注入 | +| `--limit 0` | 同样表示不限制 | +| `--quiet` | 不打印自动分页提示 | + +### 自动分页注入 + +当可分页命令没有显式 limit、且查询本身也没有 limit 时,CLI 会注入 +`DEFAULT_LIST_LIMIT`(100),并在 stderr 说明: + +```text +Showing first 100 rows (default pagination); use --limit N or --no-limit to change. +``` + +分组聚合的文案不同: + +```text +Showing first 100 groups (default pagination); use --limit N or --no-limit to change. +``` + +提示只写 **stderr**,stdout 仍是纯数据。`--quiet` 关闭提示;`--no-limit` +用于明确要求全量查询(防止误把整表读出来)。 + +标量聚合从不分页:`dql aggregate --fn count` 执行 `COUNT(*)`,只返回一个值。 + +## 计数与存在性 + +```bash +rhosocial-activerecord dql count --model app.models:User --where "status=active" -o value +rhosocial-activerecord dql exists --model app.models:User --where "status=active" -o value +``` + +`-o value` 输出 `4` 或 `true` / `false`;`-o json` 输出 JSON 标量。 + +## 聚合 + +```bash +# 标量:一个值 +rhosocial-activerecord dql aggregate --model app.models:Order \ + --fn sum --field total -o value + +# 分组:每组一行 +rhosocial-activerecord dql aggregate --model app.models:Order \ + --fn count --field '*' --group-by status -o json +``` + +- `--fn` 取值:`count`、`sum`、`avg`、`min`、`max`。 +- `--field '*'` 只对 `count` 有效。 +- `--field` 必须是模型字段;未知字段在 SQL 之前被拒绝。 +- `--group-by` 可重复,每个名称都会校验。 +- `--distinct` 在聚合内部应用 `DISTINCT`。 +- 分组聚合返回字典行;标量聚合返回单个值。 + +分组结果为空时无法推断列名,可能需要显式 `--fields`,否则报错: + +```text +Error: The query returned no rows, so output fields cannot be inferred. Pass --fields explicitly. +``` + +## 执行自定义查询 + +`dql run` 执行 `module:callable` 或 `module:Class.method` 返回的 `ActiveQuery`(或子类): + +```python +# app/queries.py +from app.models import User + +def active_users(): + return User.query().where(User.c.age >= 30).order_by(User.c.age.desc()) +``` + +```bash +rhosocial-activerecord dql run --model app.models:User \ + --query app.queries:active_users -o json + +# 返回查询的模型 classmethod +rhosocial-activerecord dql run --model app.models:User \ + --query app.models:User.active_users -o json +``` + +- 工厂函数**无参数调用**。 +- 返回值必须是 `ActiveQuery` 或子类,包括返回 `self` 的业务包装和 `__query_class__` + 方法;其它类型会被拒绝,并在消息中说明期望类型。 +- `--mode` 决定执行方式: + +| mode | 等价调用 | +|------|----------| +| `list`(默认) | `all()` | +| `one` | `one()`;无记录时退出码 3 | +| `count` | `count()` | +| `exists` | `exists()` | +| `aggregate` | `aggregate()`,返回字典行 | + +- `list` 和 `aggregate` 会应用分页(含自动注入)。 +- `one`、`count`、`exists` 忽略分页参数。 + +## 让 AI 使用这些命令 + +- `--where` 只支持等值,不要尝试 `age>=30`;把比较写进查询工厂,再用 `dql run`。 +- 自动分页在 stdout 上不可见,只在 stderr 提示。建议显式 `--limit N`(或 `--no-limit`), + 解析 stderr 时加 `--quiet`。 +- 大批量 `dql list` 之前先用 `dql count` 估算规模。 +- 需要逐行交给其它工具时用 `-o jsonl`。 +- `dql run` 的工厂从 `--path` 目录导入;复杂谓词写在项目代码里,不要塞进字符串。 +- 分组聚合可能为空时传 `--fields`,避免列名推断错误。 diff --git a/docs/zh_CN/cli/errors.md b/docs/zh_CN/cli/errors.md new file mode 100644 index 00000000..a1245d80 --- /dev/null +++ b/docs/zh_CN/cli/errors.md @@ -0,0 +1,84 @@ +# 错误与校验 + +CLI 把模型层和后端异常翻译成命令级错误,不改变框架语义:模型仍在构造时校验, +`save()` 仍会重新执行业务规则校验。 + +## 消息与退出码映射 + +| 场景 | 消息开头 | 退出码 | +|---|---|---| +| 记录数据不合法 | `Invalid data for Model ...` | 1 | +| 约束冲突 | `Integrity constraint failed ...` | 1 | +| 记录不存在 | `Model with pk=... was not found` | 3 | +| 只读模型 | `... is read-only ...` | 6 | +| 需要确认 | `... Pass --yes ...` | 5 | +| 版本冲突 | `Record version mismatch ...` | 1 | +| 锁或死锁 | `Record is locked ...` / `Deadlock detected ...` | 1 | +| 连接失败 | `Database connection failed ...` | 1 | +| 其它数据库错误 | `Database error ...` | 1 | +| 后端或配置类缺失 | `Cannot import the ...` | 4 | +| 未知异常 | `Unexpected error (TypeName): ...` | 1 | + +完整退出码见[输出与管道](output.md#退出码)。 + +## 模型异常与 CLI 错误 + +查询无结果时模型层抛 `backend.errors.RecordNotFound`;CLI 层抛自己的 +`CliNotFoundError` 并映射为退出码 3。两者刻意保持独立: + +- `RecordNotFound` 描述查询结果,不带退出码; +- `CliNotFoundError` 描述命令结果。 + +命令处理层用 `raise ... from exc` 翻译,所以原始异常仍在异常链里,便于排查。 + +## 校验 + +校验发生在两个时点: + +1. 构造模型实例时(`dml create`、`bulk create`,以及 `dml update` 加载的数据); +2. `save()` 内部:`model_validate(model_dump())` 加上 `validate_record()` 业务规则。 + +失败不写库,退出码 1。`dml update` 的消息会带模型和主键: + +```text +Error: Invalid data for User pk=42: age: Input should be less than or equal to 100 +``` + +批量操作会指出条目(已知时也指出主键): + +```text +Error: Invalid data for User (item 2): name: String should have at least 2 characters +Error: Bulk update failed at item 2 (pk=2): age: Input should be <= 100 +``` + +批量命令在发出 SQL 前校验整批,失败时表数据保持不变。 + +## 取消操作 + +拒绝确认不算错误: + +- 退出码 0; +- stderr 输出 `Operation cancelled.`,不带 `Error:` 前缀; +- 不写库。 + +非交互环境不会弹出提示:破坏性命令缺少 `--yes` 时以退出码 5 结束,并提示补 `--yes`。 + +## 未知异常 + +已知异常按上表映射;其余只输出一行: + +```text +Unexpected error (ValueError): ... +``` + +不打印 traceback。需要完整堆栈时用 `--log-level DEBUG`,细节会进入日志流(stderr)。 + +## 让 AI 使用这些命令 + +- 按退出码分支:3 表示“不存在”,5 表示“需要确认”,6 表示“只读”, + 4 表示“导入路径错误”,1 是普通失败。 +- 阅读库代码时区分 `RecordNotFound`(模型层)与 `CliNotFoundError`(CLI 层): + 命令返回的是后者。 +- 校验失败意味着没有写入,不要发起补偿删除。 +- 单独收集 stderr:错误行以 `Error:` 开头(未知异常为 `Unexpected error (...)`),不是合法 JSON。 +- 只在排查时加 `--log-level DEBUG`,它会在 stderr 产生大量输出。 diff --git a/docs/zh_CN/cli/output.md b/docs/zh_CN/cli/output.md new file mode 100644 index 00000000..66271f3a --- /dev/null +++ b/docs/zh_CN/cli/output.md @@ -0,0 +1,79 @@ +# 输出、管道与退出码 + +机器格式只把数据写到 stdout。提示、日志和错误始终写 stderr,因此载荷可以直接管道或捕获, +无需过滤。 + +## 输出格式 + +`-o table|json|jsonl|csv|tsv|value`: + +| 格式 | 用途 | +|------|------| +| `json` | 列表为数组,单条为对象,计数为标量 | +| `jsonl` | 每行一个 JSON 值,适合管道和流式处理 | +| `csv` / `tsv` | 表头 + 数据行;`--no-header` 省略表头 | +| `value` | 裸标量,用于 `count`、`exists` 或标量聚合 | +| `table` | Rich 表格;没有 Rich 时回退 JSON | + +stdout 不是 TTY 且未指定 `-o` 时默认 `json`;TTY 上默认 `table`。 + +`--fields id,name` 限制输出列。列表结果会按模型字段(或字典键)校验名称, +未知名称会列出可用字段;标量结果会忽略 `--fields`。 + +`-o value` 只对标量结果有效。用于列表或单条记录时以退出码 1 报 +`Error: -o value is only valid for scalar results`。 + +## 写操作结果 + +DML 删除和批量更新/删除返回摘要对象: + +| 命令 | JSON | CSV/TSV 列 | +|------|------|------------| +| `dml delete` | `{"action", "pk", "affected"}`(可能含 `deleted_at_field`、`dry_run`) | `action,pk,affected`(含附加列) | +| `dml bulk update` | `{"action", "affected"}` | `action,affected` | +| `dml bulk delete` | `{"action", "affected"}` | `action,affected` | + +这些命令的 `-o value` 只输出受影响行数。`dml create` 与 `dml bulk create` 返回新建记录, +因此 `json` / `jsonl` / `csv` / `tsv` 描述的是记录行,而不是摘要对象。 + +## 管道 + +```bash +cat user.json | rhosocial-activerecord dml create --model app.models:User --data @- + +rhosocial-activerecord dql list --model app.models:User -o jsonl \ + | jq -c 'select(.status == "active")' + +rhosocial-activerecord dql count --model app.models:User -o value +``` + +- `--data @-` 与 `--data -` 从 stdin 读取。 +- 下游提前关闭(例如 `| head`)时不打印 traceback,退出码为 141。 +- 长管道优先用 JSONL:每行都是完整值。 + +## 日志与提示 + +- 框架默认日志级别为 `WARNING`,日志写 stderr。 +- `--log-level DEBUG` 输出后端和模型细节,便于排查连接或模型加载问题。 +- `--quiet` 关闭自动分页提示等非错误 stderr 输出,不会屏蔽错误。 + +## 退出码 + +| 退出码 | 含义 | +|--------|------| +| 0 | 成功,包括用户取消确认 | +| 1 | 模型、连接、校验或数据库错误 | +| 2 | 命令行用法错误(argparse) | +| 3 | 记录不存在 | +| 4 | 后端或连接配置类无法导入 | +| 5 | 非交互环境下的破坏性命令需要 `--yes` | +| 6 | 只读模型拒绝 DML | +| 141 | 下游管道关闭 | + +## 让 AI 使用这些命令 + +- 默认用 `-o json`(流式处理用 `jsonl`),不要解析 `table`。 +- stdout 只有载荷;stderr 上的文本都当作诊断信息,不要当数据。 +- 单独处理 141:表示消费方停止读取,不是命令失败。 +- 模型字段很多时用 `--fields` 让载荷更小、更稳定。 +- 需要同时收集 stderr 日志时,用 `--quiet` 配合 `-o json`;分页提示属于信息性输出,可以丢弃。 diff --git a/docs/zh_CN/cli/set-cte.md b/docs/zh_CN/cli/set-cte.md new file mode 100644 index 00000000..8e63cdd0 --- /dev/null +++ b/docs/zh_CN/cli/set-cte.md @@ -0,0 +1,80 @@ +# 集合操作与 CTE + +`set` 与 `cte` 执行已有的 `SetOperationQuery` / `CTEQuery`,有两种用法: + +1. **简单两侧**(`set union`、`union-all`、`intersect`、`except`)用 CLI 参数构造两个模型查询; +2. **组合查询**(`set run`、`cte run`)执行返回查询对象的工厂函数,覆盖 CTE、多臂组合和自定义谓词。 + +## 简单集合操作 + +```bash +rhosocial-activerecord set union \ + --left-model app.models:User --right-model app.models:Admin \ + --left-where "status=active" --right-where "status=active" -o json +``` + +- `--left-model` / `--right-model` 必填,且必须同为同步或同为异步。 +- `--left-where` / `--right-where` 与 `dql list` 一样只支持等值 `field=value`; + `--order-by`、`--select` 同时作用于两侧。 +- `--limit`、`--offset`、`--page/--page-size` 会分别应用到两侧。**不会自动注入默认分页**。 +- `union-all` 映射到 `UNION ALL`;`intersect` / `except` 需要后端支持。 + +行以字典返回,因为集合操作不绑定单一模型类;输出格式规则与普通查询行一致。 + +## 组合查询 + +```python +# app/queries.py +from app.models import Admin, User + +def active_users_union(): + return User.query().where(User.c.status == "active").union( + Admin.query().where(Admin.c.status == "active") + ) +``` + +```bash +rhosocial-activerecord set run --model app.models:User \ + --query app.queries:active_users_union -o json +``` + +- `set run` / `cte run` 的 `--model` 可选:工厂需要模型被配置时传,工厂自己配置连接时省略。 +- 工厂函数无参数调用,必须返回 `SetOperationQuery`(`set run`)或 `CTEQuery`(`cte run`); + 传入普通 `ActiveQuery` 会被拒绝,并在消息中说明期望类型。 +- 这些命令调用 `aggregate()`。`SetOperationQuery` 与 `CTEQuery` 的行是字典, + 不支持 `all()` 或 `limit()`。 +- `set run` / `cte run` 不额外加分页;需要限制行数时写进查询本身。 + +## CTE + +```python +# app/queries.py +from rhosocial.activerecord.query import CTEQuery +from app.models import User + +def older_users(): + cte = CTEQuery(User.backend()) + cte.with_cte("older_users", User.query().where(User.c.age >= 30)) + return cte.from_cte("older_users") +``` + +```bash +rhosocial-activerecord cte run --model app.models:User \ + --query app.queries:older_users -o json +``` + +递归 CTE 和方言差异都通过框架 API 在 Python 里构造,CLI 只执行结果对象。 + +## 后端支持 + +`intersect` / `except` 和递归 CTE 依赖数据库能力。方言不支持时后端抛出错误,CLI 以退出码 1 +报告。CLI 没有能力开关:应在应用层用框架的 dialect 协议判断。 + +## 让 AI 使用这些命令 + +- 超出两个简单臂的组合,优先用 `set run` / `cte run`:工厂就是普通 Python,避免引号问题。 +- 工厂内部调用 `Model.backend()` 时传 `--model`;只有工厂自己配置连接时才省略。 +- 集合操作行是字典、没有模型元数据,`--fields` 必须是行里真实存在的键。 +- 空结果时传 `--fields`,否则无法推断输出列。 +- 查询类型不符(例如把 `ActiveQuery` 传给 `set run`)会以退出码 1 结束并说明期望类型; + 修正工厂,不要重复执行同一命令。 diff --git a/pyproject.toml b/pyproject.toml index 6fde07c4..f26942dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,8 +87,13 @@ dependencies = [ # requiring `python -m rhosocial.activerecord.backend.impl.sqlite`. [project.scripts] rhosocial-activerecord-sqlite = "rhosocial.activerecord.backend.impl.sqlite.__main__:main" +rhosocial-activerecord = "rhosocial.activerecord.cli:main" [project.optional-dependencies] +# Optional Rich rendering for `crud ... -o table`. Without it, table output +# falls back to JSON, exactly like the backend CLIs. +cli = ["rich>=13.0"] + # Async support for SQLite backend # These are required only when using AsyncSQLiteBackend async = [ diff --git a/src/rhosocial/activerecord/backend/errors.py b/src/rhosocial/activerecord/backend/errors.py index cf125c8a..5687694a 100644 --- a/src/rhosocial/activerecord/backend/errors.py +++ b/src/rhosocial/activerecord/backend/errors.py @@ -72,10 +72,15 @@ class IntegrityError(DatabaseError): class StaleRecordError(DatabaseError): - """The record was changed by another process before this update. + """The record version no longer matches the stored row. Raised by optimistic locking when the UPDATE affects no rows because the stored version no longer matches the value the record was loaded with. + Zero rows has two possible causes: another process updated the row, or the + row was deleted. The message names both possibilities instead of asserting + only the first, because distinguishing them would need another query and + would still race with concurrent writers. + It is a :class:`DatabaseError` subclass, so existing ``except DatabaseError`` handlers keep catching it, while callers can opt into :class:`StaleRecordError` to tell a normal concurrency conflict apart @@ -96,7 +101,10 @@ def __init__( primary_key: Any, expected_version: int, operation: str = "update", - message: str = "Record was updated by another process", + message: str = ( + "Record version does not match; the row may have been updated or " + "deleted by another process" + ), ) -> None: self.model = model self.primary_key = primary_key diff --git a/src/rhosocial/activerecord/base/base.py b/src/rhosocial/activerecord/base/base.py index f3f92ca1..d864771e 100644 --- a/src/rhosocial/activerecord/base/base.py +++ b/src/rhosocial/activerecord/base/base.py @@ -645,6 +645,13 @@ def _prepare_find_or_create_inputs( ``lookup`` is the natural key and must name at least one model field. ``defaults`` fill the remaining fields on creation only, so ``lookup`` wins when both name the same field. + + Raises: + ValueError: The lookup is empty; a lookup value is ``None``; or + ``lookup`` / ``defaults`` name a database-generated primary + key field. + TypeError: A key is not a model field, a value is an expression, or + ``defaults`` is neither a mapping nor ``None``. """ if not lookup: raise ValueError( @@ -716,16 +723,21 @@ def _open_savepoint_if_usable(cls) -> Optional[str]: if backend is None: return None manager = backend.transaction_manager + if not callable(getattr(manager, "supports_savepoint", None)): + return None try: - if not callable(getattr(manager, "supports_savepoint", None)): - return None if not manager.supports_savepoint(): return None if not getattr(manager, "is_active", False): return None return manager.savepoint() - except Exception as exc: # capability probing must not break the write path - cls.log(logging.WARNING, f"Could not open savepoint for find_one_or_create: {exc}") + except Exception: + cls.log( + logging.ERROR, + "Could not probe savepoint support for find_one_or_create; " + "continuing without one", + exc_info=True, + ) return None @classmethod @@ -771,6 +783,11 @@ def find_one_or_create( supports savepoints, a savepoint is used to recover from a failed insert; it is a recovery point, not a transaction boundary. + The lookup is matched with ``query.one()``. The caller must ensure the + lookup columns carry a unique constraint: without one several rows can + match, and which row is returned is undefined. The method neither + inspects nor validates database constraints. + Args: defaults: Values used only when the row is created. When a key also appears in ``lookup``, the lookup value wins because it defines @@ -806,8 +823,8 @@ def find_one_or_create( cls._rollback_to_savepoint(savepoint) try: winner = cls._find_one_by_lookup(lookup_values) - except Exception: - winner = None + except Exception as read_failure: + raise exc from read_failure if winner is not None: return winner, False raise exc @@ -1591,6 +1608,13 @@ def _prepare_find_or_create_inputs( ``lookup`` is the natural key and must name at least one model field. ``defaults`` fill the remaining fields on creation only, so ``lookup`` wins when both name the same field. + + Raises: + ValueError: The lookup is empty; a lookup value is ``None``; or + ``lookup`` / ``defaults`` name a database-generated primary + key field. + TypeError: A key is not a model field, a value is an expression, or + ``defaults`` is neither a mapping nor ``None``. """ if not lookup: raise ValueError( @@ -1662,16 +1686,21 @@ async def _open_savepoint_if_usable(cls) -> Optional[str]: if backend is None: return None manager = backend.transaction_manager + if not callable(getattr(manager, "supports_savepoint", None)): + return None try: - if not callable(getattr(manager, "supports_savepoint", None)): - return None if not await manager.supports_savepoint(): return None if not getattr(manager, "is_active", False): return None return await manager.savepoint() - except Exception as exc: # capability probing must not break the write path - cls.log(logging.WARNING, f"Could not open savepoint for find_one_or_create: {exc}") + except Exception: + cls.log( + logging.ERROR, + "Could not probe savepoint support for find_one_or_create; " + "continuing without one", + exc_info=True, + ) return None @classmethod @@ -1717,6 +1746,11 @@ async def find_one_or_create( supports savepoints, a savepoint is used to recover from a failed insert; it is a recovery point, not a transaction boundary. + The lookup is matched with ``query.one()``. The caller must ensure the + lookup columns carry a unique constraint: without one several rows can + match, and which row is returned is undefined. The method neither + inspects nor validates database constraints. + Args: defaults: Values used only when the row is created. When a key also appears in ``lookup``, the lookup value wins because it defines @@ -1752,8 +1786,8 @@ async def find_one_or_create( await cls._rollback_to_savepoint(savepoint) try: winner = await cls._find_one_by_lookup(lookup_values) - except Exception: - winner = None + except Exception as read_failure: + raise exc from read_failure if winner is not None: return winner, False raise exc diff --git a/src/rhosocial/activerecord/cli/__init__.py b/src/rhosocial/activerecord/cli/__init__.py new file mode 100644 index 00000000..cc756a89 --- /dev/null +++ b/src/rhosocial/activerecord/cli/__init__.py @@ -0,0 +1,413 @@ +# src/rhosocial/activerecord/cli/__init__.py +"""Command-line interface for rhosocial-activerecord. + +The command tree is split by responsibility: + +* ``dml``: single-record and bulk ActiveRecord writes. +* ``dql``: ActiveQuery reads and user-defined query execution. +* ``set``: SetOperationQuery combinations. +* ``cte``: CTEQuery execution. + +Example: + rhosocial-activerecord dql list --model app.models:User \\ + --backend-class mypkg.backend:MyBackend \\ + --config-class mypkg.config:MyConnectionConfig \\ + --config database=app.db -o json +""" + +import argparse +import logging +import sys +from typing import List, Optional + +from ..logging import LoggingConfig, configure_logging +from .args import ( + aggregate_parent, + confirm_parent, + connection_parent, + filter_parent, + mode_parent, + model_parent, + optional_model_parent, + output_parent, + pagination_parent, + pk_parent, + query_parent, + write_parent, +) +from .backends import resolve_connection +from .commands import cte, dml, dql, setop +from .error_mapping import operation_label, translate_error +from .errors import ( + CLIError, + EXIT_BROKEN_PIPE, + EXIT_ERROR, + report_error, + report_unexpected, +) +from .interaction import ensure_writable +from .model_loader import load_model, prepare_sys_path +from .runtime import RuntimeContext, run_coroutine + + +COMMAND_NAMES = { + "dml": ("create", "update", "delete", "bulk"), + "dql": ("get", "list", "count", "exists", "aggregate", "run"), + "set": ("union", "union-all", "intersect", "except", "run"), + "cte": ("run",), +} + + +def _add_set_arm_arguments(parser) -> None: + parser.add_argument("--left-model", required=True, metavar="MODULE:CLASS") + parser.add_argument("--right-model", required=True, metavar="MODULE:CLASS") + parser.add_argument("--left-where", action="append", default=None, metavar="FIELD=VALUE") + parser.add_argument("--right-where", action="append", default=None, metavar="FIELD=VALUE") + + +def _add_dry_run(parser) -> None: + parser.add_argument("--dry-run", action="store_true", help="Report without writing") + + +def build_parser() -> argparse.ArgumentParser: + """Build the full CLI parser.""" + parser = argparse.ArgumentParser( + prog="rhosocial-activerecord", + description="Operate on ActiveRecord models from the command line.", + ) + groups = parser.add_subparsers(dest="section", help="Available command groups") + + # dml + dml_parser = groups.add_parser("dml", help="Model data operations") + dml_sub = dml_parser.add_subparsers(dest="command", help="Available DML commands") + + dml_sub.add_parser( + "create", + parents=[connection_parent, model_parent, output_parent, write_parent], + help="Create one record", + ) + dml_sub.add_parser( + "update", + parents=[connection_parent, model_parent, output_parent, write_parent, pk_parent], + help="Update one record", + ) + delete_parser = dml_sub.add_parser( + "delete", + parents=[connection_parent, model_parent, output_parent, pk_parent, confirm_parent], + help="Delete one record", + ) + _add_dry_run(delete_parser) + + bulk_parser = dml_sub.add_parser("bulk", help="Bulk model data operations") + bulk_sub = bulk_parser.add_subparsers(dest="bulk_command", help="Available bulk commands") + bulk_sub.add_parser( + "create", + parents=[connection_parent, model_parent, output_parent, write_parent], + help="Create records from JSON/JSONL", + ) + bulk_sub.add_parser( + "update", + parents=[connection_parent, model_parent, output_parent, write_parent], + help="Update records from JSON/JSONL", + ) + bulk_sub.add_parser( + "delete", + parents=[connection_parent, model_parent, output_parent, write_parent, confirm_parent], + help="Delete records from JSON/JSONL primary keys", + ) + + # dql + dql_parser = groups.add_parser("dql", help="Model query operations") + dql_sub = dql_parser.add_subparsers(dest="command", help="Available DQL commands") + dql_sub.add_parser( + "get", + parents=[connection_parent, model_parent, output_parent, pk_parent], + help="Fetch one record by primary key", + ) + dql_sub.add_parser( + "list", + parents=[connection_parent, model_parent, output_parent, filter_parent, pagination_parent], + help="List records", + ) + dql_sub.add_parser( + "count", + parents=[connection_parent, model_parent, output_parent, filter_parent], + help="Count records", + ) + dql_sub.add_parser( + "exists", + parents=[connection_parent, model_parent, output_parent, filter_parent], + help="Check whether records exist", + ) + dql_sub.add_parser( + "aggregate", + parents=[ + connection_parent, + model_parent, + output_parent, + filter_parent, + pagination_parent, + aggregate_parent, + ], + help="Run an aggregate query", + ) + dql_sub.add_parser( + "run", + parents=[connection_parent, model_parent, output_parent, query_parent, mode_parent, pagination_parent], + help="Execute a user-supplied ActiveQuery", + ) + + # set + set_parser = groups.add_parser("set", help="Set operation queries") + set_sub = set_parser.add_subparsers(dest="set_operation", help="Available set operations") + for name, help_text in ( + ("union", "UNION of two model queries"), + ("union-all", "UNION ALL of two model queries"), + ("intersect", "INTERSECT of two model queries"), + ("except", "EXCEPT of two model queries"), + ): + arm_parser = set_sub.add_parser( + name, + parents=[connection_parent, output_parent, filter_parent, pagination_parent], + help=help_text, + ) + _add_set_arm_arguments(arm_parser) + set_sub.add_parser( + "run", + parents=[connection_parent, optional_model_parent, output_parent, query_parent], + help="Execute a user-supplied SetOperationQuery", + ) + + # cte + cte_parser = groups.add_parser("cte", help="CTE queries") + cte_sub = cte_parser.add_subparsers(dest="command", help="Available CTE commands") + cte_sub.add_parser( + "run", + parents=[connection_parent, optional_model_parent, output_parent, query_parent], + help="Execute a user-supplied CTEQuery", + ) + + return parser + + +def _configure_cli_logging(level: int) -> None: + """Set the framework log level, including loggers created at import time.""" + configure_logging(level=level) + prefix = "rhosocial.activerecord" + for name, logger in logging.Logger.manager.loggerDict.items(): + if name == prefix or name.startswith(prefix + "."): + if isinstance(logger, logging.Logger): + logger.setLevel(level) + + +def _prepare_model_logging(model, backend_class, level: int) -> LoggingConfig: + """Give the model and backend a stderr/level-aware logging config.""" + config = LoggingConfig(default_level=level) + model.__logging_config__ = config + backend_class.__logging_config__ = config + return config + + +def _configure_sync(model, args, runner): + config, backend_class = resolve_connection(model, False, args) + logging_config = _prepare_model_logging(model, backend_class, args.log_level) + model.configure(config, backend_class, logging_config=logging_config) + try: + return runner() + finally: + backend = model.backend() + if backend is not None: + try: + backend.disconnect() + except Exception: + pass + + +def _configure_async(model, args, runner): + config, backend_class = resolve_connection(model, True, args) + logging_config = _prepare_model_logging(model, backend_class, args.log_level) + + async def execute(): + await model.configure(config, backend_class, logging_config=logging_config) + try: + return await runner() + finally: + backend = model.backend() + if backend is not None: + try: + await backend.disconnect() + except Exception: + pass + + return run_coroutine(execute()) + + +def _run_dml(model_ref, args, operation): + ensure_writable(model_ref.model, f"dml {operation.replace('_', ' ')}") + ctx = RuntimeContext(args, model_ref.model, model_ref.is_async) + if model_ref.is_async: + handler = dml.ASYNC_HANDLERS[operation] + return _configure_async(model_ref.model, args, lambda: handler(ctx)) + handler = dml.SYNC_HANDLERS[operation] + return _configure_sync(model_ref.model, args, lambda: handler(ctx)) + + +def _run_dql(model_ref, args): + ctx = RuntimeContext(args, model_ref.model, model_ref.is_async) + if model_ref.is_async: + handler = dql.ASYNC_HANDLERS[args.command] + return _configure_async(model_ref.model, args, lambda: handler(ctx)) + handler = dql.SYNC_HANDLERS[args.command] + return _configure_sync(model_ref.model, args, lambda: handler(ctx)) + + +def _configure_pair(left_model, right_model, args, runner): + config, backend_class = resolve_connection(left_model, False, args) + left_config = _prepare_model_logging(left_model, backend_class, args.log_level) + right_config = _prepare_model_logging(right_model, backend_class, args.log_level) + left_model.configure(config, backend_class, logging_config=left_config) + right_model.configure(config, backend_class, logging_config=right_config) + try: + return runner(left_model, right_model) + finally: + for model in (left_model, right_model): + backend = model.backend() + if backend is not None: + try: + backend.disconnect() + except Exception: + pass + + +async def _configure_pair_async(left_model, right_model, args, runner): + config, backend_class = resolve_connection(left_model, True, args) + left_config = _prepare_model_logging(left_model, backend_class, args.log_level) + right_config = _prepare_model_logging(right_model, backend_class, args.log_level) + await left_model.configure(config, backend_class, logging_config=left_config) + await right_model.configure(config, backend_class, logging_config=right_config) + try: + return await runner(left_model, right_model) + finally: + for model in (left_model, right_model): + backend = model.backend() + if backend is not None: + try: + await backend.disconnect() + except Exception: + pass + + +def _run_set_simple(args): + left_ref = load_model(args.left_model) + right_ref = load_model(args.right_model) + if left_ref.is_async != right_ref.is_async: + raise CLIError("Set operands must both be synchronous or both be asynchronous") + if not hasattr(args, "model"): + args.model = None + ctx = RuntimeContext(args, left_ref.model, left_ref.is_async) + if left_ref.is_async: + return run_coroutine( + _configure_pair_async( + left_ref.model, + right_ref.model, + args, + lambda left, right: setop.execute_async(ctx, left, right), + ) + ) + return _configure_pair( + left_ref.model, + right_ref.model, + args, + lambda left, right: setop.execute_sync(ctx, left, right), + ) + + +def _run_query_group(args, module): + if getattr(args, "model", None): + model_ref = load_model(args.model) + ctx = RuntimeContext(args, model_ref.model, model_ref.is_async) + if model_ref.is_async: + return _configure_async( + model_ref.model, args, lambda: module.execute_async(ctx) + ) + return _configure_sync( + model_ref.model, args, lambda: module.execute_sync(ctx) + ) + ctx = RuntimeContext(args, None, False) + return module.execute_auto(ctx) + + +def main(argv: Optional[List[str]] = None) -> int: + """Run the CLI and return its exit code.""" + parser = build_parser() + args = parser.parse_args(argv) + + if args.section is None: + print("Error: Please specify a command group: dml, dql, set, or cte", file=sys.stderr) + print("Use --help for more information.", file=sys.stderr) + return EXIT_ERROR + + if args.section == "dml" and args.command == "bulk": + if args.bulk_command is None: + print("Error: Please specify a bulk command: create, update, or delete", file=sys.stderr) + return EXIT_ERROR + operation = "bulk_" + args.bulk_command + elif args.section == "dml": + if args.command is None: + print("Error: Please specify a DML command: create, update, delete, or bulk", file=sys.stderr) + return EXIT_ERROR + operation = args.command + elif args.section == "dql" and args.command is None: + print("Error: Please specify a DQL command", file=sys.stderr) + return EXIT_ERROR + elif args.section == "set" and args.set_operation is None: + print("Error: Please specify a set operation", file=sys.stderr) + return EXIT_ERROR + elif args.section == "cte" and args.command is None: + print("Error: Please specify the cte run command", file=sys.stderr) + return EXIT_ERROR + else: + operation = None + + _configure_cli_logging(args.log_level) + prepare_sys_path(getattr(args, "path", None)) + + try: + if args.section == "dml": + if operation is None: + print("Error: Please specify a DML command", file=sys.stderr) + return EXIT_ERROR + model_ref = load_model(args.model) + return _run_dml(model_ref, args, operation) + if args.section == "dql": + model_ref = load_model(args.model) + return _run_dql(model_ref, args) + if args.section == "set": + if args.set_operation is None: + print("Error: Please specify a set operation", file=sys.stderr) + return EXIT_ERROR + if args.set_operation == "run": + return _run_query_group(args, setop) + return _run_set_simple(args) + if args.section == "cte": + if args.command != "run": + print("Error: Please specify the cte run command", file=sys.stderr) + return EXIT_ERROR + return _run_query_group(args, cte) + print("Error: Unknown command group", file=sys.stderr) + return EXIT_ERROR + except CLIError as exc: + report_error(exc) + return exc.exit_code + except BrokenPipeError: + return EXIT_BROKEN_PIPE + except Exception as exc: # Keep stdout clean for machine-readable formats. + translated = translate_error(exc, operation=operation_label(args)) + if translated is not None: + report_error(translated) + return translated.exit_code + report_unexpected(exc) + return EXIT_ERROR + + +__all__ = ["main", "build_parser", "COMMAND_NAMES"] diff --git a/src/rhosocial/activerecord/cli/__main__.py b/src/rhosocial/activerecord/cli/__main__.py new file mode 100644 index 00000000..c22011b9 --- /dev/null +++ b/src/rhosocial/activerecord/cli/__main__.py @@ -0,0 +1,7 @@ +# src/rhosocial/activerecord/cli/__main__.py +"""Allow ``python -m rhosocial.activerecord.cli``.""" + +from . import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/rhosocial/activerecord/cli/args.py b/src/rhosocial/activerecord/cli/args.py new file mode 100644 index 00000000..62120711 --- /dev/null +++ b/src/rhosocial/activerecord/cli/args.py @@ -0,0 +1,294 @@ +# src/rhosocial/activerecord/cli/args.py +"""Shared argparse parents for the ActiveRecord model CLI.""" + +import argparse +import logging + +#: Output formats offered by the CLI. +OUTPUT_FORMATS = ("table", "json", "jsonl", "csv", "tsv", "value") + +#: Default number of rows returned by row-producing commands. +DEFAULT_LIST_LIMIT = 100 + + +_LOG_LEVELS = { + "CRITICAL": logging.CRITICAL, + "ERROR": logging.ERROR, + "WARNING": logging.WARNING, + "INFO": logging.INFO, + "DEBUG": logging.DEBUG, +} + + +def log_level(value): + """argparse type: accept a logging level name.""" + text = str(value).upper() + if text in _LOG_LEVELS: + return _LOG_LEVELS[text] + raise argparse.ArgumentTypeError( + f"unknown log level {value!r}; choose one of {', '.join(_LOG_LEVELS)}" + ) + + +def _add_connection_arguments(parser): + parser.add_argument( + "--path", + action="append", + default=None, + metavar="DIR", + help="Extra directory to add to sys.path; may be repeated", + ) + parser.add_argument( + "--backend-class", + default=None, + metavar="MODULE:CLASS", + help="Fully qualified backend class to use, for example " + "mypkg.backend:MyBackend. For an asynchronous model, name the " + "asynchronous class.", + ) + parser.add_argument( + "--connection", + default=None, + metavar="MODULE:CALLABLE", + help="Named connection callable returning a ConnectionConfig", + ) + parser.add_argument( + "--config-class", + default=None, + metavar="MODULE:CLASS", + help="Fully qualified connection config class, for example " + "mypkg.config:MyConnectionConfig", + ) + parser.add_argument( + "--config", + action="append", + default=None, + metavar="KEY=VALUE", + help="Set a connection config field; may be repeated. Unknown keys and " + "options.KEY entries go into config.options", + ) + parser.add_argument( + "--log-level", + type=log_level, + default=logging.WARNING, + help="Framework log level (default: WARNING); logs go to stderr", + ) + + +def _add_output_arguments(parser): + parser.add_argument( + "-o", + "--output", + default=None, + choices=OUTPUT_FORMATS, + help="Output format; defaults to table on a TTY and json otherwise", + ) + parser.add_argument( + "--fields", + default=None, + metavar="A,B", + help="Comma-separated field names to include in the output", + ) + parser.add_argument( + "--rich-ascii", + action="store_true", + help="Use ASCII borders for table output", + ) + parser.add_argument( + "--no-header", + action="store_true", + help="Omit the header row in csv/tsv output", + ) + parser.add_argument( + "--quiet", + action="store_true", + help="Suppress informational stderr messages", + ) + + +def _add_filter_arguments(parser): + parser.add_argument( + "--where", + action="append", + default=None, + metavar="FIELD=VALUE", + help="Equality filter; may be repeated", + ) + parser.add_argument( + "--order-by", + action="append", + default=None, + metavar="FIELD [ASC|DESC]", + help="Sort clause; may be repeated", + ) + parser.add_argument( + "--select", + default=None, + metavar="A,B", + help="Comma-separated columns to select", + ) + parser.add_argument( + "--include-deleted", + action="store_true", + help="Include soft-deleted rows when the model supports it", + ) + parser.add_argument( + "--only-deleted", + action="store_true", + help="Query only soft-deleted rows when the model supports it", + ) + + +def _add_pagination_arguments(parser): + parser.add_argument("--limit", type=int, default=None, help="Maximum rows") + parser.add_argument("--offset", type=int, default=None, help="Rows to skip") + parser.add_argument( + "--no-limit", + action="store_true", + help="Do not inject the default row limit", + ) + parser.add_argument("--page", type=int, default=None, help="Page number (1-based)") + parser.add_argument("--page-size", type=int, default=None, help="Rows per page") + + +def _add_write_arguments(parser): + parser.add_argument( + "--data", + required=True, + metavar="JSON|@FILE|@-|-", + help="JSON object/array, a file, or stdin", + ) + parser.add_argument( + "--input-format", + choices=("json", "jsonl"), + default=None, + help="Force the input format instead of auto-detecting it", + ) + parser.add_argument( + "--batch-size", + type=int, + default=None, + help="Split bulk create/update into batches of this size", + ) + parser.add_argument( + "--update-fields", + default=None, + metavar="A,B", + help="Comma-separated fields applied by bulk update", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Report the planned operation without writing", + ) + + +def _add_confirm_arguments(parser): + parser.add_argument( + "--yes", + action="store_true", + help="Confirm a destructive command without prompting", + ) + + +def _add_pk_arguments(parser): + parser.add_argument("--pk", default=None, help="Primary key value or JSON object") + parser.add_argument( + "--pk-json", + default=None, + help="Primary key JSON object (alias for --pk with a JSON object)", + ) + + +#: Parent parser carrying connection options, without ``--model``. +connection_parent = argparse.ArgumentParser(add_help=False) +_add_connection_arguments(connection_parent) + +#: Parent parser carrying the required model reference. +model_parent = argparse.ArgumentParser(add_help=False) +model_parent.add_argument( + "--model", + required=True, + metavar="MODULE:CLASS", + help="ActiveRecord model to operate on, for example app.models:User", +) + +#: Parent parser carrying an optional model reference (set/cte run). +optional_model_parent = argparse.ArgumentParser(add_help=False) +optional_model_parent.add_argument( + "--model", + default=None, + metavar="MODULE:CLASS", + help="Optional ActiveRecord model used to configure the connection", +) + +#: Parent parser for presentation options. +output_parent = argparse.ArgumentParser(add_help=False) +_add_output_arguments(output_parent) + +#: Parent parser for simple filters. +filter_parent = argparse.ArgumentParser(add_help=False) +_add_filter_arguments(filter_parent) + +#: Parent parser for pagination. +pagination_parent = argparse.ArgumentParser(add_help=False) +_add_pagination_arguments(pagination_parent) + +#: Parent parser for write data. +write_parent = argparse.ArgumentParser(add_help=False) +_add_write_arguments(write_parent) + +#: Parent parser for destructive-command confirmation. +confirm_parent = argparse.ArgumentParser(add_help=False) +_add_confirm_arguments(confirm_parent) + +#: Parent parser for primary-key arguments. +pk_parent = argparse.ArgumentParser(add_help=False) +_add_pk_arguments(pk_parent) + +#: Parent parser for a user-supplied query factory. +query_parent = argparse.ArgumentParser(add_help=False) +query_parent.add_argument( + "--query", + required=True, + metavar="MODULE:CALLABLE", + help="Callable returning an existing Query object", +) + +#: Parent parser for dql run execution mode. +mode_parent = argparse.ArgumentParser(add_help=False) +mode_parent.add_argument( + "--mode", + default="list", + choices=("list", "one", "count", "exists", "aggregate"), + help="How to execute the user-supplied query", +) + +#: Parent parser for aggregate options. +aggregate_parent = argparse.ArgumentParser(add_help=False) +aggregate_parent.add_argument( + "--fn", + default="count", + choices=("count", "sum", "avg", "min", "max"), + help="Aggregate function", +) +aggregate_parent.add_argument( + "--field", + default="*", + help="Column to aggregate; count accepts *", +) +aggregate_parent.add_argument( + "--group-by", + action="append", + default=None, + metavar="COLUMN", + help="GROUP BY column; may be repeated", +) +aggregate_parent.add_argument( + "--distinct", + action="store_true", + help="Use DISTINCT in the aggregate function", +) + +#: Backwards-compatible alias used by older tests/imports. +fields_parent = output_parent diff --git a/src/rhosocial/activerecord/cli/backends.py b/src/rhosocial/activerecord/cli/backends.py new file mode 100644 index 00000000..afb03ea4 --- /dev/null +++ b/src/rhosocial/activerecord/cli/backends.py @@ -0,0 +1,402 @@ +# src/rhosocial/activerecord/cli/backends.py +"""Backend and connection resolution for the model CLI. + +The CLI is part of the core package and therefore knows nothing about the +packages that happen to ship a backend. A caller names the backend class and +the connection config class by their fully qualified Python names, and passes +connection configuration values as ``KEY=VALUE`` pairs. +""" + +import dataclasses +import importlib +import inspect +import json +import types +import typing +from typing import Any, Dict, List, Optional, Tuple + +from ..backend.base import AsyncStorageBackend, StorageBackend +from ..backend.config import ConnectionConfig +from ..backend.named_connection import NamedConnectionResolver +from .errors import CLIError, BackendUnavailableError + + +#: Annotation wrapper used by ``typing.Annotated``; absent on Python 3.8. +_ANNOTATED = getattr(typing, "Annotated", None) + +#: Union origins understood by the config value coercion helper. +_UNION_ORIGINS = {typing.Union} +if getattr(types, "UnionType", None) is not None: # Python 3.10+ + _UNION_ORIGINS.add(types.UnionType) + + +def _load_attribute(module_path: str, attribute: str, reference: str, label: str) -> Any: + """Import a module and walk a dotted attribute path inside it.""" + try: + module = importlib.import_module(module_path) + except ImportError as exc: + raise BackendUnavailableError( + f'Cannot import the {label} "{reference}": {exc}', + hint="Install the package that provides it and check the module path.", + ) from None + + target = module + try: + for part in attribute.split("."): + target = getattr(target, part) + except AttributeError: + raise BackendUnavailableError( + f'The {label} module "{module_path}" has no attribute "{attribute}".' + ) from None + except ImportError as exc: + raise BackendUnavailableError( + f'Cannot import the {label} "{reference}": {exc}', + hint="Install the optional package that provides it.", + ) from None + return target + + +def import_symbol(reference: str, label: str = "class") -> Any: + """Import a class named as ``module:Class`` or as a dotted name. + + Both spellings are accepted: ``mypkg.backend:MyBackend`` (the same shape + used by ``--model`` and ``--connection``) and ``mypkg.backend.MyBackend``. + The dotted form is resolved by trying progressively shorter module paths, + so a class may be nested several packages deep without the caller having + to know where the module ends and the attribute begins. + + Args: + reference: Fully qualified class name. + label: What the value names, used in error messages. + + Returns: + The imported object. + + Raises: + BackendUnavailableError: When the module or attribute cannot be found. + """ + reference = (reference or "").strip() + if not reference: + raise CLIError(f"The {label} reference is empty") + + if ":" in reference: + module_path, attribute = reference.rsplit(":", 1) + if not module_path or not attribute: + raise CLIError( + f'Invalid {label} "{reference}": expected the form module:Class' + ) + return _load_attribute(module_path, attribute, reference, label) + + parts = reference.split(".") + if len(parts) < 2: + raise CLIError( + f'Invalid {label} "{reference}": expected the form module:Class' + ) + + # A dotted name has no delimiter between the module and the attribute, + # so try the longest importable module prefix first. + for index in range(len(parts) - 1, 0, -1): + module_path = ".".join(parts[:index]) + attribute = ".".join(parts[index:]) + try: + target = importlib.import_module(module_path) + except ImportError: + continue + + try: + for part in attribute.split("."): + target = getattr(target, part) + except AttributeError: + raise BackendUnavailableError( + f'The {label} module "{module_path}" has no attribute "{attribute}".' + ) from None + except ImportError as exc: + raise BackendUnavailableError( + f'Cannot import the {label} "{reference}": {exc}', + hint="Install the optional package that provides it.", + ) from None + return target + + raise BackendUnavailableError( + f'Cannot import the {label} "{reference}".', + hint="Install the package that provides it and check the class path.", + ) + + +def load_backend_class(reference: str, is_async: bool): + """Import and validate a backend class. + + Args: + reference: Fully qualified backend class name. + is_async: Whether the model uses the asynchronous API. The caller is + expected to name the class that matches this mode. + + Returns: + The backend class. + + Raises: + CLIError: When the reference does not name a backend of the right kind. + BackendUnavailableError: When the reference cannot be imported. + """ + backend_class = import_symbol(reference, "backend class") + if not isinstance(backend_class, type): + raise CLIError(f'The backend reference "{reference}" does not name a class') + + expected = AsyncStorageBackend if is_async else StorageBackend + if not issubclass(backend_class, expected): + article = "an" if is_async else "a" + kind = "asynchronous" if is_async else "synchronous" + raise CLIError( + f'The backend class "{reference}" is not {article} {kind} backend' + ) + return backend_class + + +def load_config_class(reference: str): + """Import and validate a connection config class.""" + config_class = import_symbol(reference, "connection config class") + if not isinstance(config_class, type): + raise CLIError( + f'The connection config reference "{reference}" does not name a class' + ) + if not issubclass(config_class, ConnectionConfig): + raise CLIError( + f'The connection config class "{reference}" is not a ConnectionConfig subclass' + ) + return config_class + + +def _parse_options(option_values: Optional[List[str]]) -> List[Tuple[str, str]]: + parsed = [] + for item in option_values or []: + if "=" not in item: + raise CLIError(f'Invalid --config "{item}": expected KEY=VALUE') + key, value = item.split("=", 1) + if not key: + raise CLIError(f'Invalid --config "{item}": key is empty') + parsed.append((key, value)) + return parsed + + +def _scalar_type(annotation) -> type: + """Reduce an annotation such as ``Optional[int]`` to a scalar type.""" + origin = typing.get_origin(annotation) + args = typing.get_args(annotation) + if _ANNOTATED is not None and origin is _ANNOTATED and args: + return _scalar_type(args[0]) + if origin in _UNION_ORIGINS: + candidates = [arg for arg in args if arg is not type(None)] + return _scalar_type(candidates[0]) if candidates else str + if origin is not None: + return str + return annotation if isinstance(annotation, type) else str + + +def _coerce(value: str, annotation) -> Any: + scalar = _scalar_type(annotation) + if scalar is bool: + return value.lower() in ("true", "yes", "1", "on") + if scalar is int: + try: + return int(value) + except ValueError: + raise CLIError(f'Invalid integer value "{value}"') from None + if scalar is float: + try: + return float(value) + except ValueError: + raise CLIError(f'Invalid number value "{value}"') from None + return value + + +def _field_types(config_class) -> Dict[str, Any]: + """Return the constructor's field names and their annotations.""" + try: + dataclass_fields = dataclasses.fields(config_class) + except TypeError: + dataclass_fields = None + + if dataclass_fields is not None: + try: + hints = typing.get_type_hints(config_class) + except (NameError, TypeError): + hints = {} + return { + field.name: hints.get(field.name, field.type) + for field in dataclass_fields + } + + try: + signature = inspect.signature(config_class) + hints = typing.get_type_hints(config_class.__init__) + except (TypeError, ValueError, NameError): + return {} + + fields: Dict[str, Any] = {} + for name, parameter in signature.parameters.items(): + if name == "self" or parameter.kind in ( + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + ): + continue + fields[name] = hints.get(name, parameter.annotation) + return fields + + +def _collect_config_updates(args, config_class) -> Dict[str, Any]: + """Turn repeated ``--config KEY=VALUE`` options into constructor kwargs. + + A key that names a constructor field is coerced to that field's type. + ``options.KEY=value`` and unknown keys are collected into ``config.options``, + so a backend may accept driver-specific values without the CLI knowing them. + """ + fields = _field_types(config_class) + updates: Dict[str, Any] = {} + extra_options: Dict[str, Any] = {} + + for key, value in _parse_options(getattr(args, "config", None)): + if key == "options": + try: + parsed = json.loads(value) + except json.JSONDecodeError: + raise CLIError( + '--config options must be a JSON object; ' + "use options.KEY=VALUE for individual entries" + ) from None + if not isinstance(parsed, dict): + raise CLIError("--config options must be a JSON object") + extra_options.update(parsed) + elif key.startswith("options."): + extra_options[key[len("options."):]] = value + elif key in fields and key != "options": + updates[key] = _coerce(value, fields[key]) + else: + extra_options[key] = value + + if extra_options: + updates["options"] = extra_options + return updates + + +def _build_config(args, config_class) -> ConnectionConfig: + values = _collect_config_updates(args, config_class) + try: + config = config_class(**values) + except TypeError as exc: + raise CLIError(f"Cannot build connection config: {exc}") from None + if not isinstance(config, ConnectionConfig): + raise CLIError( + f'"{config_class.__name__}" produced {type(config).__name__}, ' + "not a ConnectionConfig" + ) + return config + + +def _apply_overrides(config: ConnectionConfig, args) -> ConnectionConfig: + updates = _collect_config_updates(args, type(config)) + if not updates: + return config + if "options" in updates: + merged = dict(getattr(config, "options", {}) or {}) + merged.update(updates["options"]) + updates["options"] = merged + + clone = getattr(config, "clone", None) + if callable(clone): + return clone(**updates) + + try: + base = config.to_dict() + except (AttributeError, TypeError): + base = {} + base.update(updates) + try: + return type(config)(**base) + except TypeError as exc: + raise CLIError(f"Cannot override connection config: {exc}") from None + + +def _load_named_connection(qualified_name: str) -> ConnectionConfig: + if ":" not in qualified_name: + raise CLIError( + f'Invalid --connection "{qualified_name}": expected module:callable' + ) + module_path, attr_name = qualified_name.rsplit(":", 1) + resolver_name = f"{module_path}.{attr_name}" + try: + resolver = NamedConnectionResolver(resolver_name).load() + config = resolver.resolve() + except Exception as exc: + raise CLIError(f'Cannot resolve --connection "{qualified_name}": {exc}') from exc + if not isinstance(config, ConnectionConfig): + raise CLIError( + f'--connection "{qualified_name}" returned {type(config).__name__}, ' + "not a ConnectionConfig" + ) + return config + + +def _validate_backend_class(backend_class, is_async: bool, source: str) -> None: + expected = AsyncStorageBackend if is_async else StorageBackend + if not issubclass(backend_class, expected): + article = "an" if is_async else "a" + kind = "asynchronous" if is_async else "synchronous" + raise CLIError( + f"{source} selected {backend_class.__name__}, which is not " + f"{article} {kind} backend" + ) + + +def resolve_connection(model, is_async: bool, args): + """Resolve the connection config and backend class for one command. + + Each half is resolved independently: + + * backend class: ``--backend-class``, otherwise the model's own + ``configure()``. + * connection config: ``--connection``, ``--config-class`` with ``--config`` + values, otherwise the model's own ``configure()``. + + The two halves may come from different sources, so a named connection can + supply credentials while the model supplies its backend class. + + Returns: + Tuple of (config, backend_class). + + Raises: + CLIError: When a required half is missing or the two inputs conflict. + """ + backend_reference = getattr(args, "backend_class", None) + if backend_reference: + backend_class = load_backend_class(backend_reference, is_async) + else: + backend_class = getattr(model, "__backend_class__", None) + if backend_class is None: + raise CLIError( + f"{model.__name__} has no backend class. Pass --backend-class " + "MODULE:CLASS, or call configure() in the model module." + ) + _validate_backend_class(backend_class, is_async, f"{model.__name__}'s configuration") + + connection_reference = getattr(args, "connection", None) + config_reference = getattr(args, "config_class", None) + if connection_reference and config_reference: + raise CLIError("Use --connection or --config-class, not both") + + if connection_reference: + config = _apply_overrides(_load_named_connection(connection_reference), args) + elif config_reference: + config = _build_config(args, load_config_class(config_reference)) + else: + config = getattr(model, "__connection_config__", None) + if config is None: + raise CLIError( + f"{model.__name__} has no connection config. Pass --config-class " + "MODULE:CLASS with --config KEY=VALUE, pass --connection, or call " + "configure() in the model module." + ) + if not isinstance(config, ConnectionConfig): + raise CLIError(f"{model.__name__} has an invalid connection configuration") + config = _apply_overrides(config, args) + + return config, backend_class diff --git a/src/rhosocial/activerecord/cli/commands/__init__.py b/src/rhosocial/activerecord/cli/commands/__init__.py new file mode 100644 index 00000000..8db1f30d --- /dev/null +++ b/src/rhosocial/activerecord/cli/commands/__init__.py @@ -0,0 +1,2 @@ +# src/rhosocial/activerecord/cli/commands/__init__.py +"""CLI command handlers grouped by responsibility.""" diff --git a/src/rhosocial/activerecord/cli/commands/cte.py b/src/rhosocial/activerecord/cli/commands/cte.py new file mode 100644 index 00000000..976036c0 --- /dev/null +++ b/src/rhosocial/activerecord/cli/commands/cte.py @@ -0,0 +1,65 @@ +# src/rhosocial/activerecord/cli/commands/cte.py +"""CTE command handlers.""" + +from ..output import render_list +from ..runtime import ( + run_coroutine, + ASYNC_CTE_QUERY_TYPES, + CTE_QUERY_TYPES, + context_fields, + resolve_query_reference, + resolve_query_reference_async, + validate_query, +) + + +def _render_rows(rows, ctx): + fields = context_fields(None, ctx.args, rows) + render_list(rows, None, fields, ctx.output, ctx.rich_ascii, ctx.args.no_header) + + +def _run_sync(ctx): + query = resolve_query_reference(ctx.args.query, "cte query") + validate_query( + query, CTE_QUERY_TYPES, ASYNC_CTE_QUERY_TYPES, is_async=False, label="--query" + ) + rows = query.aggregate() + _render_rows(rows, ctx) + return 0 + + +async def _run_async(ctx): + query = await resolve_query_reference_async(ctx.args.query, "cte query") + validate_query( + query, CTE_QUERY_TYPES, ASYNC_CTE_QUERY_TYPES, is_async=True, label="--query" + ) + rows = await query.aggregate() + _render_rows(rows, ctx) + return 0 + + +def execute_sync(ctx) -> int: + return _run_sync(ctx) + + +async def execute_async(ctx) -> int: + return await _run_async(ctx) + + +def execute_auto(ctx) -> int: + """Execute ``cte run`` when no model/backend is configured up front.""" + query = resolve_query_reference(ctx.args.query, "cte query") + is_async = validate_query( + query, CTE_QUERY_TYPES, ASYNC_CTE_QUERY_TYPES, label="--query" + ) + if is_async: + return run_coroutine(_render_async(query, ctx)) + rows = query.aggregate() + _render_rows(rows, ctx) + return 0 + + +async def _render_async(query, ctx): + rows = await query.aggregate() + _render_rows(rows, ctx) + return 0 diff --git a/src/rhosocial/activerecord/cli/commands/dml.py b/src/rhosocial/activerecord/cli/commands/dml.py new file mode 100644 index 00000000..158f3f62 --- /dev/null +++ b/src/rhosocial/activerecord/cli/commands/dml.py @@ -0,0 +1,425 @@ +# src/rhosocial/activerecord/cli/commands/dml.py +"""DML command handlers: single-record and bulk ActiveRecord operations.""" + +from typing import Any, Dict, List + +from pydantic import ValidationError + +from ...backend.errors import BulkValidationError, ValidationError as DBValidationError +from ..errors import CLIError, CliNotFoundError +from ..input import check_fields, convert_field, read_object, read_records, read_values +from ..interaction import confirm, ensure_writable, soft_delete_action, soft_delete_field +from ..output import render_affected, render_delete, render_list, render_record +from ..runtime import parse_pk, parse_pk_mapping + + +def _read_record_data(args) -> Dict[str, Any]: + return read_object(args.data) + + +def _make_record(model, data: Dict[str, Any], index: int = None): + check_fields(model, data) + try: + return model(**data) + except ValidationError as exc: + suffix = f" (item {index})" if index is not None else "" + raise CLIError(f"Invalid data for {model.__name__}{suffix}: {exc}") from exc + + +def _invalid_data_message(model, exc: Exception, pk=None) -> str: + target = model.__name__ if pk is None else f"{model.__name__} pk={pk!r}" + return f"Invalid data for {target}: {exc}" + + +def _save_sync(record, model, pk=None) -> int: + """Save one record, translating model validation failures.""" + try: + return record.save() + except (ValidationError, DBValidationError) as exc: + raise CLIError(_invalid_data_message(model, exc, pk)) from exc + + +async def _save_async(record, model, pk=None) -> int: + """Save one record asynchronously, translating validation failures.""" + try: + return await record.save() + except (ValidationError, DBValidationError) as exc: + raise CLIError(_invalid_data_message(model, exc, pk)) from exc + + +def _update_values(model, data: Dict[str, Any]) -> Dict[str, Any]: + pk_fields = set(model.primary_key_fields()) + present_pk = [field for field in data if field in pk_fields] + if present_pk: + raise CLIError( + "Cannot update primary key field(s) " + f"{', '.join(present_pk)}; delete and create a new record instead" + ) + check_fields(model, data) + return {name: convert_field(model, name, value) for name, value in data.items()} + + +def _load_sync(model, pk): + return model.find_one(pk) + + +async def _load_async(model, pk): + return await model.find_one(pk) + + +def _not_found(model, pk): + return CliNotFoundError(f"{model.__name__} with pk={pk!r} was not found") + + +def _create_sync(ctx): + ensure_writable(ctx.model, "dml create") + record = _make_record(ctx.model, _read_record_data(ctx.args)) + _save_sync(record, ctx.model) + render_record(record, ctx.model, ctx.fields, ctx.output, ctx.rich_ascii) + return 0 + + +async def _create_async(ctx): + ensure_writable(ctx.model, "dml create") + record = _make_record(ctx.model, _read_record_data(ctx.args)) + await _save_async(record, ctx.model) + render_record(record, ctx.model, ctx.fields, ctx.output, ctx.rich_ascii) + return 0 + + +def _update_sync(ctx): + ensure_writable(ctx.model, "dml update") + pk = parse_pk(ctx.model, ctx.args) + record = _load_sync(ctx.model, pk) + if record is None: + raise _not_found(ctx.model, pk) + for name, value in _update_values(ctx.model, _read_record_data(ctx.args)).items(): + setattr(record, name, value) + _save_sync(record, ctx.model, pk) + render_record(record, ctx.model, ctx.fields, ctx.output, ctx.rich_ascii) + return 0 + + +async def _update_async(ctx): + ensure_writable(ctx.model, "dml update") + pk = parse_pk(ctx.model, ctx.args) + record = await _load_async(ctx.model, pk) + if record is None: + raise _not_found(ctx.model, pk) + for name, value in _update_values(ctx.model, _read_record_data(ctx.args)).items(): + setattr(record, name, value) + await _save_async(record, ctx.model, pk) + render_record(record, ctx.model, ctx.fields, ctx.output, ctx.rich_ascii) + return 0 + + +def _delete_sync(ctx): + ensure_writable(ctx.model, "dml delete") + pk = parse_pk(ctx.model, ctx.args) + record = _load_sync(ctx.model, pk) + if record is None: + raise _not_found(ctx.model, pk) + action = soft_delete_action(ctx.model) + field_name = soft_delete_field(ctx.model) + if ctx.args.dry_run: + render_delete( + pk, + 1, + ctx.model, + ctx.output, + ctx.rich_ascii, + action, + field_name, + True, + ctx.args.no_header, + ) + return 0 + confirm( + f"Delete {ctx.model.__name__} pk={pk!r} ({action})?", ctx.args + ) + affected = record.delete() + render_delete( + pk, + affected, + ctx.model, + ctx.output, + ctx.rich_ascii, + action, + field_name, + no_header=ctx.args.no_header, + ) + return 0 + + +async def _delete_async(ctx): + ensure_writable(ctx.model, "dml delete") + pk = parse_pk(ctx.model, ctx.args) + record = await _load_async(ctx.model, pk) + if record is None: + raise _not_found(ctx.model, pk) + action = soft_delete_action(ctx.model) + field_name = soft_delete_field(ctx.model) + if ctx.args.dry_run: + render_delete( + pk, + 1, + ctx.model, + ctx.output, + ctx.rich_ascii, + action, + field_name, + True, + ctx.args.no_header, + ) + return 0 + confirm( + f"Delete {ctx.model.__name__} pk={pk!r} ({action})?", ctx.args + ) + affected = await record.delete() + render_delete(pk, affected, ctx.model, ctx.output, ctx.rich_ascii, action, field_name) + return 0 + + +def _bulk_records(ctx) -> List[Dict[str, Any]]: + return read_records(ctx.args.data, ctx.args.input_format) + + +def _bulk_create_sync(ctx): + ensure_writable(ctx.model, "dml bulk create") + data = _bulk_records(ctx) + records = [ + _make_record(ctx.model, item, index + 1) for index, item in enumerate(data) + ] + if ctx.args.dry_run: + render_affected( + "create", len(records), ctx.output, no_header=ctx.args.no_header, dry_run=True + ) + return 0 + result = ctx.model.bulk_create(records, batch_size=ctx.args.batch_size) + render_list(result, ctx.model, ctx.fields, ctx.output, ctx.rich_ascii) + return 0 + + +async def _bulk_create_async(ctx): + ensure_writable(ctx.model, "dml bulk create") + data = _bulk_records(ctx) + records = [ + _make_record(ctx.model, item, index + 1) for index, item in enumerate(data) + ] + if ctx.args.dry_run: + render_affected( + "create", len(records), ctx.output, no_header=ctx.args.no_header, dry_run=True + ) + return 0 + result = await ctx.model.bulk_create(records, batch_size=ctx.args.batch_size) + render_list(result, ctx.model, ctx.fields, ctx.output, ctx.rich_ascii) + return 0 + + +def _bulk_update_parts(model, data): + """Return ``(pk, values)`` for one bulk-update object.""" + check_fields(model, data) + pk_fields = set(model.primary_key_fields()) + pk = parse_pk_mapping(model, data) + values = {name: value for name, value in data.items() if name not in pk_fields} + return pk, values + + +def _select_update_fields(ctx, parts) -> List[str]: + explicit = getattr(ctx.args, "update_fields", None) + if explicit: + names = [part.strip() for part in explicit.split(",") if part.strip()] + if not names: + raise CLIError("--update-fields must name at least one field") + unknown = [name for name in names if name not in ctx.model.model_fields] + if unknown: + raise CLIError( + f"Unknown --update-fields field(s): {', '.join(unknown)}. " + f"Available fields: {', '.join(ctx.model.model_fields)}" + ) + else: + sets = [set(values) for _, values in parts] + if not sets or not sets[0]: + raise CLIError("Bulk update needs at least one non-primary-key field") + if any(field_set != sets[0] for field_set in sets[1:]): + raise CLIError( + "Bulk update objects must all contain the same fields; " + "pass --update-fields to choose them explicitly" + ) + names = list(sets[0]) + unknown = [name for name in names if name not in ctx.model.model_fields] + if unknown: + raise CLIError( + f"Unknown field(s) for {ctx.model.__name__}: {', '.join(unknown)}. " + f"Available fields: {', '.join(ctx.model.model_fields)}" + ) + for index, (_, values) in enumerate(parts): + missing = [name for name in names if name not in values] + if missing: + raise CLIError( + f"bulk update item {index + 1} is missing field(s): {', '.join(missing)}" + ) + return names + + +def _first_bulk_error(exc): + """Return ``(index, message)`` for a BulkValidationError, 0-based.""" + details = getattr(exc, "errors", None) or [] + if details: + return details[0] + return 0, str(exc) + + +def _bulk_update_sync(ctx): + ensure_writable(ctx.model, "dml bulk update") + parts = [_bulk_update_parts(ctx.model, data) for data in _bulk_records(ctx)] + names = _select_update_fields(ctx, parts) + records = [] + for index, (pk, values) in enumerate(parts): + record = _load_sync(ctx.model, pk) + if record is None: + raise CliNotFoundError( + f"{ctx.model.__name__} bulk update item {index + 1} with pk={pk!r} " + "was not found" + ) + for name in names: + setattr(record, name, convert_field(ctx.model, name, values[name])) + records.append(record) + if ctx.args.dry_run: + render_affected( + "update", len(records), ctx.output, no_header=ctx.args.no_header, dry_run=True + ) + return 0 + try: + affected = ctx.model.bulk_update( + records, names, batch_size=ctx.args.batch_size + ) + except BulkValidationError as exc: + index, message = _first_bulk_error(exc) + pk = parts[index][0] if index < len(parts) else None + raise CLIError( + f"Bulk update failed at item {index + 1} (pk={pk!r}): {message}" + ) from exc + render_affected("update", affected, ctx.output, no_header=ctx.args.no_header) + return 0 + + +async def _bulk_update_async(ctx): + ensure_writable(ctx.model, "dml bulk update") + parts = [_bulk_update_parts(ctx.model, data) for data in _bulk_records(ctx)] + names = _select_update_fields(ctx, parts) + records = [] + for index, (pk, values) in enumerate(parts): + record = await _load_async(ctx.model, pk) + if record is None: + raise CliNotFoundError( + f"{ctx.model.__name__} bulk update item {index + 1} with pk={pk!r} " + "was not found" + ) + for name in names: + setattr(record, name, convert_field(ctx.model, name, values[name])) + records.append(record) + if ctx.args.dry_run: + render_affected( + "update", len(records), ctx.output, no_header=ctx.args.no_header, dry_run=True + ) + return 0 + try: + affected = await ctx.model.bulk_update( + records, names, batch_size=ctx.args.batch_size + ) + except BulkValidationError as exc: + index, message = _first_bulk_error(exc) + pk = parts[index][0] if index < len(parts) else None + raise CLIError( + f"Bulk update failed at item {index + 1} (pk={pk!r}): {message}" + ) from exc + render_affected("update", affected, ctx.output, no_header=ctx.args.no_header) + return 0 + + +def _bulk_delete_values(model, values): + pks = [] + for index, value in enumerate(values): + if isinstance(value, dict): + pks.append(parse_pk_mapping(model, value, f"bulk delete item {index + 1}")) + elif model.is_composite_pk(): + raise CLIError( + f"Bulk delete item {index + 1} must be an object for composite primary key" + ) + else: + pks.append(parse_pk(model, _PkArgs(value))) + return pks + + +class _PkArgs: + def __init__(self, value): + self.pk = value + self.pk_json = None + + +def _bulk_delete_sync(ctx): + ensure_writable(ctx.model, "dml bulk delete") + pks = _bulk_delete_values(ctx.model, read_values(ctx.args.data, ctx.args.input_format)) + records = [] + for index, pk in enumerate(pks): + record = _load_sync(ctx.model, pk) + if record is None: + raise CliNotFoundError( + f"{ctx.model.__name__} bulk delete item {index + 1} with pk={pk!r} " + "was not found" + ) + records.append(record) + action = soft_delete_action(ctx.model) + if ctx.args.dry_run: + render_affected( + action, len(records), ctx.output, no_header=ctx.args.no_header, dry_run=True + ) + return 0 + confirm(f"Delete {len(records)} {ctx.model.__name__} records ({action})?", ctx.args) + affected = ctx.model.bulk_delete(records) + render_affected(action, affected, ctx.output, no_header=ctx.args.no_header) + return 0 + + +async def _bulk_delete_async(ctx): + ensure_writable(ctx.model, "dml bulk delete") + pks = _bulk_delete_values(ctx.model, read_values(ctx.args.data, ctx.args.input_format)) + records = [] + for index, pk in enumerate(pks): + record = await _load_async(ctx.model, pk) + if record is None: + raise CliNotFoundError( + f"{ctx.model.__name__} bulk delete item {index + 1} with pk={pk!r} " + "was not found" + ) + records.append(record) + action = soft_delete_action(ctx.model) + if ctx.args.dry_run: + render_affected( + action, len(records), ctx.output, no_header=ctx.args.no_header, dry_run=True + ) + return 0 + confirm(f"Delete {len(records)} {ctx.model.__name__} records ({action})?", ctx.args) + affected = await ctx.model.bulk_delete(records) + render_affected(action, affected, ctx.output, no_header=ctx.args.no_header) + return 0 + + +SYNC_HANDLERS = { + "create": _create_sync, + "update": _update_sync, + "delete": _delete_sync, + "bulk_create": _bulk_create_sync, + "bulk_update": _bulk_update_sync, + "bulk_delete": _bulk_delete_sync, +} + +ASYNC_HANDLERS = { + "create": _create_async, + "update": _update_async, + "delete": _delete_async, + "bulk_create": _bulk_create_async, + "bulk_update": _bulk_update_async, + "bulk_delete": _bulk_delete_async, +} diff --git a/src/rhosocial/activerecord/cli/commands/dql.py b/src/rhosocial/activerecord/cli/commands/dql.py new file mode 100644 index 00000000..efcdfa1f --- /dev/null +++ b/src/rhosocial/activerecord/cli/commands/dql.py @@ -0,0 +1,303 @@ +# src/rhosocial/activerecord/cli/commands/dql.py +"""DQL command handlers: ActiveQuery reads and user-defined query execution.""" + + +from ...backend.expression import Column +from ...backend.expression import functions +from ..errors import CLIError, CliNotFoundError +from ..interaction import apply_pagination +from ..output import render_list, render_record, render_scalar +from ..runtime import ( + ACTIVE_QUERY_TYPES, + ASYNC_ACTIVE_QUERY_TYPES, + apply_filters, + base_query, + context_fields, + parse_pk, + resolve_query_reference, + resolve_query_reference_async, + validate_query, +) + + +def _query(ctx): + return apply_filters(base_query(ctx.model, ctx.args), ctx.model, ctx.args) + + +def _list_sync(ctx): + query = apply_pagination(_query(ctx), ctx.args, label="rows") + records = query.all() + render_list( + records, ctx.model, ctx.fields, ctx.output, ctx.rich_ascii, ctx.args.no_header + ) + return 0 + + +async def _list_async(ctx): + query = apply_pagination(_query(ctx), ctx.args, label="rows") + records = await query.all() + render_list( + records, ctx.model, ctx.fields, ctx.output, ctx.rich_ascii, ctx.args.no_header + ) + return 0 + + +def _get_sync(ctx): + pk = parse_pk(ctx.model, ctx.args) + record = ctx.model.find_one(pk) + if record is None: + raise CliNotFoundError(f"{ctx.model.__name__} with pk={pk!r} was not found") + render_record(record, ctx.model, ctx.fields, ctx.output, ctx.rich_ascii) + return 0 + + +async def _get_async(ctx): + pk = parse_pk(ctx.model, ctx.args) + record = await ctx.model.find_one(pk) + if record is None: + raise CliNotFoundError(f"{ctx.model.__name__} with pk={pk!r} was not found") + render_record(record, ctx.model, ctx.fields, ctx.output, ctx.rich_ascii) + return 0 + + +def _count_sync(ctx): + count = _query(ctx).count() + render_scalar(count, ctx.output) + return 0 + + +async def _count_async(ctx): + count = await _query(ctx).count() + render_scalar(count, ctx.output) + return 0 + + +def _exists_sync(ctx): + exists = _query(ctx).exists() + render_scalar(exists, ctx.output) + return 0 + + +async def _exists_async(ctx): + exists = await _query(ctx).exists() + render_scalar(exists, ctx.output) + return 0 + + +def _validate_aggregate_field(ctx) -> None: + """Reject unknown or incompatible ``--field`` values before any SQL runs.""" + field = ctx.args.field + available = list(ctx.model.model_fields) + if field == "*": + if ctx.args.fn != "count": + raise CLIError(f"--fn {ctx.args.fn} requires a concrete --field, not *") + return + if field not in available: + raise CLIError( + f'Unknown aggregate field "{field}". ' + f"Available fields: {', '.join(available)}" + ) + + +def _aggregate_expression(ctx, field): + dialect = ctx.model.backend().dialect + fn = ctx.args.fn + if field == "*": + if fn != "count": + raise CLIError(f"--fn {fn} requires a concrete --field, not *") + return functions.count(dialect, "*", is_distinct=ctx.args.distinct, alias="value") + column = Column(dialect, ctx.model.column_name(field)) + if fn == "count": + return functions.count( + dialect, column, is_distinct=ctx.args.distinct, alias="value" + ) + if fn == "sum": + return functions.sum_( + dialect, column, is_distinct=ctx.args.distinct, alias="value" + ) + if fn == "avg": + return functions.avg( + dialect, column, is_distinct=ctx.args.distinct, alias="value" + ) + if fn == "min": + return functions.min_(dialect, column, is_distinct=ctx.args.distinct, alias="value") + if fn == "max": + return functions.max_(dialect, column, is_distinct=ctx.args.distinct, alias="value") + raise CLIError(f"Unsupported aggregate function: {fn}") + + +def _aggregate_query(ctx): + query = _query(ctx) + group_by = ctx.args.group_by or [] + if not group_by: + return None, query + for field in group_by: + if field not in ctx.model.model_fields: + raise CLIError( + f'Unknown --group-by field "{field}". ' + f"Available fields: {', '.join(ctx.model.model_fields)}" + ) + dialect = ctx.model.backend().dialect + group_columns = [Column(dialect, ctx.model.column_name(field)) for field in group_by] + query = query.group_by(*group_columns) + query = query.select( + *group_columns, _aggregate_expression(ctx, ctx.args.field) + ) + return group_columns, query + + +def _scalar_aggregate(ctx, query): + if ctx.args.field == "*": + if ctx.args.fn != "count": + raise CLIError(f"--fn {ctx.args.fn} requires a concrete --field, not *") + value = query.count(is_distinct=ctx.args.distinct) + elif ctx.args.fn == "count": + value = query.count(ctx.args.field, is_distinct=ctx.args.distinct) + elif ctx.args.fn == "sum": + value = query.sum_(ctx.args.field, is_distinct=ctx.args.distinct) + elif ctx.args.fn == "avg": + value = query.avg(ctx.args.field, is_distinct=ctx.args.distinct) + elif ctx.args.fn == "min": + value = query.min_(ctx.args.field) + elif ctx.args.fn == "max": + value = query.max_(ctx.args.field) + else: + raise CLIError(f"Unsupported aggregate function: {ctx.args.fn}") + render_scalar(value, ctx.output) + return value + + +async def _scalar_aggregate_async(ctx, query): + if ctx.args.field == "*": + if ctx.args.fn != "count": + raise CLIError(f"--fn {ctx.args.fn} requires a concrete --field, not *") + value = await query.count(is_distinct=ctx.args.distinct) + elif ctx.args.fn == "count": + value = await query.count(ctx.args.field, is_distinct=ctx.args.distinct) + elif ctx.args.fn == "sum": + value = await query.sum_(ctx.args.field, is_distinct=ctx.args.distinct) + elif ctx.args.fn == "avg": + value = await query.avg(ctx.args.field, is_distinct=ctx.args.distinct) + elif ctx.args.fn == "min": + value = await query.min_(ctx.args.field) + elif ctx.args.fn == "max": + value = await query.max_(ctx.args.field) + else: + raise CLIError(f"Unsupported aggregate function: {ctx.args.fn}") + render_scalar(value, ctx.output) + return value + + +def _aggregate_sync(ctx): + _validate_aggregate_field(ctx) + group_columns, query = _aggregate_query(ctx) + if group_columns is None: + _scalar_aggregate(ctx, apply_filters(base_query(ctx.model, ctx.args), ctx.model, ctx.args)) + return 0 + query = apply_pagination(query, ctx.args, label="groups") + rows = query.aggregate() + fields = context_fields(None, ctx.args, rows) + render_list(rows, None, fields, ctx.output, ctx.rich_ascii, ctx.args.no_header) + return 0 + + +async def _aggregate_async(ctx): + _validate_aggregate_field(ctx) + group_columns, query = _aggregate_query(ctx) + if group_columns is None: + await _scalar_aggregate_async( + ctx, apply_filters(base_query(ctx.model, ctx.args), ctx.model, ctx.args) + ) + return 0 + query = apply_pagination(query, ctx.args, label="groups") + rows = await query.aggregate() + fields = context_fields(None, ctx.args, rows) + render_list(rows, None, fields, ctx.output, ctx.rich_ascii, ctx.args.no_header) + return 0 + + +def _run_query_sync(ctx, reference): + query = resolve_query_reference(reference, "query") + validate_query( + query, ACTIVE_QUERY_TYPES, ASYNC_ACTIVE_QUERY_TYPES, + is_async=False, label="--query", + ) + mode = ctx.args.mode + if mode in ("list", "aggregate"): + query = apply_pagination(query, ctx.args, label="rows") + if mode == "list": + records = query.all() + render_list( + records, ctx.model, ctx.fields, ctx.output, ctx.rich_ascii, ctx.args.no_header + ) + elif mode == "one": + record = query.one() + if record is None: + raise CliNotFoundError("Query returned no record") + render_record(record, ctx.model, ctx.fields, ctx.output, ctx.rich_ascii) + elif mode == "count": + render_scalar(query.count(), ctx.output) + elif mode == "exists": + render_scalar(query.exists(), ctx.output) + else: + rows = query.aggregate() + fields = context_fields(None, ctx.args, rows) + render_list(rows, None, fields, ctx.output, ctx.rich_ascii, ctx.args.no_header) + return 0 + + +async def _run_query_async(ctx, reference): + query = await resolve_query_reference_async(reference, "query") + validate_query( + query, ACTIVE_QUERY_TYPES, ASYNC_ACTIVE_QUERY_TYPES, + is_async=True, label="--query", + ) + mode = ctx.args.mode + if mode in ("list", "aggregate"): + query = apply_pagination(query, ctx.args, label="rows") + if mode == "list": + records = await query.all() + render_list( + records, ctx.model, ctx.fields, ctx.output, ctx.rich_ascii, ctx.args.no_header + ) + elif mode == "one": + record = await query.one() + if record is None: + raise CliNotFoundError("Query returned no record") + render_record(record, ctx.model, ctx.fields, ctx.output, ctx.rich_ascii) + elif mode == "count": + render_scalar(await query.count(), ctx.output) + elif mode == "exists": + render_scalar(await query.exists(), ctx.output) + else: + rows = await query.aggregate() + fields = context_fields(None, ctx.args, rows) + render_list(rows, None, fields, ctx.output, ctx.rich_ascii, ctx.args.no_header) + return 0 + + +def _run_sync(ctx): + return _run_query_sync(ctx, ctx.args.query) + + +async def _run_async(ctx): + return await _run_query_async(ctx, ctx.args.query) + + +SYNC_HANDLERS = { + "get": _get_sync, + "list": _list_sync, + "count": _count_sync, + "exists": _exists_sync, + "aggregate": _aggregate_sync, + "run": _run_sync, +} + +ASYNC_HANDLERS = { + "get": _get_async, + "list": _list_async, + "count": _count_async, + "exists": _exists_async, + "aggregate": _aggregate_async, + "run": _run_async, +} diff --git a/src/rhosocial/activerecord/cli/commands/setop.py b/src/rhosocial/activerecord/cli/commands/setop.py new file mode 100644 index 00000000..9ce64920 --- /dev/null +++ b/src/rhosocial/activerecord/cli/commands/setop.py @@ -0,0 +1,128 @@ +# src/rhosocial/activerecord/cli/commands/setop.py +"""SetOperation command handlers.""" + + +from ...query import AsyncSetOperationQuery, SetOperationQuery +from ..interaction import apply_pagination +from ..output import render_list +from ..runtime import ( + run_coroutine, + ASYNC_SET_QUERY_TYPES, + SET_QUERY_TYPES, + apply_filters, + base_query, + context_fields, + resolve_query_reference, + resolve_query_reference_async, + validate_query, +) + +_OPERATIONS = { + "union": "UNION", + "union-all": "UNION ALL", + "intersect": "INTERSECT", + "except": "EXCEPT", +} + + +def _arm_query(model, args, where): + query = base_query(model, args) + if where: + from ..runtime import parse_where + + for predicate in parse_where(model, where): + query = query.where(predicate) + query = apply_filters(query, model, args) + return apply_pagination(query, args, default=False) + + +def _render_rows(rows, ctx): + fields = context_fields(None, ctx.args, rows) + render_list(rows, None, fields, ctx.output, ctx.rich_ascii, ctx.args.no_header) + + +def _simple_sync(ctx, left_model, right_model, operation): + left = _arm_query(left_model, ctx.args, ctx.args.left_where) + right = _arm_query(right_model, ctx.args, ctx.args.right_where) + op = _OPERATIONS[operation] + if op == "UNION": + combined = left.union(right) + elif op == "INTERSECT": + combined = left.intersect(right) + elif op == "EXCEPT": + combined = left.except_(right) + else: + combined = SetOperationQuery(left, right, "UNION ALL") + rows = combined.aggregate() + _render_rows(rows, ctx) + return 0 + + +async def _simple_async(ctx, left_model, right_model, operation): + left = _arm_query(left_model, ctx.args, ctx.args.left_where) + right = _arm_query(right_model, ctx.args, ctx.args.right_where) + op = _OPERATIONS[operation] + if op == "UNION": + combined = left.union(right) + elif op == "INTERSECT": + combined = left.intersect(right) + elif op == "EXCEPT": + combined = left.except_(right) + else: + combined = AsyncSetOperationQuery(left, right, "UNION ALL") + rows = await combined.aggregate() + _render_rows(rows, ctx) + return 0 + + +def _run_sync(ctx): + query = resolve_query_reference(ctx.args.query, "set query") + validate_query( + query, SET_QUERY_TYPES, ASYNC_SET_QUERY_TYPES, is_async=False, label="--query" + ) + rows = query.aggregate() + _render_rows(rows, ctx) + return 0 + + +async def _run_async(ctx): + query = await resolve_query_reference_async(ctx.args.query, "set query") + validate_query( + query, SET_QUERY_TYPES, ASYNC_SET_QUERY_TYPES, is_async=True, label="--query" + ) + rows = await query.aggregate() + _render_rows(rows, ctx) + return 0 + + +def execute_sync(ctx, left_model=None, right_model=None) -> int: + operation = getattr(ctx.args, "set_operation", None) + if operation in _OPERATIONS: + return _simple_sync(ctx, left_model, right_model, operation) + return _run_sync(ctx) + + +async def execute_async(ctx, left_model=None, right_model=None) -> int: + operation = getattr(ctx.args, "set_operation", None) + if operation in _OPERATIONS: + return await _simple_async(ctx, left_model, right_model, operation) + return await _run_async(ctx) + + +def execute_auto(ctx) -> int: + """Execute ``set run`` when no model/backend is configured up front.""" + query = resolve_query_reference(ctx.args.query, "set query") + is_async = validate_query( + query, SET_QUERY_TYPES, ASYNC_SET_QUERY_TYPES, label="--query" + ) + if is_async: + return run_coroutine(_render_async(query, ctx)) + rows = query.aggregate() + _render_rows(rows, ctx) + return 0 + + +async def _render_async(query, ctx): + rows = await query.aggregate() + _render_rows(rows, ctx) + return 0 diff --git a/src/rhosocial/activerecord/cli/error_mapping.py b/src/rhosocial/activerecord/cli/error_mapping.py new file mode 100644 index 00000000..fc08a286 --- /dev/null +++ b/src/rhosocial/activerecord/cli/error_mapping.py @@ -0,0 +1,138 @@ +# src/rhosocial/activerecord/cli/error_mapping.py +"""Translate model and backend exceptions into CLI errors. + +The CLI never changes framework semantics: models still raise their own +exceptions, and this module only decides which command-level error and exit +code describes them. Every translation keeps the original exception chain with +``raise translated from exc``. + +The model layer raises ``RecordNotFound``; the CLI layer raises +``CliNotFoundError``. They stay separate classes because one describes a query +result and the other describes a command outcome and exit code. +""" + +from typing import Optional + +from pydantic import ValidationError as PydanticValidationError + +from ..backend.errors import ( + BulkStateError, + BulkValidationError, + ConnectionError as BackendConnectionError, + DatabaseError, + DeadlockError, + IntegrityError, + LockError, + OperationalError, + QueryError, + ReadOnlyError, + RecordNotFound, + StaleRecordError, + TypeConversionError, + UnaddressableRecordError, + ValidationError as DBValidationError, +) +from .errors import CLIError, CliNotFoundError, ReadOnlyModelError + + +def _pydantic_summary(error: PydanticValidationError) -> str: + """Return the first error as ``field: message``.""" + details = error.errors() + if not details: + return str(error) + first = details[0] + location = ".".join(str(part) for part in first.get("loc", ())) + message = first.get("msg", "") + return f"{location}: {message}" if location else message + + +def operation_context(operation: str) -> str: + """Return `` during `` or an empty string.""" + return f" during {operation}" if operation else "" + + +def translate_error( + exc: Exception, + *, + operation: str = "", + model=None, + pk=None, +) -> Optional[CLIError]: + """Return the CLI error that describes ``exc``, or ``None`` if unknown. + + Args: + exc: The exception raised by a model or backend call. + operation: Command label such as ``"dml update"``; used in the message. + model: Optional model class, used when the message needs a name. + pk: Optional primary key, used for not-found and update messages. + """ + context = operation_context(operation) + model_name = model.__name__ if model is not None else "model" + + if isinstance(exc, CLIError): + return exc + if isinstance(exc, PydanticValidationError): + return CLIError(f"Invalid data for {model_name}: {_pydantic_summary(exc)}") + if isinstance(exc, UnaddressableRecordError): + if model is not None: + return CLIError( + f"{model.__name__} has no primary key; {operation or 'this command'} cannot " + "address a single row. Use `dql list --where ...` instead." + ) + return CLIError(str(exc)) + if isinstance(exc, RecordNotFound): + if pk is not None: + return CliNotFoundError(f"{model_name} with pk={pk!r} was not found") + return CliNotFoundError(str(exc)) + if isinstance(exc, ReadOnlyError): + return ReadOnlyModelError(exc.model_name, exc.operation) + if isinstance(exc, StaleRecordError): + return CLIError( + f"Record version mismatch{context}; it may have been updated or deleted " + "by another process. Reload the record and retry." + ) + if isinstance(exc, DeadlockError): + return CLIError(f"Deadlock detected{context}; retry the operation") + if isinstance(exc, LockError): + return CLIError(f"Record is locked{context}; retry after the lock clears") + if isinstance(exc, BulkValidationError): + details = getattr(exc, "errors", None) or [] + if details: + index, message = details[0] + return CLIError(f"Bulk operation failed at item {index + 1}: {message}") + return CLIError(str(exc)) + if isinstance(exc, BulkStateError): + return CLIError(f"Bulk operation cannot run{context}: {exc}") + if isinstance(exc, IntegrityError): + return CLIError(f"Integrity constraint failed{context}: {exc}") + if isinstance(exc, TypeConversionError): + return CLIError(f"Invalid value{context}: {exc}") + if isinstance(exc, DBValidationError): + return CLIError(f"Invalid data for {model_name}{context}: {exc}") + if isinstance(exc, BackendConnectionError): + return CLIError(f"Database connection failed{context}: {exc}") + if isinstance(exc, OperationalError): + return CLIError(f"Database operation failed{context}: {exc}") + if isinstance(exc, QueryError): + return CLIError(f"Query failed{context}: {exc}") + if isinstance(exc, DatabaseError): + return CLIError(f"Database error{context}: {exc}") + return None + + +def operation_label(args) -> str: + """Build a human-readable command label from parsed arguments.""" + section = getattr(args, "section", None) + if section == "dml": + command = getattr(args, "bulk_command", None) + if getattr(args, "command", None) == "bulk" and command: + return f"dml bulk {command}" + if getattr(args, "command", None): + return f"dml {args.command}" + if section == "dql" and getattr(args, "command", None): + return f"dql {args.command}" + if section == "set" and getattr(args, "set_operation", None): + return f"set {args.set_operation}" + if section == "cte": + return "cte run" + return section or "command" diff --git a/src/rhosocial/activerecord/cli/errors.py b/src/rhosocial/activerecord/cli/errors.py new file mode 100644 index 00000000..b86c00de --- /dev/null +++ b/src/rhosocial/activerecord/cli/errors.py @@ -0,0 +1,123 @@ +# src/rhosocial/activerecord/cli/errors.py +"""Exit codes and user-facing errors for the ActiveRecord model CLI.""" + +import logging +import sys +from typing import Optional + +logger = logging.getLogger("rhosocial.activerecord.cli") + +#: Successful command. +EXIT_OK = 0 + +#: Model loading, connection, validation, or database failure. +EXIT_ERROR = 1 + +#: argparse owns this code; it is documented here for callers reading the list. +EXIT_USAGE = 2 + +#: A record addressed by primary key does not exist. +EXIT_NOT_FOUND = 3 + +#: The selected backend package is not installed or is unknown. +EXIT_BACKEND_UNAVAILABLE = 4 + +#: A destructive command needs --yes in a non-interactive context. +EXIT_CONFIRMATION_REQUIRED = 5 + +#: A DML command was attempted on a model that declares itself read-only. +EXIT_READ_ONLY = 6 + +#: A downstream pipe closed before the CLI finished writing. +EXIT_BROKEN_PIPE = 141 + + +class CLIError(Exception): + """A recoverable CLI failure with its process exit code. + + Args: + message: Human-readable explanation, printed to stderr. + exit_code: The process exit code that represents this failure. + hint: Optional follow-up line, such as an install command. + """ + + #: Whether the message describes a failure. ``CancelledError`` is not one. + is_error = True + + def __init__(self, message: str, exit_code: int = EXIT_ERROR, hint: Optional[str] = None): + super().__init__(message) + self.message = message + self.exit_code = exit_code + self.hint = hint + + +class CliNotFoundError(CLIError): + """The record addressed by the command does not exist. + + This is the CLI-layer error and always carries exit code 3. The model layer + raises ``backend.errors.RecordNotFound`` instead; command handlers translate + it with ``raise ... from exc`` so the original exception stays in the chain. + """ + + def __init__(self, message: str): + super().__init__(message, exit_code=EXIT_NOT_FOUND) + + +#: Backwards-compatible alias; the class is CLI-specific, so prefer the new name. +NotFoundError = CliNotFoundError + + +class CancelledError(CLIError): + """The user declined a destructive action. + + Cancelling is not a failure: the process exits successfully and the message + is printed without the ``Error:`` prefix. + """ + + is_error = False + + def __init__(self, message: str = "Operation cancelled."): + super().__init__(message, exit_code=EXIT_OK) + + +class BackendUnavailableError(CLIError): + """The backend named by the user cannot be loaded.""" + + def __init__(self, message: str, hint: Optional[str] = None): + super().__init__(message, exit_code=EXIT_BACKEND_UNAVAILABLE, hint=hint) + + +class ConfirmationRequiredError(CLIError): + """A destructive command needs --yes because stdin/stdout is not a TTY.""" + + def __init__(self, message: str): + super().__init__(message, exit_code=EXIT_CONFIRMATION_REQUIRED) + + +class ReadOnlyModelError(CLIError): + """DML was refused because the model declares itself read-only.""" + + def __init__(self, model_name: str, operation: str): + super().__init__( + f"{model_name} is read-only (__read_only__ = True); " + f"'{operation}' is not allowed.", + exit_code=EXIT_READ_ONLY, + ) + + +def report_error(error: CLIError) -> None: + """Write an error, and an optional hint, to stderr.""" + prefix = "Error: " if error.is_error else "" + print(f"{prefix}{error.message}", file=sys.stderr) + if error.hint: + print(error.hint, file=sys.stderr) + + +def report_unexpected(error: Exception) -> None: + """Write an unexpected failure to stderr without polluting stdout. + + The message names the exception type so a report can be matched to a bug; + the traceback goes to the debug log instead of the terminal. + """ + print(f"Unexpected error ({type(error).__name__}): {error}", file=sys.stderr) + logger.debug("Unexpected CLI failure", exc_info=True) diff --git a/src/rhosocial/activerecord/cli/input.py b/src/rhosocial/activerecord/cli/input.py new file mode 100644 index 00000000..b2faffbc --- /dev/null +++ b/src/rhosocial/activerecord/cli/input.py @@ -0,0 +1,193 @@ +# src/rhosocial/activerecord/cli/input.py +"""Input parsing for the ActiveRecord model CLI. + +The CLI accepts data as inline JSON, a file, stdin, or JSONL. This module keeps +the parsing in one place so DML commands can share it without duplicating the +"object vs. array vs. JSONL" rules. +""" + +import json +import sys +from typing import Any, Dict, List, Optional + +from pydantic import TypeAdapter, ValidationError + +from .errors import CLIError + +#: Default label used by ``--data`` parsing helpers. +DEFAULT_DATA_LABEL = "--data" + + +def _read_text(argument: str) -> str: + """Read ``--data`` as inline text, ``@FILE``, ``@-``, or ``-``.""" + if argument == "@-" or argument == "-": + return sys.stdin.read() + if argument.startswith("@"): + filename = argument[1:] + try: + with open(filename, encoding="utf-8") as handle: + return handle.read() + except OSError as exc: + raise CLIError(f'Cannot read data file "{filename}": {exc}') from None + return argument + + +def _json_error(text: str, exc: Exception, label: str = DEFAULT_DATA_LABEL) -> CLIError: + line = getattr(exc, "lineno", None) + column = getattr(exc, "colno", None) + location = f" at line {line}, column {column}" if line and column else "" + return CLIError(f"{label} is not valid JSON{location}: {exc}") + + +def read_object(argument: str, label: str = DEFAULT_DATA_LABEL) -> Dict[str, Any]: + """Read a single JSON object from inline, file, or stdin.""" + text = _read_text(argument) + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + raise _json_error(text, exc, label) from None + if not isinstance(data, dict): + raise CLIError(f"{label} must be a JSON object") + return data + + +def _parse_jsonl(text: str, label: str) -> List[Any]: + values = [] + for number, line in enumerate(text.splitlines(), 1): + if not line.strip(): + continue + try: + values.append(json.loads(line)) + except json.JSONDecodeError as exc: + raise CLIError( + f"{label} line {number} is not valid JSON: {exc}" + ) from None + return values + + +def read_records( + argument: str, + input_format: Optional[str] = None, + label: str = DEFAULT_DATA_LABEL, +) -> List[Dict[str, Any]]: + """Read one or more JSON objects from inline, file, stdin, JSON array, or JSONL.""" + text = _read_text(argument) + stripped = text.lstrip() + if input_format == "jsonl": + values = _parse_jsonl(text, label) + elif input_format == "json": + try: + values = json.loads(text) + except json.JSONDecodeError as exc: + raise _json_error(text, exc, label) from None + if isinstance(values, dict): + values = [values] + if not isinstance(values, list): + raise CLIError(f"{label} must be a JSON object or array") + elif stripped.startswith("["): + try: + values = json.loads(text) + except json.JSONDecodeError as exc: + raise _json_error(text, exc, label) from None + elif stripped.startswith("{"): + try: + parsed = json.loads(text) + except json.JSONDecodeError: + # A multi-line stream of objects (JSONL) commonly starts with ``{``. + values = _parse_jsonl(text, label) + else: + values = [parsed] + else: + values = _parse_jsonl(text, label) + + if not isinstance(values, list): + values = [values] + for index, value in enumerate(values): + if not isinstance(value, dict): + raise CLIError(f"{label} item {index + 1} must be a JSON object") + if not values: + raise CLIError(f"{label} contains no records") + return values + + +def read_values( + argument: str, + input_format: Optional[str] = None, + label: str = DEFAULT_DATA_LABEL, +) -> List[Any]: + """Read values for bulk delete: scalars, arrays, objects, or JSONL.""" + text = _read_text(argument) + stripped = text.lstrip() + if input_format == "jsonl": + values = _parse_jsonl(text, label) + elif input_format == "json": + try: + values = json.loads(text) + except json.JSONDecodeError as exc: + raise _json_error(text, exc, label) from None + elif stripped.startswith("["): + try: + values = json.loads(text) + except json.JSONDecodeError as exc: + raise _json_error(text, exc, label) from None + elif stripped.startswith("{"): + try: + values = json.loads(text) + except json.JSONDecodeError: + values = _parse_jsonl(text, label) + else: + values = _parse_jsonl(text, label) + + if not isinstance(values, list): + values = [values] + if not values: + raise CLIError(f"{label} contains no values") + return values + + +def check_fields(model, data: Dict[str, Any]) -> None: + """Reject unknown model fields before constructing a record.""" + unknown = [key for key in data if key not in model.model_fields] + if unknown: + raise CLIError( + f"Unknown field(s) for {model.__name__}: {', '.join(unknown)}. " + f"Available fields: {', '.join(model.model_fields)}" + ) + + +def field_adapter(model, field_name: str): + """Return the Pydantic adapter for one model field.""" + return TypeAdapter(model.model_fields[field_name].annotation) + + +def convert_field(model, field_name: str, value: Any) -> Any: + """Convert a decoded JSON value to the field's declared Python type.""" + try: + return field_adapter(model, field_name).validate_python(value) + except ValidationError as exc: + raise CLIError(f"Invalid value for {field_name}: {exc}") from None + + +def coerce_filter_value(model, field_name: str, value: Any) -> Any: + """Best-effort conversion for ``--where`` values.""" + try: + return field_adapter(model, field_name).validate_python(value) + except ValidationError: + if isinstance(value, str): + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return value + try: + return field_adapter(model, field_name).validate_python(parsed) + except ValidationError: + return value + return value + + +def coerce_pk_value(model, field_name: str, value: Any) -> Any: + """Convert one primary-key component, with a precise CLI error.""" + try: + return field_adapter(model, field_name).validate_python(value) + except ValidationError as exc: + raise CLIError(f"Invalid primary key value for {field_name}: {exc}") from None diff --git a/src/rhosocial/activerecord/cli/interaction.py b/src/rhosocial/activerecord/cli/interaction.py new file mode 100644 index 00000000..648796e6 --- /dev/null +++ b/src/rhosocial/activerecord/cli/interaction.py @@ -0,0 +1,118 @@ +# src/rhosocial/activerecord/cli/interaction.py +"""TTY, pagination, confirmation, and read-only helpers.""" + +import sys +from typing import Optional + +from ..interface.update import IDeleteBehavior +from .args import DEFAULT_LIST_LIMIT +from .errors import ( + CancelledError, + CLIError, + ConfirmationRequiredError, + ReadOnlyModelError, +) + + +def is_tty() -> bool: + """Whether stdout is an interactive terminal.""" + return bool(getattr(sys.stdout, "isatty", lambda: False)()) + + +def resolve_output(args) -> str: + """Return the explicit output format, or a TTY-aware default.""" + output = getattr(args, "output", None) + if output: + return output + return "table" if is_tty() else "json" + + +def _has_limit(query) -> bool: + clause = getattr(query, "limit_offset_clause", None) + return clause is not None and getattr(clause, "limit", None) is not None + + +def apply_pagination(query, args, *, default: bool = True, label: str = "result"): + """Apply explicit or default pagination to a query. + + Returns the query. When a default limit is injected, a one-line note is + written to stderr unless ``--quiet`` is set. + """ + limit = getattr(args, "limit", None) + offset = getattr(args, "offset", None) + no_limit = getattr(args, "no_limit", False) + page = getattr(args, "page", None) + page_size = getattr(args, "page_size", None) + + if limit is not None and limit < 0: + raise CLIError("--limit must be greater than or equal to 0") + if offset is not None and offset < 0: + raise CLIError("--offset must be greater than or equal to 0") + + if page is not None or page_size is not None: + if page is None or page_size is None: + raise CLIError("--page and --page-size must be used together") + if limit is not None or offset is not None: + raise CLIError("Use --page/--page-size or --limit/--offset, not both") + if page < 1 or page_size < 1: + raise CLIError("--page and --page-size must be positive") + query = query.limit(page_size) + query = query.offset((page - 1) * page_size) + return query + + if limit is not None and limit > 0: + query = query.limit(limit) + if offset is not None and offset > 0: + query = query.offset(offset) + + if no_limit or limit is not None: + return query + if not default: + return query + if _has_limit(query): + return query + + query = query.limit(DEFAULT_LIST_LIMIT) + if not getattr(args, "quiet", False): + print( + f"Showing first {DEFAULT_LIST_LIMIT} {label} (default pagination); " + "use --limit N or --no-limit to change.", + file=sys.stderr, + ) + return query + + +def soft_delete_action(model) -> str: + """Return ``soft_delete`` or ``delete`` for this model.""" + if issubclass(model, IDeleteBehavior): + return "soft_delete" + return "delete" + + +def soft_delete_field(model) -> Optional[str]: + """Return the declared soft-delete field name, if any.""" + return getattr(model, "__deleted_at_field__", None) + + +def ensure_writable(model, operation: str) -> None: + """Refuse a DML operation when the model declares itself read-only. + + Reads the *value* returned by ``read_only()``; membership in + ``IReadOnlyBehavior`` only says a method exists, not that it is true. + """ + read_only = getattr(model, "read_only", None) + if callable(read_only) and read_only(): + raise ReadOnlyModelError(model.__name__, operation) + + +def confirm(prompt: str, args) -> None: + """Confirm a destructive action, or fail with the dedicated exit code.""" + if getattr(args, "yes", False): + return + if not is_tty() or not getattr(sys.stdin, "isatty", lambda: False)(): + raise ConfirmationRequiredError( + f"{prompt} Pass --yes to confirm in a non-interactive context." + ) + answer = input(f"{prompt} [y/N] ") + if answer.strip().lower() not in ("y", "yes"): + raise CancelledError() diff --git a/src/rhosocial/activerecord/cli/model_loader.py b/src/rhosocial/activerecord/cli/model_loader.py new file mode 100644 index 00000000..863fb137 --- /dev/null +++ b/src/rhosocial/activerecord/cli/model_loader.py @@ -0,0 +1,116 @@ +# src/rhosocial/activerecord/cli/model_loader.py +"""Load an ActiveRecord model named as ``module:Class``.""" + +import importlib +import os +import sys +from typing import List, Optional + +from ..interface import IActiveRecord, IAsyncActiveRecord +from .errors import CLIError + + +def prepare_sys_path(extra_paths: Optional[List[str]] = None) -> None: + """Put the caller's working directory and ``--path`` entries on ``sys.path``. + + A console script starts with the script's directory on the path, not the + directory the user invoked it from, so importing ``app.models`` needs this + adjustment before any module import happens. + + Args: + extra_paths: Additional directories from repeated ``--path`` options. + """ + directories = [os.getcwd()] + list(extra_paths or []) + for directory in reversed(directories): + resolved = os.path.abspath(directory) + if resolved not in sys.path: + sys.path.insert(0, resolved) + + +def split_model_path(model_path: str): + """Split ``module:Class`` into its two parts. + + Args: + model_path: A value such as ``app.models:User``. + + Returns: + Tuple of module path and class name. + + Raises: + CLIError: When the value lacks the colon separator. + """ + if ":" not in model_path: + raise CLIError( + f'Invalid model reference "{model_path}": expected the form module:Class, ' + "for example app.models:User" + ) + module_path, class_name = model_path.rsplit(":", 1) + if not module_path or not class_name: + raise CLIError( + f'Invalid model reference "{model_path}": both module and class must be present' + ) + return module_path, class_name + + +def load_model(model_path: str): + """Import and validate a model class. + + Args: + model_path: A value such as ``app.models:User``. + + Returns: + A :class:`ModelRef` describing the class and whether it is asynchronous. + + Raises: + CLIError: When the module or class cannot be loaded, or the class is + not an ActiveRecord model. + """ + module_path, class_name = split_model_path(model_path) + try: + module = importlib.import_module(module_path) + except ModuleNotFoundError as exc: + missing = getattr(exc, "name", None) or module_path + raise CLIError( + f'Cannot import model "{model_path}": module "{missing}" was not found. ' + "Check the module name, or add its directory with --path." + ) from None + except Exception as exc: # Import-time side effects can fail for many reasons. + raise CLIError(f'Cannot import module "{module_path}": {exc}') from None + + try: + model_class = getattr(module, class_name) + except AttributeError: + raise CLIError( + f'Cannot load model "{model_path}": module "{module_path}" has no class "{class_name}"' + ) from None + + if not isinstance(model_class, type): + raise CLIError(f'Cannot load model "{model_path}": {class_name} is not a class') + + if issubclass(model_class, IAsyncActiveRecord): + return ModelRef(model_class, is_async=True) + if issubclass(model_class, IActiveRecord): + return ModelRef(model_class, is_async=False) + + raise CLIError( + f'Cannot load model "{model_path}": {class_name} is neither an ' + "IActiveRecord nor an IAsyncActiveRecord subclass" + ) + + +class ModelRef: + """A loaded model class and the sync/async API it implements. + + Attributes: + model: The model class. + is_async: True when the class implements ``IAsyncActiveRecord``. + """ + + def __init__(self, model, is_async: bool): + self.model = model + self.is_async = is_async + + @property + def name(self): + """The model class name.""" + return self.model.__name__ diff --git a/src/rhosocial/activerecord/cli/output.py b/src/rhosocial/activerecord/cli/output.py new file mode 100644 index 00000000..efbff7f5 --- /dev/null +++ b/src/rhosocial/activerecord/cli/output.py @@ -0,0 +1,295 @@ +# src/rhosocial/activerecord/cli/output.py +"""Output rendering for the ActiveRecord model CLI. + +Machine formats (``json``, ``jsonl``, ``csv``, ``tsv``, ``value``) write only +data to stdout. Human ``table`` output uses Rich when available and falls back +to JSON. +""" + +import csv +import datetime +import decimal +import json +import sys +from typing import Any, Dict, Iterable, List, Optional + +from .errors import CLIError + +try: + import rich # noqa: F401 + + RICH_AVAILABLE = True +except ImportError: + RICH_AVAILABLE = False + + +def _available_fields(model, rows=None) -> List[str]: + if model is not None: + return list(model.model_fields.keys()) + if rows: + return list(rows[0].keys()) + return [] + + +def selected_fields(model, fields_argument: Optional[str], rows=None) -> List[str]: + """Return the field names to render, validating ``--fields``.""" + available = _available_fields(model, rows) + requested = ( + [part.strip() for part in fields_argument.split(",") if part.strip()] + if fields_argument + else [] + ) + if fields_argument and not requested: + raise CLIError("--fields must name at least one field") + if not available: + if requested: + # No rows means no column names to check against; trust the request. + return requested + if rows is not None: + raise CLIError( + "The query returned no rows, so output fields cannot be inferred. " + "Pass --fields explicitly." + ) + raise CLIError("Cannot determine output fields without a model or row data") + if not requested: + return available + unknown = [name for name in requested if name not in available] + if unknown: + owner = model.__name__ if model is not None else "row data" + raise CLIError( + f"Unknown field(s) for {owner}: {', '.join(unknown)}. " + f"Available fields: {', '.join(available)}" + ) + return requested + + +def record_to_dict(record, fields: List[str]) -> Dict[str, Any]: + """Serialize a model instance or dict row to a JSON-compatible dict.""" + if isinstance(record, dict): + return {field: record.get(field) for field in fields} + dumped = record.model_dump(mode="json") + return {field: dumped.get(field) for field in fields} + + +def _json_default(value): + if isinstance(value, (datetime.datetime, datetime.date, datetime.time)): + return value.isoformat() + if isinstance(value, decimal.Decimal): + return str(value) + return str(value) + + +def _write_json(data: Any) -> None: + sys.stdout.write( + json.dumps(data, indent=2, ensure_ascii=False, default=_json_default) + "\n" + ) + + +def _write_jsonl(data: Any) -> None: + sys.stdout.write( + json.dumps(data, ensure_ascii=False, default=_json_default) + "\n" + ) + + +def _format_cell(value: Any) -> str: + if value is None: + return "" + if isinstance(value, (datetime.datetime, datetime.date, datetime.time)): + return value.isoformat() + if isinstance(value, decimal.Decimal): + return str(value) + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False, default=_json_default) + return str(value) + + +def _scalar_text(value: Any) -> str: + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def _write_delimited(rows: Iterable[Dict[str, Any]], fields: List[str], delimiter: str, no_header): + writer = csv.writer(sys.stdout, delimiter=delimiter) + if not no_header: + writer.writerow(fields) + for row in rows: + writer.writerow([_format_cell(row.get(field)) for field in fields]) + + +def _write_payload_delimited(payload: Dict[str, Any], output_format: str, no_header: bool): + """Write one result object as a CSV/TSV header plus a single row.""" + delimiter = "," if output_format == "csv" else "\t" + fields = list(payload) + writer = csv.writer(sys.stdout, delimiter=delimiter) + if not no_header: + writer.writerow(fields) + writer.writerow([_format_cell(payload[field]) for field in fields]) + + +def _rich_modules(): + """Import the Rich pieces on demand, or return None when unavailable.""" + if not RICH_AVAILABLE: + return None + try: + from rich import box + from rich.console import Console + from rich.table import Table + + return box, Console, Table + except ImportError: + return None + + +def _box(box_module, rich_ascii: bool): + return box_module.ASCII if rich_ascii else box_module.ROUNDED + + +def render_list( + records, + model, + fields: List[str], + output_format: str, + rich_ascii: bool, + no_header: bool = False, +) -> None: + """Render a list of model instances or dict rows.""" + rows = [record_to_dict(record, fields) for record in records] + if output_format == "json": + _write_json(rows) + return + if output_format == "jsonl": + for row in rows: + _write_jsonl(row) + return + if output_format == "csv": + _write_delimited(rows, fields, ",", no_header) + return + if output_format == "tsv": + _write_delimited(rows, fields, "\t", no_header) + return + if output_format == "value": + raise CLIError("-o value is only valid for scalar results") + + rich_modules = _rich_modules() + if rich_modules is None: + _write_json(rows) + return + + box_module, Console, Table = rich_modules + console = Console() + table = Table(box=_box(box_module, rich_ascii), show_header=True, header_style="bold") + for field in fields: + table.add_column(field, overflow="fold") + for row in rows: + table.add_row(*[_format_cell(row.get(field)) for field in fields]) + console.print(table) + if not rows: + owner = model.__name__ if model is not None else "result" + console.print(f"No records in {owner}", style="dim") + + +def render_record( + record, + model, + fields: List[str], + output_format: str, + rich_ascii: bool, +) -> None: + """Render one model instance or dict row.""" + row = record_to_dict(record, fields) + if output_format == "json": + _write_json(row) + return + if output_format == "jsonl": + _write_jsonl(row) + return + if output_format == "value": + raise CLIError("-o value is only valid for scalar results") + if output_format in ("csv", "tsv"): + delimiter = "," if output_format == "csv" else "\t" + _write_delimited([row], fields, delimiter, False) + return + + rich_modules = _rich_modules() + if rich_modules is None: + _write_json(row) + return + box_module, Console, Table = rich_modules + console = Console() + table = Table(box=_box(box_module, rich_ascii), show_header=True, header_style="bold") + table.add_column("Field") + table.add_column("Value") + for field in fields: + table.add_row(field, _format_cell(row.get(field))) + console.print(table) + + +def render_scalar(value: Any, output_format: str) -> None: + """Render a scalar result (count, exists, non-grouped aggregate).""" + if output_format in ("json", "table"): + _write_json(value) + return + if output_format == "jsonl": + _write_jsonl(value) + return + if output_format == "value": + sys.stdout.write(_scalar_text(value) + "\n") + return + if output_format in ("csv", "tsv"): + sys.stdout.write(_format_cell(value) + "\n") + return + raise CLIError(f"Unsupported output format: {output_format}") + + +def render_affected( + action: str, + affected: int, + output_format: str, + no_header: bool = False, + **extra, +) -> None: + """Render the result of a bulk update/delete.""" + payload = {"action": action, "affected": affected} + payload.update(extra) + if output_format == "json": + _write_json(payload) + return + if output_format == "jsonl": + _write_jsonl(payload) + return + if output_format == "value": + sys.stdout.write(f"{affected}\n") + return + if output_format in ("csv", "tsv"): + _write_payload_delimited(payload, output_format, no_header) + return + _write_json(payload) + + +def render_delete( + pk: Any, + affected: int, + model, + output_format: str, + rich_ascii: bool, + action: str = "delete", + deleted_at_field: Optional[str] = None, + dry_run: bool = False, + no_header: bool = False, +) -> None: + """Render a single delete result or dry-run plan.""" + payload = {"action": action, "pk": pk, "affected": affected} + if deleted_at_field: + payload["deleted_at_field"] = deleted_at_field + if dry_run: + payload["dry_run"] = True + if output_format == "value": + sys.stdout.write(f"{affected}\n") + return + if output_format in ("csv", "tsv"): + _write_payload_delimited(payload, output_format, no_header) + return + _write_json(payload) diff --git a/src/rhosocial/activerecord/cli/runtime.py b/src/rhosocial/activerecord/cli/runtime.py new file mode 100644 index 00000000..383377e3 --- /dev/null +++ b/src/rhosocial/activerecord/cli/runtime.py @@ -0,0 +1,291 @@ +# src/rhosocial/activerecord/cli/runtime.py +"""Shared runtime helpers: model fields, query construction, and query factories.""" + +import asyncio +import inspect +import json +import sys +from typing import Any, Dict, List, Optional, Sequence, Tuple, Type + +from ..backend.expression import Column, ComparisonPredicate, Literal +from ..query import ( + ActiveQuery, + AsyncActiveQuery, + AsyncCTEQuery, + AsyncSetOperationQuery, + CTEQuery, + SetOperationQuery, +) +from .backends import import_symbol +from .errors import CLIError +from .input import coerce_filter_value, coerce_pk_value +from .output import selected_fields + +#: Default label used by query factory helpers. +DEFAULT_QUERY_LABEL = "query" + + +def run_coroutine(coro): + """Run one coroutine, using a selector loop on Windows. + + psycopg (and other async drivers) cannot use Windows' default + ProactorEventLoop. The CLI owns its loop, so it must choose a compatible + one instead of relying on the interpreter default. + """ + if sys.platform == "win32": + import selectors + + loop = asyncio.SelectorEventLoop(selectors.SelectSelector()) + try: + return loop.run_until_complete(coro) + finally: + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + return asyncio.run(coro) + + +def context_fields(model, args, rows=None) -> List[str]: + """Resolve ``--fields`` against model fields or dict-row keys.""" + fields_argument = getattr(args, "fields", None) + if model is None and rows is None: + if fields_argument: + raise CLIError("--fields requires a model or row data to resolve against") + return [] + return selected_fields(model, fields_argument, rows) + + +class RuntimeContext: + """Everything a command handler needs for one invocation.""" + + def __init__(self, args, model, is_async: bool): + self.args = args + self.model = model + self.is_async = is_async + self.fields = context_fields(model, args) + from .interaction import resolve_output + + self.output = resolve_output(args) + self.rich_ascii = bool(getattr(args, "rich_ascii", False)) + + +def parse_pk(model, args): + """Return a primary-key condition: scalar for one field, dict for several.""" + value = getattr(args, "pk_json", None) + plain = getattr(args, "pk", None) + if value is not None and plain is not None: + raise CLIError("Use --pk or --pk-json, not both") + if value is None: + value = plain + if value is None: + raise CLIError("A primary key is required: pass --pk or --pk-json") + + pk_fields = list(model.primary_key_fields()) + if not pk_fields: + raise CLIError( + f"{model.__name__} has no primary key; use --where with dql list instead" + ) + + parsed = None + if isinstance(value, str) and value.strip().startswith("{"): + try: + parsed = json.loads(value) + except json.JSONDecodeError as exc: + raise CLIError(f"--pk is not valid JSON: {exc}") from None + + if isinstance(parsed, dict): + keys = set(parsed) + if keys != set(pk_fields): + raise CLIError( + f"--pk keys must be exactly the primary key fields " + f"({', '.join(pk_fields)}); got {', '.join(sorted(keys))}" + ) + converted = { + field: coerce_pk_value(model, field, parsed[field]) for field in pk_fields + } + if len(pk_fields) == 1: + return converted[pk_fields[0]] + return converted + + if model.is_composite_pk(): + raise CLIError( + f"{model.__name__} has a composite primary key; pass --pk as a JSON object " + f"with keys {', '.join(pk_fields)}" + ) + return coerce_pk_value(model, pk_fields[0], value) + + +def parse_pk_mapping(model, mapping: Dict[str, Any], label: str = "record"): + """Extract and coerce a primary key from a bulk input object.""" + pk_fields = list(model.primary_key_fields()) + missing = [field for field in pk_fields if field not in mapping] + if missing: + raise CLIError(f"{label} is missing primary key field(s): {', '.join(missing)}") + converted = { + field: coerce_pk_value(model, field, mapping[field]) for field in pk_fields + } + if len(pk_fields) == 1: + return converted[pk_fields[0]] + return converted + + +def parse_where(model, clauses: Optional[Sequence[str]]): + """Turn ``field=value`` clauses into expression predicates.""" + predicates = [] + dialect = model.backend().dialect + for clause in clauses or []: + if "=" not in clause: + raise CLIError(f'Invalid --where "{clause}": expected field=value') + field_name, raw_value = clause.split("=", 1) + field_name = field_name.strip() + if field_name not in model.model_fields: + raise CLIError( + f'Unknown --where field "{field_name}". ' + f"Available fields: {', '.join(model.model_fields)}" + ) + value = coerce_filter_value(model, field_name, raw_value.strip()) + column = Column(dialect, model.column_name(field_name)) + predicates.append( + ComparisonPredicate(dialect, "=", column, Literal(dialect, value)) + ) + return predicates + + +def parse_order_by(model, clauses: Optional[Sequence[str]]): + """Turn ``field [asc|desc]`` clauses into order_by arguments.""" + clauses_out = [] + dialect = model.backend().dialect + for clause in clauses or []: + parts = clause.split() + if not parts: + raise CLIError("--order-by cannot be empty") + field_name = parts[0] + if field_name not in model.model_fields: + raise CLIError( + f'Unknown --order-by field "{field_name}". ' + f"Available fields: {', '.join(model.model_fields)}" + ) + direction = "ASC" + if len(parts) > 1: + if len(parts) > 2 or parts[1].lower() not in ("asc", "desc"): + raise CLIError( + f'Invalid --order-by "{clause}": expected field or field asc|desc' + ) + direction = parts[1].upper() + clauses_out.append((Column(dialect, model.column_name(field_name)), direction)) + return clauses_out + + +def parse_select(model, argument: Optional[str]): + """Turn ``--select a,b`` into model column expressions.""" + if not argument: + return None + names = [part.strip() for part in argument.split(",") if part.strip()] + if not names: + raise CLIError("--select must name at least one column") + unknown = [name for name in names if name not in model.model_fields] + if unknown: + raise CLIError( + f"Unknown --select field(s): {', '.join(unknown)}. " + f"Available fields: {', '.join(model.model_fields)}" + ) + dialect = model.backend().dialect + return [Column(dialect, model.column_name(name)) for name in names] + + +def base_query(model, args): + """Return the model's query, honoring soft-delete visibility flags.""" + include_deleted = getattr(args, "include_deleted", False) + only_deleted = getattr(args, "only_deleted", False) + if include_deleted and only_deleted: + raise CLIError("Use --include-deleted or --only-deleted, not both") + if only_deleted: + query_method = getattr(model, "query_only_deleted", None) + if not callable(query_method): + raise CLIError(f"{model.__name__} has no query_only_deleted() method") + return query_method() + if include_deleted: + query_method = getattr(model, "query_with_deleted", None) + if not callable(query_method): + raise CLIError(f"{model.__name__} has no query_with_deleted() method") + return query_method() + return model.query() + + +def apply_filters(query, model, args): + """Apply the simple ``--where`` / ``--order-by`` / ``--select`` options.""" + for predicate in parse_where(model, getattr(args, "where", None)): + query = query.where(predicate) + order_clauses = parse_order_by(model, getattr(args, "order_by", None)) + if order_clauses: + query = query.order_by(*order_clauses) + select_columns = parse_select(model, getattr(args, "select", None)) + if select_columns: + query = query.select(*select_columns) + return query + + +def resolve_query_reference(reference: str, label: str = DEFAULT_QUERY_LABEL) -> Any: + """Import and call a user query factory without arguments.""" + target = import_symbol(reference, label) + if not callable(target): + raise CLIError( + f'The query reference "{reference}" resolves to {type(target).__name__}; ' + "a callable returning a Query object is required." + ) + try: + return target() + except Exception as exc: + raise CLIError(f'Query factory "{reference}" failed: {exc}') from None + + +async def resolve_query_reference_async(reference: str, label: str = DEFAULT_QUERY_LABEL) -> Any: + """Like :func:`resolve_query_reference`, but awaits an async factory.""" + result = resolve_query_reference(reference, label) + if inspect.isawaitable(result): + result = await result + return result + + +def validate_query( + query: Any, + sync_types: Tuple[Type, ...], + async_types: Tuple[Type, ...], + *, + is_async: Optional[bool] = None, + label: str = DEFAULT_QUERY_LABEL, +) -> bool: + """Validate a user-supplied query and return whether it is asynchronous.""" + if is_async is True: + if not isinstance(query, async_types): + raise CLIError( + f"{label} returned {type(query).__name__}; expected one of " + f"{', '.join(cls.__name__ for cls in async_types)}" + ) + return True + if is_async is False: + if not isinstance(query, sync_types): + raise CLIError( + f"{label} returned {type(query).__name__}; expected one of " + f"{', '.join(cls.__name__ for cls in sync_types)}" + ) + return False + if isinstance(query, async_types): + return True + if isinstance(query, sync_types): + return False + expected = tuple(dict.fromkeys(sync_types + async_types)) + raise CLIError( + f"{label} returned {type(query).__name__}; expected one of " + f"{', '.join(cls.__name__ for cls in expected)}" + ) + + +#: Query classes accepted by ``dql run``. +ACTIVE_QUERY_TYPES = (ActiveQuery,) +ASYNC_ACTIVE_QUERY_TYPES = (AsyncActiveQuery,) +#: Query classes accepted by ``set run``. +SET_QUERY_TYPES = (SetOperationQuery,) +ASYNC_SET_QUERY_TYPES = (AsyncSetOperationQuery,) +#: Query classes accepted by ``cte run``. +CTE_QUERY_TYPES = (CTEQuery,) +ASYNC_CTE_QUERY_TYPES = (AsyncCTEQuery,) diff --git a/src/rhosocial/activerecord/field/version.py b/src/rhosocial/activerecord/field/version.py index 224d6fc6..0cded624 100644 --- a/src/rhosocial/activerecord/field/version.py +++ b/src/rhosocial/activerecord/field/version.py @@ -237,7 +237,8 @@ def _handle_version_after_update( Raises: StaleRecordError: If the optimistic-lock check fails because the - record was updated by another process. It is also a + stored version no longer matches; the row may have been + updated or deleted by another process. It is also a :class:`DatabaseError`, so existing handlers keep working. """ if result.affected_rows == 0: diff --git a/tests/providers/registry.py b/tests/providers/registry.py index a24ba33d..51f2aa26 100644 --- a/tests/providers/registry.py +++ b/tests/providers/registry.py @@ -26,6 +26,10 @@ """ from rhosocial.activerecord.testsuite.core.registry import ProviderRegistry +from rhosocial.activerecord.testsuite.feature.cli.providers import ( + GenericAsyncCLIProvider, + GenericSyncCLIProvider, +) from . import pooling # noqa: F401 (registers the pool reset handler on import) from .basic import BasicSyncProvider, BasicAsyncProvider from .events import EventsSyncProvider, EventsAsyncProvider @@ -82,6 +86,10 @@ provider_registry.register("feature.relation.IRelationSyncProvider", RelationSyncProvider) provider_registry.register("feature.relation.IRelationAsyncProvider", RelationAsyncProvider) +# Register CLI providers, assembled from the basic and mixins providers above. +provider_registry.register("feature.cli.ICLISyncProvider", GenericSyncCLIProvider) +provider_registry.register("feature.cli.ICLIAsyncProvider", GenericAsyncCLIProvider) + # Register benchmark providers. provider_registry.register("benchmark.crud.ICrudBenchmarkProvider", CrudBenchmarkProvider) provider_registry.register("benchmark.query.IQueryBenchmarkProvider", QueryBenchmarkProvider) diff --git a/tests/rhosocial/activerecord_test/feature/basic/find_one_or_create/test_find_one_or_create.py b/tests/rhosocial/activerecord_test/feature/basic/find_one_or_create/test_find_one_or_create.py index 29378a5c..4b65c57c 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/find_one_or_create/test_find_one_or_create.py +++ b/tests/rhosocial/activerecord_test/feature/basic/find_one_or_create/test_find_one_or_create.py @@ -1,10 +1,11 @@ # tests/rhosocial/activerecord_test/feature/basic/find_one_or_create/test_find_one_or_create.py """Unit tests for ``BaseActiveRecord.find_one_or_create`` (synchronous).""" +import logging from typing import ClassVar, Optional import pytest -from rhosocial.activerecord.backend.errors import IntegrityError +from rhosocial.activerecord.backend.errors import IntegrityError, OperationalError from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig @@ -148,6 +149,73 @@ def test_generated_primary_key_in_defaults_is_rejected(self, item_model): with pytest.raises(ValueError, match="generates its primary key"): item_model.find_one_or_create(code="a", defaults={"id": 1}) + def test_conflict_reread_failure_keeps_the_cause(self, item_model, monkeypatch): + original_lookup = item_model._find_one_by_lookup + reads = [] + + def failing_lookup(cls, lookup): + reads.append(dict(lookup)) + if len(reads) == 1: + return original_lookup(lookup) + raise OperationalError("reread connection lost") + + monkeypatch.setattr( + item_model, "_find_one_by_lookup", classmethod(failing_lookup) + ) + + def racing_save(self): + raise IntegrityError("simulated concurrent insert") + + monkeypatch.setattr(item_model, "save", racing_save) + + with pytest.raises(IntegrityError) as info: + item_model.find_one_or_create( + code="race", defaults={"alt_code": "race-alt"} + ) + + assert isinstance(info.value.__cause__, OperationalError) + assert "reread connection lost" in str(info.value.__cause__) + assert item_model.query().count() == 0 + + def test_savepoint_probe_failure_logs_error(self, item_model, monkeypatch): + + manager = item_model.backend().transaction_manager + records = [] + + def spy_log(cls, level, msg, *args, **kwargs): + records.append((level, msg)) + + monkeypatch.setattr(item_model, "log", classmethod(spy_log)) + + def broken_probe(): + raise RuntimeError("capability probe exploded") + + monkeypatch.setattr(manager, "supports_savepoint", broken_probe) + + row, created = item_model.find_one_or_create(code="a") + + assert created is True + assert row.code == "a" + error_records = [item for item in records if item[0] == logging.ERROR] + assert error_records, "a broken probe must be reported at ERROR level" + assert "savepoint" in error_records[0][1].lower() + + def test_savepoint_unsupported_does_not_log_error(self, item_model, monkeypatch): + + manager = item_model.backend().transaction_manager + records = [] + + def spy_log(cls, level, msg, *args, **kwargs): + records.append((level, msg)) + + monkeypatch.setattr(item_model, "log", classmethod(spy_log)) + monkeypatch.setattr(manager, "supports_savepoint", lambda: False) + + row, created = item_model.find_one_or_create(code="a") + + assert created is True + assert not [item for item in records if item[0] == logging.ERROR] + def test_conflict_rereads_the_winner_once(self, item_model, monkeypatch): calls = [] original_lookup = item_model._find_one_by_lookup @@ -271,3 +339,4 @@ def spy_rollback(name): assert rollbacks, "the failed insert must roll back to its savepoint" assert manager.is_active is True assert item_model.query().count() == 1, "the outer transaction is still usable" + diff --git a/tests/rhosocial/activerecord_test/feature/basic/find_one_or_create/test_find_one_or_create_async.py b/tests/rhosocial/activerecord_test/feature/basic/find_one_or_create/test_find_one_or_create_async.py index ab11c082..34a2f49d 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/find_one_or_create/test_find_one_or_create_async.py +++ b/tests/rhosocial/activerecord_test/feature/basic/find_one_or_create/test_find_one_or_create_async.py @@ -1,10 +1,11 @@ # tests/rhosocial/activerecord_test/feature/basic/find_one_or_create/test_find_one_or_create_async.py """Unit tests for ``AsyncBaseActiveRecord.find_one_or_create``.""" +import logging from typing import ClassVar, Optional import pytest -from rhosocial.activerecord.backend.errors import IntegrityError +from rhosocial.activerecord.backend.errors import IntegrityError, OperationalError from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal from rhosocial.activerecord.backend.impl.sqlite.backend.async_backend import AsyncSQLiteBackend from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig @@ -152,6 +153,81 @@ async def test_generated_primary_key_in_defaults_is_rejected( with pytest.raises(ValueError, match="generates its primary key"): await async_item_model.find_one_or_create(code="a", defaults={"id": 1}) + async def test_conflict_reread_failure_keeps_the_cause( + self, async_item_model, monkeypatch + ): + original_lookup = async_item_model._find_one_by_lookup + reads = [] + + async def failing_lookup(cls, lookup): + reads.append(dict(lookup)) + if len(reads) == 1: + return await original_lookup(lookup) + raise OperationalError("reread connection lost") + + monkeypatch.setattr( + async_item_model, "_find_one_by_lookup", classmethod(failing_lookup) + ) + + async def racing_save(self): + raise IntegrityError("simulated concurrent insert") + + monkeypatch.setattr(async_item_model, "save", racing_save) + + with pytest.raises(IntegrityError) as info: + await async_item_model.find_one_or_create( + code="race", defaults={"alt_code": "race-alt"} + ) + + assert isinstance(info.value.__cause__, OperationalError) + assert "reread connection lost" in str(info.value.__cause__) + assert await async_item_model.query().count() == 0 + + async def test_savepoint_probe_failure_logs_error( + self, async_item_model, monkeypatch + ): + manager = async_item_model.backend().transaction_manager + records = [] + + def spy_log(cls, level, msg, *args, **kwargs): + records.append((level, msg)) + + monkeypatch.setattr(async_item_model, "log", classmethod(spy_log)) + + async def broken_probe(): + raise RuntimeError("capability probe exploded") + + monkeypatch.setattr(manager, "supports_savepoint", broken_probe) + + row, created = await async_item_model.find_one_or_create(code="a") + + assert created is True + assert row.code == "a" + error_records = [item for item in records if item[0] == logging.ERROR] + assert error_records, "a broken probe must be reported at ERROR level" + assert "savepoint" in error_records[0][1].lower() + + async def test_savepoint_unsupported_does_not_log_error( + self, async_item_model, monkeypatch + ): + manager = async_item_model.backend().transaction_manager + records = [] + + def spy_log(cls, level, msg, *args, **kwargs): + records.append((level, msg)) + + monkeypatch.setattr(async_item_model, "log", classmethod(spy_log)) + + async def unsupported(): + return False + + monkeypatch.setattr(manager, "supports_savepoint", unsupported) + + row, created = await async_item_model.find_one_or_create(code="a") + + assert created is True + assert not [item for item in records if item[0] == logging.ERROR] + async def test_conflict_rereads_the_winner_once(self, async_item_model, monkeypatch): calls = [] original_lookup = async_item_model._find_one_by_lookup diff --git a/tests/rhosocial/activerecord_test/feature/cli/conftest.py b/tests/rhosocial/activerecord_test/feature/cli/conftest.py new file mode 100644 index 00000000..c17456de --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/cli/conftest.py @@ -0,0 +1,299 @@ +# tests/rhosocial/activerecord_test/feature/cli/conftest.py +"""Shared fixtures for the ActiveRecord CLI feature tests.""" + +import io +import sqlite3 +import textwrap +import uuid +from contextlib import redirect_stderr, redirect_stdout +from types import SimpleNamespace + +import pytest + +from rhosocial.activerecord.cli import main + +from logging_isolation import preserve_logging + +SQLITE_BACKEND = "rhosocial.activerecord.backend.impl.sqlite.backend:SQLiteBackend" +ASYNC_SQLITE_BACKEND = ( + "rhosocial.activerecord.backend.impl.sqlite.backend:AsyncSQLiteBackend" +) +SQLITE_CONFIG = ( + "rhosocial.activerecord.backend.impl.sqlite.config:SQLiteConnectionConfig" +) + + +def connection_args(database, backend=SQLITE_BACKEND): + """Build the explicit backend, config class, and database arguments.""" + return [ + "--backend-class", + backend, + "--config-class", + SQLITE_CONFIG, + "--config", + f"database={database}", + ] + + +def run_cli(argv, capture=True): + """Run the CLI in-process and return (exit_code, stdout, stderr).""" + with preserve_logging(): + if not capture: + try: + return main(argv), "", "" + except SystemExit as exc: + return (exc.code if exc.code is not None else 0), "", "" + out, err = io.StringIO(), io.StringIO() + code = 0 + with redirect_stdout(out), redirect_stderr(err): + try: + code = main(argv) + except SystemExit as exc: + code = exc.code if exc.code is not None else 0 + return code, out.getvalue(), err.getvalue() + + +def write_module(path, module_name, body): + (path / f"{module_name}.py").write_text(textwrap.dedent(body), encoding="utf-8") + + +def create_users_table(database, rows=None): + if rows is None: + rows = ( + (1, "Alice", "alice@example.com", 30), + (2, "Bob", "bob@example.com", 25), + (3, "Alice", "alice2@example.com", 40), + (4, "Dan", "dan@example.com", 22), + ) + conn = sqlite3.connect(str(database)) + conn.execute( + "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, " + "email TEXT, age INTEGER DEFAULT 0, deleted_at TEXT)" + ) + conn.executemany( + "INSERT INTO users (id, name, email, age) VALUES (?, ?, ?, ?)", rows + ) + conn.commit() + conn.close() + + +def create_order_items_table(database): + conn = sqlite3.connect(str(database)) + conn.execute( + "CREATE TABLE order_items (order_id INTEGER, product_id INTEGER, " + "note TEXT DEFAULT '', PRIMARY KEY (order_id, product_id))" + ) + conn.executemany( + "INSERT INTO order_items (order_id, product_id, note) VALUES (?, ?, ?)", + [(1, 101, "first"), (1, 102, "second"), (2, 101, "third")], + ) + conn.commit() + conn.close() + + +MODEL_BODY = """ +from datetime import datetime +from typing import ClassVar, Optional + +from pydantic import Field, field_validator, model_validator + +from rhosocial.activerecord.base.field_proxy import FieldProxy +from rhosocial.activerecord.field import ( + AsyncSoftDeleteMixin, + ReadOnlyMixin, + SoftDeleteMixin, +) +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord +from rhosocial.activerecord.query import AsyncCTEQuery, CTEQuery + + +class User(ActiveRecord): + __table_name__ = "users" + c: ClassVar[FieldProxy] = FieldProxy() + id: int + name: str + email: Optional[str] = None + age: int = 0 + + +class AsyncUser(AsyncActiveRecord): + __table_name__ = "users" + c: ClassVar[FieldProxy] = FieldProxy() + id: int + name: str + email: Optional[str] = None + age: int = 0 + + +class ReadOnlyUser(ActiveRecord, ReadOnlyMixin): + __table_name__ = "users" + c: ClassVar[FieldProxy] = FieldProxy() + id: int + name: str + email: Optional[str] = None + age: int = 0 + + +class AsyncReadOnlyUser(AsyncActiveRecord, ReadOnlyMixin): + __table_name__ = "users" + c: ClassVar[FieldProxy] = FieldProxy() + id: int + name: str + email: Optional[str] = None + age: int = 0 + + +class SoftUser(SoftDeleteMixin, ActiveRecord): + __table_name__ = "users" + c: ClassVar[FieldProxy] = FieldProxy() + id: int + name: str + email: Optional[str] = None + age: int = 0 + deleted_at: Optional[datetime] = None + + +class AsyncSoftUser(AsyncSoftDeleteMixin, AsyncActiveRecord): + __table_name__ = "users" + c: ClassVar[FieldProxy] = FieldProxy() + id: int + name: str + email: Optional[str] = None + age: int = 0 + deleted_at: Optional[datetime] = None + + +class ValidatedUser(ActiveRecord): + __table_name__ = "users" + c: ClassVar[FieldProxy] = FieldProxy() + id: Optional[int] = None + name: str = Field(min_length=2) + email: Optional[str] = None + age: int = Field(default=0, ge=0, le=100) + + @field_validator("name") + @classmethod + def reject_admin_name(cls, value): + if value.lower() == "admin": + raise ValueError("name must not be 'admin'") + return value + + @model_validator(mode="after") + def check_senior_age(self): + if self.name == "senior" and self.age < 60: + raise ValueError("senior users need age >= 60") + return self + + +class AsyncValidatedUser(AsyncActiveRecord): + __table_name__ = "users" + c: ClassVar[FieldProxy] = FieldProxy() + id: Optional[int] = None + name: str = Field(min_length=2) + email: Optional[str] = None + age: int = Field(default=0, ge=0, le=100) + + @field_validator("name") + @classmethod + def reject_admin_name(cls, value): + if value.lower() == "admin": + raise ValueError("name must not be 'admin'") + return value + + @model_validator(mode="after") + def check_senior_age(self): + if self.name == "senior" and self.age < 60: + raise ValueError("senior users need age >= 60") + return self + + +class KeylessUser(ActiveRecord): + __table_name__ = "users" + __primary_key__ = None + c: ClassVar[FieldProxy] = FieldProxy() + id: Optional[int] = None + name: str + email: Optional[str] = None + age: int = 0 + + +class OrderItem(ActiveRecord): + __table_name__ = "order_items" + __primary_key__ = ("order_id", "product_id") + order_id: int + product_id: int + note: str = "" + + +class NotAModel: + pass + + +def active_users(): + return User.query().where(User.c.age >= 30) + + +def user_union(): + return User.query().where(User.c.age == 30).union( + User.query().where(User.c.age == 40) + ) + + +def user_cte(): + cte = CTEQuery(User.backend()) + cte.with_cte("older_users", User.query().where(User.c.age >= 30)) + return cte.from_cte("older_users") + + +def async_user_union(): + return AsyncUser.query().where(AsyncUser.c.age == 30).union( + AsyncUser.query().where(AsyncUser.c.age == 40) + ) + + +def async_user_cte(): + cte = AsyncCTEQuery(AsyncUser.backend()) + cte.with_cte("older_users", AsyncUser.query().where(AsyncUser.c.age >= 30)) + return cte.from_cte("older_users") + + +def broken_connection(): + raise RuntimeError("connection resolver exploded") +""" + + +@pytest.fixture +def cli_env(tmp_path): + module_name = "cli_models_" + uuid.uuid4().hex + database = tmp_path / "app.db" + create_users_table(database) + create_order_items_table(database) + write_module(tmp_path, module_name, MODEL_BODY) + base = ["--path", str(tmp_path), *connection_args(database)] + async_base = [ + "--path", + str(tmp_path), + *connection_args(database, backend=ASYNC_SQLITE_BACKEND), + ] + return SimpleNamespace( + tmp_path=tmp_path, + database=database, + module_name=module_name, + user_model=f"{module_name}:User", + async_model=f"{module_name}:AsyncUser", + read_only_model=f"{module_name}:ReadOnlyUser", + async_read_only_model=f"{module_name}:AsyncReadOnlyUser", + soft_model=f"{module_name}:SoftUser", + async_soft_model=f"{module_name}:AsyncSoftUser", + validated_model=f"{module_name}:ValidatedUser", + async_validated_model=f"{module_name}:AsyncValidatedUser", + keyless_model=f"{module_name}:KeylessUser", + order_model=f"{module_name}:OrderItem", + active_users=f"{module_name}:active_users", + user_union=f"{module_name}:user_union", + user_cte=f"{module_name}:user_cte", + async_user_union=f"{module_name}:async_user_union", + async_user_cte=f"{module_name}:async_user_cte", + base=base, + async_base=async_base, + ) diff --git a/tests/rhosocial/activerecord_test/feature/cli/logging_isolation.py b/tests/rhosocial/activerecord_test/feature/cli/logging_isolation.py new file mode 100644 index 00000000..5542927d --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/cli/logging_isolation.py @@ -0,0 +1,40 @@ +# tests/rhosocial/activerecord_test/feature/cli/logging_isolation.py +"""Restore framework logging state after an in-process CLI invocation. + +``cli.main()`` is a process entry point: it configures framework loggers for +the command that is about to run and never restores them, because the process +is expected to exit. In the test suite many CLI runs share one pytest process, +so an unguarded call leaks a WARNING level into later tests, including the +execution-logging contract tests. This context manager snapshots and restores +the global default config and every existing framework logger level. +""" + +import logging +from contextlib import contextmanager + +from rhosocial.activerecord.logging.defaults import get_default_logging_config + +_PREFIX = "rhosocial.activerecord" + + +def _framework_loggers(): + return [ + (name, logger) + for name, logger in logging.Logger.manager.loggerDict.items() + if isinstance(logger, logging.Logger) + and (name == _PREFIX or name.startswith(_PREFIX + ".")) + ] + + +@contextmanager +def preserve_logging(): + """Restore default logging config and framework logger levels afterwards.""" + config = get_default_logging_config() + saved = (config.default_level, config.propagate, config.auto_setup) + levels = [(logger, logger.level) for _, logger in _framework_loggers()] + try: + yield + finally: + config.default_level, config.propagate, config.auto_setup = saved + for logger, level in levels: + logger.setLevel(level) diff --git a/tests/rhosocial/activerecord_test/feature/cli/test_cli_bulk.py b/tests/rhosocial/activerecord_test/feature/cli/test_cli_bulk.py new file mode 100644 index 00000000..7422cdeb --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/cli/test_cli_bulk.py @@ -0,0 +1,211 @@ +# tests/rhosocial/activerecord_test/feature/cli/test_cli_bulk.py +"""CLI bulk DML tests.""" + +import io +import json + +from conftest import run_cli + + +class TestBulkCreate: + def test_bulk_create_json_array(self, cli_env): + code, out, _ = run_cli( + [ + "dml", + "bulk", + "create", + *cli_env.base, + "--model", + cli_env.user_model, + "--data", + '[{"id": 5, "name": "Eve", "age": 33},' + '{"id": 6, "name": "Frank", "age": 44}]', + "-o", + "json", + ] + ) + assert code == 0 + rows = json.loads(out) + assert [row["id"] for row in rows] == [5, 6] + + def test_bulk_create_jsonl_from_stdin(self, cli_env, monkeypatch): + monkeypatch.setattr( + "sys.stdin", + io.StringIO( + '{"id": 7, "name": "Gina", "age": 28}\n' + '{"id": 8, "name": "Hana", "age": 31}\n' + ), + ) + code, out, _ = run_cli( + [ + "dml", + "bulk", + "create", + *cli_env.base, + "--model", + cli_env.user_model, + "--data", + "@-", + "-o", + "jsonl", + ] + ) + assert code == 0 + lines = [json.loads(line) for line in out.splitlines() if line.strip()] + assert [line["name"] for line in lines] == ["Gina", "Hana"] + + +class TestBulkUpdate: + def test_bulk_update(self, cli_env): + code, out, _ = run_cli( + [ + "dml", + "bulk", + "update", + *cli_env.base, + "--model", + cli_env.user_model, + "--data", + '[{"id": 1, "age": 31}, {"id": 2, "age": 26}]', + "-o", + "json", + ] + ) + assert code == 0 + assert json.loads(out) == {"action": "update", "affected": 2} + + def test_bulk_update_missing_record_aborts(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "bulk", + "update", + *cli_env.base, + "--model", + cli_env.user_model, + "--data", + '[{"id": 1, "age": 31}, {"id": 999, "age": 26}]', + "-o", + "json", + ] + ) + assert code == 3 + assert out == "" + assert "999" in err + + def test_bulk_update_field_mismatch_returns_one(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "bulk", + "update", + *cli_env.base, + "--model", + cli_env.user_model, + "--data", + '[{"id": 1, "age": 31}, {"id": 2, "name": "Bobby"}]', + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "same fields" in err + + +class TestBulkDelete: + def test_dry_run(self, cli_env): + code, out, _ = run_cli( + [ + "dml", + "bulk", + "delete", + *cli_env.base, + "--model", + cli_env.user_model, + "--data", + "[1, 2]", + "--dry-run", + "-o", + "json", + ] + ) + assert code == 0 + assert json.loads(out) == {"action": "delete", "affected": 2, "dry_run": True} + + def test_requires_yes_in_non_tty(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "bulk", + "delete", + *cli_env.base, + "--model", + cli_env.user_model, + "--data", + "[1, 2]", + "-o", + "json", + ] + ) + assert code == 5 + assert out == "" + assert "--yes" in err + + def test_delete(self, cli_env): + code, out, _ = run_cli( + [ + "dml", + "bulk", + "delete", + *cli_env.base, + "--model", + cli_env.user_model, + "--data", + "[1, 2]", + "--yes", + "-o", + "json", + ] + ) + assert code == 0 + assert json.loads(out) == {"action": "delete", "affected": 2} + + def test_soft_delete(self, cli_env): + code, out, _ = run_cli( + [ + "dml", + "bulk", + "delete", + *cli_env.base, + "--model", + cli_env.soft_model, + "--data", + "[1, 2]", + "--yes", + "-o", + "json", + ] + ) + assert code == 0 + assert json.loads(out)["action"] == "soft_delete" + + def test_read_only_rejected(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "bulk", + "delete", + *cli_env.base, + "--model", + cli_env.read_only_model, + "--data", + "[1]", + "--yes", + "-o", + "json", + ] + ) + assert code == 6 + assert out == "" + assert "read-only" in err diff --git a/tests/rhosocial/activerecord_test/feature/cli/test_cli_commands.py b/tests/rhosocial/activerecord_test/feature/cli/test_cli_commands.py new file mode 100644 index 00000000..02624ead --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/cli/test_cli_commands.py @@ -0,0 +1,1144 @@ +# tests/rhosocial/activerecord_test/feature/cli/test_cli_commands.py +"""Black-box tests for the model DML/DQL command-line interface. + +The CLI is run in-process through ``main(argv)``. Machine formats are parsed +from stdout, while errors and logs are asserted on stderr. Each test gets a +fresh temporary module and SQLite database, so the model import cache and the +data never leak between cases. +""" + +import csv +import io +import json +import sqlite3 +import sys +import textwrap +import uuid +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from rhosocial.activerecord.cli import main +from rhosocial.activerecord.cli import output as cli_output +from rhosocial.activerecord.cli.args import OUTPUT_FORMATS +from logging_isolation import preserve_logging + +SQLITE_BACKEND = "rhosocial.activerecord.backend.impl.sqlite.backend:SQLiteBackend" +ASYNC_SQLITE_BACKEND = ( + "rhosocial.activerecord.backend.impl.sqlite.backend:AsyncSQLiteBackend" +) +SQLITE_CONFIG = ( + "rhosocial.activerecord.backend.impl.sqlite.config:SQLiteConnectionConfig" +) + + +def connection_args(database, backend=SQLITE_BACKEND): + """Build the explicit backend, config class, and database arguments.""" + return [ + "--backend-class", + backend, + "--config-class", + SQLITE_CONFIG, + "--config", + f"database={database}", + ] + + +def run_cli(argv): + """Run the CLI in-process and return (exit_code, stdout, stderr).""" + with preserve_logging(): + out, err = io.StringIO(), io.StringIO() + code = 0 + with redirect_stdout(out), redirect_stderr(err): + try: + code = main(argv) + except SystemExit as exc: + code = exc.code if exc.code is not None else 0 + return code, out.getvalue(), err.getvalue() + + +def write_module(path, module_name, body): + (path / f"{module_name}.py").write_text(textwrap.dedent(body), encoding="utf-8") + + +def create_users_table(database, rows=(("1", "Alice", "alice@example.com", "30"),)): + conn = sqlite3.connect(str(database)) + conn.execute( + "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, " + "email TEXT, age INTEGER DEFAULT 0)" + ) + conn.executemany( + "INSERT INTO users (id, name, email, age) VALUES (?, ?, ?, ?)", rows + ) + conn.commit() + conn.close() + + +def create_order_items_table(database): + conn = sqlite3.connect(str(database)) + conn.execute( + "CREATE TABLE order_items (order_id INTEGER, product_id INTEGER, " + "note TEXT DEFAULT '', PRIMARY KEY (order_id, product_id))" + ) + conn.executemany( + "INSERT INTO order_items (order_id, product_id, note) VALUES (?, ?, ?)", + [(1, 101, "first"), (1, 102, "second"), (2, 101, "third")], + ) + conn.commit() + conn.close() + + +MODEL_BODY = """ +from typing import Optional + +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord + + +class User(ActiveRecord): + __table_name__ = "users" + id: int + name: str + email: Optional[str] = None + age: int = 0 + + +class AsyncUser(AsyncActiveRecord): + __table_name__ = "users" + id: int + name: str + email: Optional[str] = None + age: int = 0 + + +class OrderItem(ActiveRecord): + __table_name__ = "order_items" + __primary_key__ = ("order_id", "product_id") + order_id: int + product_id: int + note: str = "" + + +class NotAModel: + pass +""" + + +@pytest.fixture +def cli_env(tmp_path): + module_name = "cli_models_" + uuid.uuid4().hex + database = tmp_path / "app.db" + rows = ( + (1, "Alice", "alice@example.com", 30), + (2, "Bob", "bob@example.com", 25), + (3, "Alice", "alice2@example.com", 40), + ) + create_users_table(database, rows=rows) + create_order_items_table(database) + write_module(tmp_path, module_name, MODEL_BODY) + base = ["--path", str(tmp_path), *connection_args(database)] + async_base = [ + "--path", + str(tmp_path), + *connection_args(database, backend=ASYNC_SQLITE_BACKEND), + ] + return SimpleNamespace( + tmp_path=tmp_path, + database=database, + module_name=module_name, + user_model=f"{module_name}:User", + async_model=f"{module_name}:AsyncUser", + order_model=f"{module_name}:OrderItem", + base=base, + async_base=async_base, + ) + + +@pytest.fixture +def configured_env(tmp_path): + """A module that calls configure() at import time.""" + module_name = "cli_configured_" + uuid.uuid4().hex + database = tmp_path / "configured.db" + create_users_table(database, rows=((7, "Configured", "cfg@example.com", 41),)) + body = f""" +from typing import Optional + +from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig + + +class User(ActiveRecord): + __table_name__ = "users" + id: int + name: str + email: Optional[str] = None + age: int = 0 + + +User.configure(SQLiteConnectionConfig(database=r"{database}"), SQLiteBackend) +""" + write_module(tmp_path, module_name, body) + return SimpleNamespace( + tmp_path=tmp_path, + database=database, + model=f"{module_name}:User", + ) + + +@pytest.fixture +def named_connection_env(tmp_path): + module_name = "cli_connections_" + uuid.uuid4().hex + database = tmp_path / "named.db" + create_users_table(database, rows=((8, "Named", "named@example.com", 33),)) + body = f""" +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig + + +def prod_db(): + return SQLiteConnectionConfig(database=r"{database}") +""" + write_module(tmp_path, module_name, body) + return SimpleNamespace( + tmp_path=tmp_path, + database=database, + connection=f"{module_name}:prod_db", + ) + + +class TestCommandSurface: + def test_console_script_is_declared(self): + pyproject = Path(__file__).resolve().parents[5] / "pyproject.toml" + text = pyproject.read_text(encoding="utf-8") + assert ( + 'rhosocial-activerecord = "rhosocial.activerecord.cli:main"' in text + ) + + def test_cli_extra_declares_rich(self): + pyproject = Path(__file__).resolve().parents[5] / "pyproject.toml" + text = pyproject.read_text(encoding="utf-8") + assert 'cli = ["rich>=13.0"]' in text + + def test_output_formats_are_pinned(self): + assert OUTPUT_FORMATS == ("table", "json", "jsonl", "csv", "tsv", "value") + + def test_root_help_lists_command_groups(self): + code, out, _ = run_cli(["--help"]) + assert code == 0 + assert "dml" in out + + def test_dml_help_lists_all_commands(self): + code, out, _ = run_cli(["dml", "--help"]) + assert code == 0 + for command in ("create", "update", "delete", "bulk"): + assert command in out + + def test_dql_help_lists_all_commands(self): + code, out, _ = run_cli(["dql", "--help"]) + assert code == 0 + for command in ("get", "list", "count", "exists", "aggregate", "run"): + assert command in out + + def test_missing_command_returns_one(self): + code, out, err = run_cli([]) + assert code == 1 + assert out == "" + assert "dml" in err + + def test_missing_subcommand_returns_one(self): + code, _, err = run_cli(["dml"]) + assert code == 1 + assert "DML" in err + + def test_unknown_command_is_usage_error(self): + code, out, err = run_cli(["nonexistent"]) + assert code == 2 + assert "invalid choice" in err + + +class TestModelLoading: + def test_loads_model(self, cli_env): + code, out, _ = run_cli( + ["dql", "list", *cli_env.base, "--model", cli_env.user_model, "-o", "json"] + ) + assert code == 0 + assert json.loads(out)[0]["name"] == "Alice" + + def test_missing_module_returns_one(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "list", + "--path", + str(cli_env.tmp_path), + "--model", + f"no_such_module_{uuid.uuid4().hex}:User", + *connection_args(cli_env.database), + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "not found" in err + + def test_missing_class_returns_one(self, cli_env): + code, _, err = run_cli( + ["dql", "list", *cli_env.base, "--model", f"{cli_env.module_name}:Missing", "-o", "json"] + ) + assert code == 1 + assert "has no class" in err + + def test_non_model_class_returns_one(self, cli_env): + code, out, err = run_cli( + ["dql", "list", *cli_env.base, "--model", f"{cli_env.module_name}:NotAModel", "-o", "json"] + ) + assert code == 1 + assert out == "" + assert "neither an IActiveRecord nor an IAsyncActiveRecord" in err + + def test_model_without_colon_returns_one(self, cli_env): + code, _, err = run_cli( + ["dql", "list", *cli_env.base, "--model", f"{cli_env.module_name}.User", "-o", "json"] + ) + assert code == 1 + assert "module:Class" in err + + def test_missing_backend_and_config_returns_one(self, cli_env): + code, _, err = run_cli( + ["dql", "list", "--path", str(cli_env.tmp_path), "--model", cli_env.user_model, "-o", "json"] + ) + assert code == 1 + assert "no backend class" in err + + def test_path_option_makes_import_work(self, cli_env): + code, out, _ = run_cli( + ["dql", "get", *cli_env.base, "--model", cli_env.user_model, "--pk", "1", "-o", "json"] + ) + assert code == 0 + assert json.loads(out)["id"] == 1 + + def test_cli_connection_overrides_module_configuration(self, configured_env, tmp_path): + override_db = tmp_path / "override.db" + create_users_table(override_db, rows=((99, "Override", "o@example.com", 50),)) + code, out, _ = run_cli( + [ + "dql", + "list", + "--path", + str(configured_env.tmp_path), + "--model", + configured_env.model, + *connection_args(override_db), + "-o", + "json", + ] + ) + assert code == 0 + assert [row["id"] for row in json.loads(out)] == [99] + + def test_module_configuration_is_used_without_flags(self, configured_env): + code, out, _ = run_cli( + ["dql", "list", "--path", str(configured_env.tmp_path), "--model", configured_env.model, "-o", "json"] + ) + assert code == 0 + assert json.loads(out)[0]["id"] == 7 + + +class TestConnectionResolution: + def test_explicit_classes_connect(self, cli_env): + code, out, _ = run_cli( + ["dql", "list", *cli_env.base, "--model", cli_env.user_model, "-o", "json"] + ) + assert code == 0 + assert len(json.loads(out)) == 3 + + def test_dotted_class_references(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "list", + "--path", + str(cli_env.tmp_path), + "--model", + cli_env.user_model, + "--backend-class", + "rhosocial.activerecord.backend.impl.sqlite.backend.SQLiteBackend", + "--config-class", + "rhosocial.activerecord.backend.impl.sqlite.config.SQLiteConnectionConfig", + "--config", + f"database={cli_env.database}", + "-o", + "json", + ] + ) + assert code == 0 + assert len(json.loads(out)) == 3 + + def test_invalid_config_pair_returns_one(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "list", + *cli_env.base, + "--model", + cli_env.user_model, + "--config", + "not-a-pair", + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "expected KEY=VALUE" in err + + def test_named_connection_with_explicit_backend(self, cli_env, named_connection_env): + code, out, _ = run_cli( + [ + "dql", + "list", + "--path", + str(named_connection_env.tmp_path), + "--model", + cli_env.user_model, + "--backend-class", + SQLITE_BACKEND, + "--connection", + named_connection_env.connection, + "-o", + "json", + ] + ) + assert code == 0 + assert [row["id"] for row in json.loads(out)] == [8] + + def test_named_connection_uses_module_backend(self, configured_env, named_connection_env): + code, out, _ = run_cli( + [ + "dql", + "list", + "--path", + str(configured_env.tmp_path), + "--model", + configured_env.model, + "--connection", + named_connection_env.connection, + "-o", + "json", + ] + ) + assert code == 0 + assert [row["id"] for row in json.loads(out)] == [8] + + def test_named_connection_requires_backend_class(self, cli_env, named_connection_env): + code, _, err = run_cli( + [ + "dql", + "list", + "--path", + str(named_connection_env.tmp_path), + "--model", + cli_env.user_model, + "--connection", + named_connection_env.connection, + "-o", + "json", + ] + ) + assert code == 1 + assert "no backend class" in err + + def test_named_connection_conflicts_with_config_class( + self, configured_env, named_connection_env + ): + code, _, err = run_cli( + [ + "dql", + "list", + "--path", + str(named_connection_env.tmp_path), + "--model", + configured_env.model, + "--connection", + named_connection_env.connection, + "--config-class", + SQLITE_CONFIG, + "-o", + "json", + ] + ) + assert code == 1 + assert "not both" in err + + def test_missing_backend_module_returns_four(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "list", + "--path", + str(cli_env.tmp_path), + "--model", + cli_env.user_model, + "--backend-class", + f"no_such_backend_{uuid.uuid4().hex}:Backend", + "-o", + "json", + ] + ) + assert code == 4 + assert out == "" + assert "Cannot import the backend class" in err + + def test_non_backend_class_returns_one(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "list", + "--path", + str(cli_env.tmp_path), + "--model", + cli_env.user_model, + "--backend-class", + "collections:OrderedDict", + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "not a synchronous backend" in err + + def test_non_config_class_returns_one(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "list", + "--path", + str(cli_env.tmp_path), + "--model", + cli_env.user_model, + "--backend-class", + SQLITE_BACKEND, + "--config-class", + "collections:OrderedDict", + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "not a ConnectionConfig subclass" in err + + def test_backend_without_config_returns_one(self, cli_env): + code, _, err = run_cli( + [ + "dql", + "list", + "--path", + str(cli_env.tmp_path), + "--model", + cli_env.user_model, + "--backend-class", + SQLITE_BACKEND, + "-o", + "json", + ] + ) + assert code == 1 + assert "--config-class" in err + + def test_config_sets_config_field(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "list", + *cli_env.base, + "--model", + cli_env.user_model, + "--config", + "timeout=7", + "-o", + "json", + ] + ) + assert code == 0 + assert len(json.loads(out)) == 3 + + def test_unknown_config_key_goes_to_options(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "list", + *cli_env.base, + "--model", + cli_env.user_model, + "--config", + "custom_flag=on", + "-o", + "json", + ] + ) + assert code == 0 + assert len(json.loads(out)) == 3 + + def test_classvar_is_not_a_config_field(self, cli_env): + # DEFAULT_PRAGMAS is a ClassVar on SQLiteConnectionConfig. It must not be + # forwarded to the constructor as a keyword argument. + code, out, _ = run_cli( + [ + "dql", + "list", + *cli_env.base, + "--model", + cli_env.user_model, + "--config", + "DEFAULT_PRAGMAS=ignored", + "-o", + "json", + ] + ) + assert code == 0 + assert len(json.loads(out)) == 3 + + +class TestDmlDql: + def test_get_by_pk(self, cli_env): + code, out, _ = run_cli( + ["dql", "get", *cli_env.base, "--model", cli_env.user_model, "--pk", "1", "-o", "json"] + ) + assert code == 0 + assert json.loads(out)["name"] == "Alice" + + def test_get_missing_returns_three(self, cli_env): + code, out, _ = run_cli( + ["dql", "get", *cli_env.base, "--model", cli_env.user_model, "--pk", "999", "-o", "json"] + ) + assert code == 3 + assert out == "" + + def test_create(self, cli_env): + code, out, _ = run_cli( + [ + "dml", + "create", + *cli_env.base, + "--model", + cli_env.user_model, + "--data", + '{"id": 4, "name": "Carol", "age": 22}', + "-o", + "json", + ] + ) + assert code == 0 + assert json.loads(out)["id"] == 4 + + def test_create_invalid_json_returns_one(self, cli_env): + code, out, err = run_cli( + ["dml", "create", *cli_env.base, "--model", cli_env.user_model, "--data", "{", "-o", "json"] + ) + assert code == 1 + assert out == "" + assert "not valid JSON" in err + + def test_create_non_object_returns_one(self, cli_env): + code, _, err = run_cli( + ["dml", "create", *cli_env.base, "--model", cli_env.user_model, "--data", "[1, 2]", "-o", "json"] + ) + assert code == 1 + assert "JSON object" in err + + def test_update_only_given_fields(self, cli_env): + code, out, _ = run_cli( + [ + "dml", + "update", + *cli_env.base, + "--model", + cli_env.user_model, + "--pk", + "2", + "--data", + '{"name": "Bobby"}', + "-o", + "json", + ] + ) + assert code == 0 + data = json.loads(out) + assert data["name"] == "Bobby" + assert data["age"] == 25 + + def test_update_rejects_primary_key(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "update", + *cli_env.base, + "--model", + cli_env.user_model, + "--pk", + "2", + "--data", + '{"id": 3, "name": "Bobby"}', + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "primary key" in err + + def test_update_without_pk_returns_one(self, cli_env): + code, _, err = run_cli( + [ + "dml", + "update", + *cli_env.base, + "--model", + cli_env.user_model, + "--data", + '{"name": "Bobby"}', + "-o", + "json", + ] + ) + assert code == 1 + assert "--pk" in err + + def test_update_missing_record_returns_three(self, cli_env): + code, out, _ = run_cli( + [ + "dml", + "update", + *cli_env.base, + "--model", + cli_env.user_model, + "--pk", + "999", + "--data", + '{"name": "Nobody"}', + "-o", + "json", + ] + ) + assert code == 3 + assert out == "" + + def test_delete(self, cli_env): + code, out, _ = run_cli( + ["dml", "delete", *cli_env.base, "--model", cli_env.user_model, "--pk", "2", "--yes", "-o", "json"] + ) + assert code == 0 + assert json.loads(out) == {"action": "delete", "pk": 2, "affected": 1} + + def test_delete_missing_returns_three(self, cli_env): + code, out, _ = run_cli( + ["dml", "delete", *cli_env.base, "--model", cli_env.user_model, "--pk", "999", "-o", "json"] + ) + assert code == 3 + assert out == "" + + def test_list_where_order_limit_offset(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "list", + *cli_env.base, + "--model", + cli_env.user_model, + "--where", + "name=Alice", + "--order-by", + "id desc", + "--limit", + "1", + "--offset", + "1", + "-o", + "json", + ] + ) + assert code == 0 + rows = json.loads(out) + assert len(rows) == 1 + assert rows[0]["id"] == 1 + + def test_list_multiple_where_is_and(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "list", + *cli_env.base, + "--model", + cli_env.user_model, + "--where", + "name=Alice", + "--where", + "age=40", + "-o", + "json", + ] + ) + assert code == 0 + assert [row["id"] for row in json.loads(out)] == [3] + + def test_list_limit_zero_means_unlimited(self, cli_env): + code, out, _ = run_cli( + ["dql", "list", *cli_env.base, "--model", cli_env.user_model, "--limit", "0", "-o", "json"] + ) + assert code == 0 + assert len(json.loads(out)) == 3 + + def test_list_empty_returns_empty_array(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "list", + *cli_env.base, + "--model", + cli_env.user_model, + "--where", + "name=Missing", + "-o", + "json", + ] + ) + assert code == 0 + assert json.loads(out) == [] + + def test_data_from_file(self, cli_env): + data_file = cli_env.tmp_path / "user.json" + data_file.write_text('{"id": 4, "name": "FromFile"}', encoding="utf-8") + code, out, _ = run_cli( + [ + "dml", + "create", + *cli_env.base, + "--model", + cli_env.user_model, + "--data", + f"@{data_file}", + "-o", + "json", + ] + ) + assert code == 0 + assert json.loads(out)["name"] == "FromFile" + + def test_data_from_stdin(self, cli_env, monkeypatch): + monkeypatch.setattr(sys, "stdin", io.StringIO('{"id": 4, "name": "FromStdin"}')) + code, out, _ = run_cli( + [ + "dml", + "create", + *cli_env.base, + "--model", + cli_env.user_model, + "--data", + "@-", + "-o", + "json", + ] + ) + assert code == 0 + assert json.loads(out)["name"] == "FromStdin" + + def test_composite_pk_get(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "get", + *cli_env.base, + "--model", + cli_env.order_model, + "--pk", + '{"order_id": 1, "product_id": 101}', + "-o", + "json", + ] + ) + assert code == 0 + assert json.loads(out)["note"] == "first" + + def test_composite_pk_update(self, cli_env): + code, out, _ = run_cli( + [ + "dml", + "update", + *cli_env.base, + "--model", + cli_env.order_model, + "--pk", + '{"order_id": 1, "product_id": 101}', + "--data", + '{"note": "changed"}', + "-o", + "json", + ] + ) + assert code == 0 + assert json.loads(out)["note"] == "changed" + + def test_composite_pk_delete(self, cli_env): + code, out, _ = run_cli( + [ + "dml", + "delete", + *cli_env.base, + "--model", + cli_env.order_model, + "--pk", + '{"order_id": 1, "product_id": 101}', + "--yes", + "-o", + "json", + ] + ) + assert code == 0 + assert json.loads(out)["affected"] == 1 + + def test_composite_pk_scalar_returns_one(self, cli_env): + code, _, err = run_cli( + ["dql", "get", *cli_env.base, "--model", cli_env.order_model, "--pk", "1", "-o", "json"] + ) + assert code == 1 + assert "composite primary key" in err + + def test_composite_pk_wrong_keys_returns_one(self, cli_env): + code, _, err = run_cli( + [ + "dql", + "get", + *cli_env.base, + "--model", + cli_env.order_model, + "--pk", + '{"order_id": 1}', + "-o", + "json", + ] + ) + assert code == 1 + assert "keys must be exactly" in err + + +class TestOutput: + def test_json_list_is_array(self, cli_env): + _, out, _ = run_cli( + ["dql", "list", *cli_env.base, "--model", cli_env.user_model, "-o", "json"] + ) + assert isinstance(json.loads(out), list) + + def test_json_get_is_object(self, cli_env): + _, out, _ = run_cli( + ["dql", "get", *cli_env.base, "--model", cli_env.user_model, "--pk", "1", "-o", "json"] + ) + assert isinstance(json.loads(out), dict) + + def test_delete_summary_shape(self, cli_env): + _, out, _ = run_cli( + ["dml", "delete", *cli_env.base, "--model", cli_env.user_model, "--pk", "1", "--yes", "-o", "json"] + ) + assert json.loads(out) == {"action": "delete", "pk": 1, "affected": 1} + + def test_csv_is_readable(self, cli_env): + _, out, _ = run_cli( + ["dql", "list", *cli_env.base, "--model", cli_env.user_model, "-o", "csv"] + ) + rows = list(csv.reader(io.StringIO(out))) + assert rows[0] == ["id", "name", "email", "age"] + assert rows[1][0] == "1" + + def test_tsv_is_readable(self, cli_env): + _, out, _ = run_cli( + ["dql", "list", *cli_env.base, "--model", cli_env.user_model, "-o", "tsv"] + ) + rows = list(csv.reader(io.StringIO(out), delimiter="\t")) + assert rows[0] == ["id", "name", "email", "age"] + assert rows[1][0] == "1" + + def test_fields_limits_json(self, cli_env): + _, out, _ = run_cli( + [ + "dql", + "get", + *cli_env.base, + "--model", + cli_env.user_model, + "--pk", + "1", + "--fields", + "id,name", + "-o", + "json", + ] + ) + assert json.loads(out) == {"id": 1, "name": "Alice"} + + def test_fields_limits_csv_header(self, cli_env): + _, out, _ = run_cli( + [ + "dql", + "list", + *cli_env.base, + "--model", + cli_env.user_model, + "--fields", + "id,name", + "-o", + "csv", + ] + ) + assert list(csv.reader(io.StringIO(out)))[0] == ["id", "name"] + + def test_unknown_field_returns_one(self, cli_env): + code, _, err = run_cli( + [ + "dql", + "get", + *cli_env.base, + "--model", + cli_env.user_model, + "--pk", + "1", + "--fields", + "nope", + "-o", + "json", + ] + ) + assert code == 1 + assert "Unknown field" in err + + def test_table_falls_back_to_json_without_rich(self, cli_env, monkeypatch): + monkeypatch.setattr(cli_output, "RICH_AVAILABLE", False) + _, out, _ = run_cli( + ["dql", "get", *cli_env.base, "--model", cli_env.user_model, "--pk", "1", "-o", "table"] + ) + assert json.loads(out)["id"] == 1 + + def test_machine_output_stays_clean_with_debug_logs(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "get", + *cli_env.base, + "--model", + cli_env.user_model, + "--pk", + "1", + "--log-level", + "DEBUG", + "-o", + "json", + ] + ) + assert code == 0 + assert json.loads(out)["id"] == 1 + assert "DEBUG" not in out + + +class TestAsync: + def test_async_list(self, cli_env): + code, out, _ = run_cli( + ["dql", "list", *cli_env.async_base, "--model", cli_env.async_model, "-o", "json"] + ) + assert code == 0 + assert len(json.loads(out)) == 3 + + def test_async_get(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "get", + *cli_env.async_base, + "--model", + cli_env.async_model, + "--pk", + "1", + "-o", + "json", + ] + ) + assert code == 0 + assert json.loads(out)["name"] == "Alice" + + def test_async_create_update_delete(self, cli_env): + code, out, _ = run_cli( + [ + "dml", + "create", + *cli_env.async_base, + "--model", + cli_env.async_model, + "--data", + '{"id": 4, "name": "AsyncCarol"}', + "-o", + "json", + ] + ) + assert code == 0 + assert json.loads(out)["id"] == 4 + + code, out, _ = run_cli( + [ + "dml", + "update", + *cli_env.async_base, + "--model", + cli_env.async_model, + "--pk", + "4", + "--data", + '{"age": 23}', + "-o", + "json", + ] + ) + assert code == 0 + assert json.loads(out)["age"] == 23 + + code, out, _ = run_cli( + [ + "dml", + "delete", + *cli_env.async_base, + "--model", + cli_env.async_model, + "--pk", + "4", + "--yes", + "-o", + "json", + ] + ) + assert code == 0 + assert json.loads(out)["affected"] == 1 + + def test_async_not_found_returns_three(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "get", + *cli_env.async_base, + "--model", + cli_env.async_model, + "--pk", + "999", + "-o", + "json", + ] + ) + assert code == 3 + assert out == "" + + def test_async_model_rejects_sync_backend(self, cli_env): + code, out, err = run_cli( + ["dql", "list", *cli_env.base, "--model", cli_env.async_model, "-o", "json"] + ) + assert code == 1 + assert out == "" + assert "not an asynchronous backend" in err diff --git a/tests/rhosocial/activerecord_test/feature/cli/test_cli_dql.py b/tests/rhosocial/activerecord_test/feature/cli/test_cli_dql.py new file mode 100644 index 00000000..a8a079a1 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/cli/test_cli_dql.py @@ -0,0 +1,174 @@ +# tests/rhosocial/activerecord_test/feature/cli/test_cli_dql.py +"""CLI DQL tests: count, exists, aggregate, and user-supplied queries.""" + +import json + +import pytest + +from conftest import run_cli + + +class TestDql: + def test_count_value(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "count", + *cli_env.base, + "--model", + cli_env.user_model, + "-o", + "value", + ] + ) + assert code == 0 + assert out.strip() == "4" + + def test_exists_value(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "exists", + *cli_env.base, + "--model", + cli_env.user_model, + "--where", + "age=99", + "-o", + "value", + ] + ) + assert code == 0 + assert out.strip() == "false" + + def test_aggregate_scalar(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "aggregate", + *cli_env.base, + "--model", + cli_env.user_model, + "--fn", + "avg", + "--field", + "age", + "-o", + "json", + ] + ) + assert code == 0 + assert json.loads(out) == pytest.approx(29.25) + + def test_aggregate_group_by(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "aggregate", + *cli_env.base, + "--model", + cli_env.user_model, + "--fn", + "count", + "--field", + "*", + "--group-by", + "name", + "-o", + "json", + ] + ) + assert code == 0 + rows = json.loads(out) + names = {row["name"] for row in rows} + assert names == {"Alice", "Bob", "Dan"} + assert all(row["value"] >= 1 for row in rows) + + def test_run_query_factory(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "run", + *cli_env.base, + "--model", + cli_env.user_model, + "--query", + cli_env.active_users, + "-o", + "json", + ] + ) + assert code == 0 + assert "default pagination" in err + rows = json.loads(out) + assert all(row["age"] >= 30 for row in rows) + assert len(rows) == 2 + + def test_run_rejects_non_query(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "run", + *cli_env.base, + "--model", + cli_env.user_model, + "--query", + f"{cli_env.module_name}:NotAModel", + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "expected one of" in err + + def test_read_only_model_is_queryable(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "list", + *cli_env.base, + "--model", + cli_env.read_only_model, + "--limit", + "2", + "-o", + "json", + ] + ) + assert code == 0 + assert len(json.loads(out)) == 2 + + +class TestAsyncDql: + def test_async_list(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "list", + *cli_env.async_base, + "--model", + cli_env.async_model, + "--limit", + "2", + "-o", + "json", + ] + ) + assert code == 0 + assert len(json.loads(out)) == 2 + + def test_async_count(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "count", + *cli_env.async_base, + "--model", + cli_env.async_model, + "-o", + "value", + ] + ) + assert code == 0 + assert out.strip() == "4" diff --git a/tests/rhosocial/activerecord_test/feature/cli/test_cli_error_mapping.py b/tests/rhosocial/activerecord_test/feature/cli/test_cli_error_mapping.py new file mode 100644 index 00000000..9dceb9c3 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/cli/test_cli_error_mapping.py @@ -0,0 +1,165 @@ +# tests/rhosocial/activerecord_test/feature/cli/test_cli_error_mapping.py +"""CLI error mapping: model/backend exceptions to CLI errors and exit codes.""" + +from rhosocial.activerecord.backend.errors import ( + BulkStateError, + BulkValidationError, + ConnectionError, + IntegrityError, + ReadOnlyError, + RecordNotFound, + StaleRecordError, + UnaddressableRecordError, +) +from rhosocial.activerecord.cli import build_parser +from rhosocial.activerecord.cli.error_mapping import operation_label, translate_error +from rhosocial.activerecord.cli.errors import ( + EXIT_BACKEND_UNAVAILABLE, + EXIT_ERROR, + EXIT_NOT_FOUND, + EXIT_OK, + EXIT_READ_ONLY, + BackendUnavailableError, + CancelledError, + CLIError, + CliNotFoundError, + ReadOnlyModelError, + report_error, + report_unexpected, +) + + +class TestErrorMapping: + def test_cli_error_passes_through(self): + original = CLIError("bad input") + assert translate_error(original, operation="dml create") is original + + def test_record_not_found_becomes_cli_not_found(self): + translated = translate_error( + RecordNotFound("Record not found for User"), + operation="dql get", + model=None, + ) + assert isinstance(translated, CliNotFoundError) + assert translated.exit_code == EXIT_NOT_FOUND + assert translated.__cause__ is None + + def test_record_not_found_uses_pk_context(self): + translated = translate_error( + RecordNotFound("Record not found for User"), + operation="dml delete", + pk=42, + ) + assert translated.exit_code == EXIT_NOT_FOUND + assert "pk=42" in translated.message + + def test_read_only_keeps_exit_six(self): + translated = translate_error( + ReadOnlyError("User", "dml create"), operation="dml create" + ) + assert isinstance(translated, ReadOnlyModelError) + assert translated.exit_code == EXIT_READ_ONLY + + def test_connection_error_is_exit_one_not_four(self): + translated = translate_error(ConnectionError("connection refused"), operation="dql list") + assert isinstance(translated, CLIError) + assert translated.exit_code == EXIT_ERROR + assert "Database connection failed during dql list" in translated.message + + def test_backend_unavailable_keeps_exit_four(self): + translated = translate_error( + BackendUnavailableError("cannot import"), operation="dml create" + ) + assert translated.exit_code == EXIT_BACKEND_UNAVAILABLE + assert isinstance(translated, BackendUnavailableError) + + def test_stale_record_mentions_both_outcomes(self): + translated = translate_error( + StaleRecordError(model=object, primary_key=1, expected_version=2), + operation="dml update", + ) + assert translated.exit_code == EXIT_ERROR + assert "updated or deleted" in translated.message + + def test_bulk_validation_reports_item_index(self): + translated = translate_error( + BulkValidationError([(1, "age: Input should be <= 100")]), + operation="dml bulk update", + ) + assert translated.exit_code == EXIT_ERROR + assert "item 2" in translated.message + + def test_bulk_state_error(self): + translated = translate_error(BulkStateError("records are inconsistent")) + assert translated.exit_code == EXIT_ERROR + assert "Bulk operation cannot run" in translated.message + + def test_unaddressable_record_points_to_where(self): + translated = translate_error( + UnaddressableRecordError("KeylessUser", "Reading the primary key value") + ) + assert translated.exit_code == EXIT_ERROR + assert "primary key" in translated.message + + def test_integrity_error_is_exit_one(self): + translated = translate_error(IntegrityError("duplicate key"), operation="dml create") + assert translated.exit_code == EXIT_ERROR + assert "Integrity constraint failed during dml create" in translated.message + + def test_unknown_error_returns_none(self): + assert translate_error(ValueError("unexpected")) is None + + +class TestOperationLabel: + def test_dml_single(self): + args = build_parser().parse_args( + ["dml", "create", "--model", "m:U", "--data", "{}"] + ) + assert operation_label(args) == "dml create" + + def test_dml_bulk(self): + args = build_parser().parse_args( + ["dml", "bulk", "delete", "--model", "m:U", "--data", "[]"] + ) + assert operation_label(args) == "dml bulk delete" + + def test_dql(self): + args = build_parser().parse_args(["dql", "aggregate", "--model", "m:U"]) + assert operation_label(args) == "dql aggregate" + + def test_set_and_cte(self): + args = build_parser().parse_args( + [ + "set", + "union", + "--left-model", + "m:U", + "--right-model", + "m:U", + ] + ) + assert operation_label(args) == "set union" + args = build_parser().parse_args(["cte", "run", "--query", "q:f"]) + assert operation_label(args) == "cte run" + + +class TestReportFormatting: + def test_cancelled_has_no_error_prefix(self, capsys): + report_error(CancelledError()) + err = capsys.readouterr().err + assert err.strip() == "Operation cancelled." + assert "Error:" not in err + + def test_cancelled_exit_code_is_zero(self): + assert CancelledError().exit_code == EXIT_OK + + def test_unexpected_names_the_type(self, capsys): + report_unexpected(ValueError("boom")) + err = capsys.readouterr().err + assert err.strip() == "Unexpected error (ValueError): boom" + assert "Traceback" not in err + + def test_error_prefix_is_kept_for_failures(self, capsys): + report_error(CLIError("nope")) + err = capsys.readouterr().err + assert err.strip() == "Error: nope" diff --git a/tests/rhosocial/activerecord_test/feature/cli/test_cli_pipeline.py b/tests/rhosocial/activerecord_test/feature/cli/test_cli_pipeline.py new file mode 100644 index 00000000..63c7bbec --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/cli/test_cli_pipeline.py @@ -0,0 +1,139 @@ +# tests/rhosocial/activerecord_test/feature/cli/test_cli_pipeline.py +"""Pipeline, stdin, and streaming-output tests for the CLI.""" + +import io +import json +import sys + +from conftest import run_cli + + +class _BrokenPipe: + def write(self, _value): + raise BrokenPipeError() + + def flush(self): + pass + + +class TestPipeline: + def test_create_from_stdin(self, cli_env, monkeypatch): + monkeypatch.setattr( + sys, "stdin", io.StringIO('{"id": 20, "name": "Pipe", "age": 20}\n') + ) + code, out, _ = run_cli( + [ + "dml", + "create", + *cli_env.base, + "--model", + cli_env.user_model, + "--data", + "@-", + "-o", + "json", + ] + ) + assert code == 0 + assert json.loads(out)["name"] == "Pipe" + + def test_jsonl_output(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "list", + *cli_env.base, + "--model", + cli_env.user_model, + "--limit", + "2", + "-o", + "jsonl", + ] + ) + assert code == 0 + lines = [json.loads(line) for line in out.splitlines() if line.strip()] + assert len(lines) == 2 + assert all("id" in line for line in lines) + + def test_default_output_is_json_when_captured(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "list", + *cli_env.base, + "--model", + cli_env.user_model, + "--limit", + "1", + ] + ) + assert code == 0 + assert isinstance(json.loads(out), list) + + def test_scalar_value(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "count", + *cli_env.base, + "--model", + cli_env.user_model, + "-o", + "value", + ] + ) + assert code == 0 + assert out.strip() == "4" + + def test_quiet_suppresses_pagination_note(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "list", + *cli_env.base, + "--model", + cli_env.user_model, + "--quiet", + "-o", + "json", + ] + ) + assert code == 0 + assert "default pagination" not in err + assert json.loads(out) + + def test_no_header_csv(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "list", + *cli_env.base, + "--model", + cli_env.user_model, + "--limit", + "2", + "--no-header", + "-o", + "csv", + ] + ) + assert code == 0 + lines = out.strip().splitlines() + assert lines[0].startswith("1,Alice") + + def test_broken_pipe_is_silent(self, cli_env, monkeypatch): + monkeypatch.setattr(sys, "stdout", _BrokenPipe()) + code = run_cli( + [ + "dql", + "list", + *cli_env.base, + "--model", + cli_env.user_model, + "-o", + "jsonl", + ], + capture=False, + )[0] + assert code == 141 diff --git a/tests/rhosocial/activerecord_test/feature/cli/test_cli_read_only.py b/tests/rhosocial/activerecord_test/feature/cli/test_cli_read_only.py new file mode 100644 index 00000000..f7953376 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/cli/test_cli_read_only.py @@ -0,0 +1,96 @@ +# tests/rhosocial/activerecord_test/feature/cli/test_cli_read_only.py +"""Read-only model behavior tests for the CLI.""" + +from conftest import run_cli + + +class TestReadOnly: + def test_create_rejected(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "create", + *cli_env.base, + "--model", + cli_env.read_only_model, + "--data", + '{"id": 99, "name": "Nope"}', + "-o", + "json", + ] + ) + assert code == 6 + assert out == "" + assert "read-only" in err + + def test_update_rejected(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "update", + *cli_env.base, + "--model", + cli_env.read_only_model, + "--pk", + "1", + "--data", + '{"age": 31}', + "-o", + "json", + ] + ) + assert code == 6 + assert out == "" + assert "read-only" in err + + def test_delete_dry_run_rejected(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "delete", + *cli_env.base, + "--model", + cli_env.read_only_model, + "--pk", + "1", + "--dry-run", + "-o", + "json", + ] + ) + assert code == 6 + assert out == "" + assert "read-only" in err + + def test_dql_still_works(self, cli_env): + code, out, _ = run_cli( + [ + "dql", + "count", + *cli_env.base, + "--model", + cli_env.read_only_model, + "-o", + "value", + ] + ) + assert code == 0 + assert out.strip() == "4" + + def test_async_create_rejected(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "create", + *cli_env.async_base, + "--model", + cli_env.async_read_only_model, + "--data", + '{"id": 99, "name": "Nope"}', + "-o", + "json", + ] + ) + assert code == 6 + assert out == "" + assert "read-only" in err diff --git a/tests/rhosocial/activerecord_test/feature/cli/test_cli_set_cte.py b/tests/rhosocial/activerecord_test/feature/cli/test_cli_set_cte.py new file mode 100644 index 00000000..2f0f1c48 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/cli/test_cli_set_cte.py @@ -0,0 +1,141 @@ +# tests/rhosocial/activerecord_test/feature/cli/test_cli_set_cte.py +"""CLI SetOperation and CTE tests.""" + +import json + +from conftest import run_cli + + +class TestSetOperation: + def test_union(self, cli_env): + code, out, _ = run_cli( + [ + "set", + "union", + *cli_env.base, + "--left-model", + cli_env.user_model, + "--right-model", + cli_env.user_model, + "--left-where", + "age=30", + "--right-where", + "age=40", + "-o", + "json", + ] + ) + assert code == 0 + rows = json.loads(out) + assert {row["id"] for row in rows} == {1, 3} + + def test_union_all(self, cli_env): + code, out, _ = run_cli( + [ + "set", + "union-all", + *cli_env.base, + "--left-model", + cli_env.user_model, + "--right-model", + cli_env.user_model, + "--left-where", + "age=30", + "--right-where", + "age=30", + "-o", + "json", + ] + ) + assert code == 0 + assert len(json.loads(out)) == 2 + + def test_run_user_query(self, cli_env): + code, out, _ = run_cli( + [ + "set", + "run", + *cli_env.base, + "--model", + cli_env.user_model, + "--query", + cli_env.user_union, + "-o", + "json", + ] + ) + assert code == 0 + assert {row["id"] for row in json.loads(out)} == {1, 3} + + def test_run_rejects_active_query(self, cli_env): + code, out, err = run_cli( + [ + "set", + "run", + *cli_env.base, + "--model", + cli_env.user_model, + "--query", + cli_env.active_users, + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "SetOperationQuery" in err + + +class TestCte: + def test_run_user_query(self, cli_env): + code, out, _ = run_cli( + [ + "cte", + "run", + *cli_env.base, + "--model", + cli_env.user_model, + "--query", + cli_env.user_cte, + "-o", + "json", + ] + ) + assert code == 0 + assert {row["id"] for row in json.loads(out)} == {1, 3} + + +class TestAsyncSetCte: + def test_async_set_run(self, cli_env): + code, out, _ = run_cli( + [ + "set", + "run", + *cli_env.async_base, + "--model", + cli_env.async_model, + "--query", + cli_env.async_user_union, + "-o", + "json", + ] + ) + assert code == 0 + assert {row["id"] for row in json.loads(out)} == {1, 3} + + def test_async_cte_run(self, cli_env): + code, out, _ = run_cli( + [ + "cte", + "run", + *cli_env.async_base, + "--model", + cli_env.async_model, + "--query", + cli_env.async_user_cte, + "-o", + "json", + ] + ) + assert code == 0 + assert {row["id"] for row in json.loads(out)} == {1, 3} diff --git a/tests/rhosocial/activerecord_test/feature/cli/test_cli_validation.py b/tests/rhosocial/activerecord_test/feature/cli/test_cli_validation.py new file mode 100644 index 00000000..8db3cc38 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/cli/test_cli_validation.py @@ -0,0 +1,768 @@ +# tests/rhosocial/activerecord_test/feature/cli/test_cli_validation.py +"""CLI validation: field constraints, bulk context, parameter checks, and errors.""" + +import builtins +import csv +import io +import json +import sqlite3 +import sys + +from conftest import run_cli +from rhosocial.activerecord.cli import interaction as cli_interaction + + +def _count(database, table="users"): + conn = sqlite3.connect(str(database)) + try: + return conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + finally: + conn.close() + + +def _value(database, pk, column): + conn = sqlite3.connect(str(database)) + try: + row = conn.execute( + f"SELECT {column} FROM users WHERE id = ?", (pk,) + ).fetchone() + return None if row is None else row[0] + finally: + conn.close() + + +class TestValidation: + def test_create_field_constraint_failure(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "create", + *cli_env.base, + "--model", + cli_env.validated_model, + "--data", + '{"id": 9, "name": "Carol", "age": 101}', + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "Invalid data for ValidatedUser" in err + assert _count(cli_env.database) == 4 + + def test_create_field_validator_failure(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "create", + *cli_env.base, + "--model", + cli_env.validated_model, + "--data", + '{"id": 9, "name": "admin", "age": 30}', + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "'admin'" in err + assert _count(cli_env.database) == 4 + + def test_create_model_validator_failure(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "create", + *cli_env.base, + "--model", + cli_env.validated_model, + "--data", + '{"id": 9, "name": "senior", "age": 30}', + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "senior users need age >= 60" in err + assert _count(cli_env.database) == 4 + + def test_update_field_constraint_failure(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "update", + *cli_env.base, + "--model", + cli_env.validated_model, + "--pk", + "1", + "--data", + '{"age": 101}', + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "ValidatedUser pk=1" in err + assert _value(cli_env.database, 1, "age") == 30 + + def test_update_model_validator_failure(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "update", + *cli_env.base, + "--model", + cli_env.validated_model, + "--pk", + "1", + "--data", + '{"name": "senior"}', + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "ValidatedUser pk=1" in err + assert _value(cli_env.database, 1, "name") == "Alice" + + def test_bulk_create_reports_item_index(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "bulk", + "create", + *cli_env.base, + "--model", + cli_env.validated_model, + "--data", + '[{"name": "Ok", "age": 20}, {"name": "X", "age": 20}]', + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "item 2" in err + assert _count(cli_env.database) == 4 + + def test_bulk_update_reports_item_and_pk(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "bulk", + "update", + *cli_env.base, + "--model", + cli_env.validated_model, + "--data", + '[{"id": 1, "age": 31}, {"id": 2, "age": 101}]', + "--update-fields", + "age", + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "item 2" in err + assert "pk=2" in err + assert _value(cli_env.database, 2, "age") == 25 + + def test_bulk_update_unknown_field(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "bulk", + "update", + *cli_env.base, + "--model", + cli_env.validated_model, + "--data", + '[{"id": 1, "nope": 1}]', + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "nope" in err + assert "Available fields" in err + + def test_update_fields_unknown_field(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "bulk", + "update", + *cli_env.base, + "--model", + cli_env.validated_model, + "--data", + '[{"id": 1, "age": 31}]', + "--update-fields", + "nope", + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "nope" in err + + def test_aggregate_unknown_field(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "aggregate", + *cli_env.base, + "--model", + cli_env.validated_model, + "--fn", + "avg", + "--field", + "nope", + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert 'Unknown aggregate field "nope"' in err + + def test_aggregate_star_requires_count(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "aggregate", + *cli_env.base, + "--model", + cli_env.validated_model, + "--fn", + "sum", + "--field", + "*", + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "--fn sum requires a concrete --field" in err + + def test_negative_limit_rejected(self, cli_env): + code, out, err = run_cli( + ["dql", "list", *cli_env.base, "--model", cli_env.validated_model, "--limit", "-1"] + ) + assert code == 1 + assert out == "" + assert "--limit must be greater than or equal to 0" in err + + def test_negative_offset_rejected(self, cli_env): + code, out, err = run_cli( + ["dql", "list", *cli_env.base, "--model", cli_env.validated_model, "--offset", "-1"] + ) + assert code == 1 + assert out == "" + assert "--offset must be greater than or equal to 0" in err + + def test_limit_zero_returns_all(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "list", + *cli_env.base, + "--model", + cli_env.validated_model, + "--limit", + "0", + "-o", + "json", + ] + ) + assert code == 0 + assert len(json.loads(out)) == 4 + + def test_page_zero_rejected(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "list", + *cli_env.base, + "--model", + cli_env.validated_model, + "--page", + "0", + "--page-size", + "2", + ] + ) + assert code == 1 + assert out == "" + assert "--page and --page-size must be positive" in err + + def test_keyless_get_reports_no_primary_key(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "get", + *cli_env.base, + "--model", + cli_env.keyless_model, + "--pk", + "1", + ] + ) + assert code == 1 + assert out == "" + assert "primary key" in err + + def test_cancel_delete_is_not_an_error(self, cli_env, monkeypatch): + monkeypatch.setattr(cli_interaction, "is_tty", lambda: True) + + class _TtyStdin: + def isatty(self): + return True + + monkeypatch.setattr(sys, "stdin", _TtyStdin()) + monkeypatch.setattr(builtins, "input", lambda prompt: "n") + code, out, err = run_cli( + [ + "dml", + "delete", + *cli_env.base, + "--model", + cli_env.validated_model, + "--pk", + "1", + ] + ) + assert code == 0 + assert out == "" + assert "Operation cancelled." in err + assert "Error:" not in err + assert _count(cli_env.database) == 4 + + def test_delete_csv_has_header_and_row(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "delete", + *cli_env.base, + "--model", + cli_env.validated_model, + "--pk", + "4", + "--yes", + "-o", + "csv", + ] + ) + assert code == 0 + rows = list(csv.reader(io.StringIO(out))) + assert rows[0] == ["action", "pk", "affected"] + assert rows[1] == ["delete", "4", "1"] + + def test_bulk_update_tsv_has_action_and_affected(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "bulk", + "update", + *cli_env.base, + "--model", + cli_env.validated_model, + "--data", + '[{"id": 1, "age": 31}, {"id": 2, "age": 26}]', + "--update-fields", + "age", + "-o", + "tsv", + ] + ) + assert code == 0 + rows = list(csv.reader(io.StringIO(out), delimiter="\t")) + assert rows[0] == ["action", "affected"] + assert rows[1] == ["update", "2"] + + +class TestAsyncValidation: + def test_create_field_constraint_failure(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "create", + *cli_env.async_base, + "--model", + cli_env.async_validated_model, + "--data", + '{"id": 9, "name": "Carol", "age": 101}', + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "Invalid data for AsyncValidatedUser" in err + assert _count(cli_env.database) == 4 + + def test_create_field_validator_failure(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "create", + *cli_env.async_base, + "--model", + cli_env.async_validated_model, + "--data", + '{"id": 9, "name": "admin", "age": 30}', + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "'admin'" in err + assert _count(cli_env.database) == 4 + + def test_create_model_validator_failure(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "create", + *cli_env.async_base, + "--model", + cli_env.async_validated_model, + "--data", + '{"id": 9, "name": "senior", "age": 30}', + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "senior users need age >= 60" in err + assert _count(cli_env.database) == 4 + + def test_update_field_constraint_failure(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "update", + *cli_env.async_base, + "--model", + cli_env.async_validated_model, + "--pk", + "1", + "--data", + '{"age": 101}', + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "AsyncValidatedUser pk=1" in err + assert _value(cli_env.database, 1, "age") == 30 + + def test_update_model_validator_failure(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "update", + *cli_env.async_base, + "--model", + cli_env.async_validated_model, + "--pk", + "1", + "--data", + '{"name": "senior"}', + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "AsyncValidatedUser pk=1" in err + assert _value(cli_env.database, 1, "name") == "Alice" + + def test_bulk_create_reports_item_index(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "bulk", + "create", + *cli_env.async_base, + "--model", + cli_env.async_validated_model, + "--data", + '[{"name": "Ok", "age": 20}, {"name": "X", "age": 20}]', + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "item 2" in err + assert _count(cli_env.database) == 4 + + def test_bulk_update_reports_item_and_pk(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "bulk", + "update", + *cli_env.async_base, + "--model", + cli_env.async_validated_model, + "--data", + '[{"id": 1, "age": 31}, {"id": 2, "age": 101}]', + "--update-fields", + "age", + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "item 2" in err + assert "pk=2" in err + assert _value(cli_env.database, 2, "age") == 25 + + def test_bulk_update_unknown_field(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "bulk", + "update", + *cli_env.async_base, + "--model", + cli_env.async_validated_model, + "--data", + '[{"id": 1, "nope": 1}]', + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "nope" in err + + def test_update_fields_unknown_field(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "bulk", + "update", + *cli_env.async_base, + "--model", + cli_env.async_validated_model, + "--data", + '[{"id": 1, "age": 31}]', + "--update-fields", + "nope", + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "nope" in err + + def test_aggregate_unknown_field(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "aggregate", + *cli_env.async_base, + "--model", + cli_env.async_validated_model, + "--fn", + "avg", + "--field", + "nope", + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert 'Unknown aggregate field "nope"' in err + + def test_aggregate_star_requires_count(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "aggregate", + *cli_env.async_base, + "--model", + cli_env.async_validated_model, + "--fn", + "sum", + "--field", + "*", + "-o", + "json", + ] + ) + assert code == 1 + assert out == "" + assert "--fn sum requires a concrete --field" in err + + def test_negative_limit_rejected(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "list", + *cli_env.async_base, + "--model", + cli_env.async_validated_model, + "--limit", + "-1", + ] + ) + assert code == 1 + assert out == "" + assert "--limit must be greater than or equal to 0" in err + + def test_negative_offset_rejected(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "list", + *cli_env.async_base, + "--model", + cli_env.async_validated_model, + "--offset", + "-1", + ] + ) + assert code == 1 + assert out == "" + assert "--offset must be greater than or equal to 0" in err + + def test_limit_zero_returns_all(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "list", + *cli_env.async_base, + "--model", + cli_env.async_validated_model, + "--limit", + "0", + "-o", + "json", + ] + ) + assert code == 0 + assert len(json.loads(out)) == 4 + + def test_page_zero_rejected(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "list", + *cli_env.async_base, + "--model", + cli_env.async_validated_model, + "--page", + "0", + "--page-size", + "2", + ] + ) + assert code == 1 + assert out == "" + assert "--page and --page-size must be positive" in err + + def test_keyless_get_reports_no_primary_key(self, cli_env): + code, out, err = run_cli( + [ + "dql", + "get", + *cli_env.base, + "--model", + cli_env.keyless_model, + "--pk", + "1", + ] + ) + assert code == 1 + assert out == "" + assert "primary key" in err + + def test_cancel_delete_is_not_an_error(self, cli_env, monkeypatch): + monkeypatch.setattr(cli_interaction, "is_tty", lambda: True) + + class _TtyStdin: + def isatty(self): + return True + + monkeypatch.setattr(sys, "stdin", _TtyStdin()) + monkeypatch.setattr(builtins, "input", lambda prompt: "n") + code, out, err = run_cli( + [ + "dml", + "delete", + *cli_env.async_base, + "--model", + cli_env.async_validated_model, + "--pk", + "1", + ] + ) + assert code == 0 + assert out == "" + assert "Operation cancelled." in err + assert "Error:" not in err + assert _count(cli_env.database) == 4 + + def test_delete_csv_has_header_and_row(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "delete", + *cli_env.async_base, + "--model", + cli_env.async_validated_model, + "--pk", + "4", + "--yes", + "-o", + "csv", + ] + ) + assert code == 0 + rows = list(csv.reader(io.StringIO(out))) + assert rows[0] == ["action", "pk", "affected"] + assert rows[1] == ["delete", "4", "1"] + + def test_bulk_update_tsv_has_action_and_affected(self, cli_env): + code, out, err = run_cli( + [ + "dml", + "bulk", + "update", + *cli_env.async_base, + "--model", + cli_env.async_validated_model, + "--data", + '[{"id": 1, "age": 31}, {"id": 2, "age": 26}]', + "--update-fields", + "age", + "-o", + "tsv", + ] + ) + assert code == 0 + rows = list(csv.reader(io.StringIO(out), delimiter="\t")) + assert rows[0] == ["action", "affected"] + assert rows[1] == ["update", "2"] + +