Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
fad5d84
feat(cli): add model CRUD command-line interface
vistart Nov 8, 2026
b462e08
test(cli): cover entry point, connections, CRUD, and output
vistart Nov 8, 2026
e3ad2dd
docs(cli): document model CRUD CLI
vistart Nov 8, 2026
fc81415
refactor(cli): resolve backends and configs by explicit class references
vistart Nov 10, 2026
77af5cf
docs(cli): document explicit backend and connection configuration
vistart Nov 10, 2026
f12c2d7
test(cli): cover explicit backend and connection resolution
vistart Nov 10, 2026
aae2239
feat(cli): split dml, dql, set, and cte command groups
vistart Nov 10, 2026
73acf2f
test(cli): cover bulk DML, user queries, pipelines, and read-only models
vistart Nov 10, 2026
8d259ff
feat(cli): run async commands on a Windows-compatible event loop
vistart Nov 10, 2026
488b14e
docs(cli): document DML, DQL, bulk, pipeline, and read-only commands
vistart Nov 10, 2026
507f915
test(cli): register CLI providers for core testsuite
vistart Nov 10, 2026
0c9595c
fix(cli): pin shared labels and avoid duplicate test basename
vistart Nov 10, 2026
5b76127
test(cli): restore framework logging after in-process CLI runs
vistart Nov 10, 2026
892f798
docs(cli): add runnable examples and refresh CLI navigation
vistart Nov 10, 2026
0fd2dbb
fix(model): surface reread failures, savepoint probe errors, and stal…
vistart Nov 10, 2026
56bdb62
feat(cli): validate inputs and translate model and backend errors
vistart Nov 10, 2026
91708ad
docs(cli): split CLI documentation into topic pages [skip ci]
vistart Nov 10, 2026
654bbc9
docs(changelog): add fragment for the CLI feature (#138)
vistart Nov 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/138.added.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions docs/en_US/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
129 changes: 129 additions & 0 deletions docs/en_US/cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Model Command-Line Interface

`rhosocial-activerecord` is the **model** entry point. It is not the same
command as `rhosocial-activerecord-<backend>` (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.
106 changes: 106 additions & 0 deletions docs/en_US/cli/bulk.md
Original file line number Diff line number Diff line change
@@ -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.
94 changes: 94 additions & 0 deletions docs/en_US/cli/connections.md
Original file line number Diff line number Diff line change
@@ -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`.
Loading
Loading