dirsql 0.3.69__tar.gz → 0.3.70__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dirsql-0.3.69 → dirsql-0.3.70}/Cargo.lock +1 -1
- {dirsql-0.3.69 → dirsql-0.3.70}/PKG-INFO +1 -1
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/Cargo.toml +1 -1
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/e2e-attestation.json +2 -2
- dirsql-0.3.70/packages/rust/src/cli/execute.rs +314 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/src/cli/mod.rs +4 -1
- dirsql-0.3.70/packages/rust/src/cli/router.rs +191 -0
- dirsql-0.3.69/packages/rust/src/cli/router.rs +0 -393
- {dirsql-0.3.69 → dirsql-0.3.70}/Cargo.toml +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/README.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/dirsql/__init__.py +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/dirsql/_async.py +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/dirsql/_dirsql.pyi +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/dirsql/cli/__init__.py +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/dirsql/cli/binary_path.py +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/dirsql/cli/is_windows.py +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/dirsql/cli/main.py +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/dirsql/cli/resolve_config_extensions.py +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/dirsql/py.typed +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/dirsql/resolve_config_extensions.py +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/dirsql/resolve_extension.py +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/.claude/CLAUDE.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/.vitepress/config.ts +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/.vitepress/theme/index.ts +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/.vitepress/theme/lang.ts +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/AGENTS.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/explanation.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/getting-started.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/howto/columns-from-paths.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/howto/define-tables.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/howto/embed.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/howto/extract-from-contents.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/howto/load-extension.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/howto/persist.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/howto/react-to-changes.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/howto/search-by-meaning.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/howto/skip-files.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/index.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/migrations.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/package.json +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/playwright.config.ts +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/pnpm-lock.yaml +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/pnpm-workspace.yaml +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/reference/cli.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/reference/columns.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/reference/config.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/reference/hooks.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/reference/http-api.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/reference/sdk.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/tests/integration/home.spec.ts +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/tests/integration/language-flag.spec.ts +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/tests/integration/sidebar.spec.ts +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/tests/unit/config.test.ts +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/tests/unit/lang.test.ts +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/docs/vitest.config.ts +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/README.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/conftest.py +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/.claude/CLAUDE.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/.vitepress/config.ts +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/.vitepress/theme/index.ts +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/AGENTS.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/explanation.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/getting-started.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/howto/columns-from-paths.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/howto/define-tables.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/howto/embed.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/howto/extract-from-contents.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/howto/load-extension.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/howto/persist.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/howto/react-to-changes.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/howto/search-by-meaning.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/howto/skip-files.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/index.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/migrations.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/package.json +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/playwright.config.ts +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/pnpm-lock.yaml +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/pnpm-workspace.yaml +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/reference/cli.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/reference/columns.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/reference/config.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/reference/hooks.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/reference/http-api.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/reference/sdk.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/tests/integration/home.spec.ts +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/tests/integration/sidebar.spec.ts +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/tests/unit/config.test.ts +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/tests/unit/lang.test.ts +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/docs/vitest.config.ts +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/src/lib.rs +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/tests/__init__.py +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/tests/binding/__init__.py +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/tests/conftest.py +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/tests/e2e/__init__.py +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/tests/integration/__init__.py +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/python/tests/smoke/__init__.py +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/Cargo.toml +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/README.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/benches/db_bench.rs +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/benches/differ_bench.rs +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/benches/matcher_bench.rs +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/benches/scanner_bench.rs +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/docs/explanation.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/docs/getting-started.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/docs/howto/columns-from-paths.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/docs/howto/define-tables.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/docs/howto/embed.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/docs/howto/extract-from-contents.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/docs/howto/load-extension.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/docs/howto/persist.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/docs/howto/react-to-changes.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/docs/howto/search-by-meaning.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/docs/howto/skip-files.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/docs/index.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/docs/migrations.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/docs/reference/cli.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/docs/reference/columns.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/docs/reference/config.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/docs/reference/hooks.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/docs/reference/http-api.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/docs/reference/sdk.md +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/src/bin/dirsql.rs +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/src/cli/init.rs +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/src/cli/serialize.rs +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/src/cli/server.rs +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/src/command.rs +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/src/config.rs +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/src/db.rs +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/src/differ.rs +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/src/lib.rs +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/src/matcher.rs +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/src/persist.rs +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/src/scanner.rs +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/packages/rust/src/watcher.rs +0 -0
- {dirsql-0.3.69 → dirsql-0.3.70}/pyproject.toml +0 -0
|
@@ -4,7 +4,7 @@ name = "dirsql-py-ext"
|
|
|
4
4
|
# pypi/maturin handler can rewrite it via `write-version` before
|
|
5
5
|
# `maturin build`. `pyproject.toml` declares `dynamic = ["version"]`
|
|
6
6
|
# and maturin reads this field. Mirrors `packages/rust/Cargo.toml`.
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.70"
|
|
8
8
|
edition.workspace = true
|
|
9
9
|
publish = false
|
|
10
10
|
readme = "README.md"
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
//! Transport-agnostic `/query` pipeline.
|
|
2
|
+
//!
|
|
3
|
+
//! The full orchestration for running one query — intake validation,
|
|
4
|
+
//! the `pre-query` hook, the query timeout, [`DirSQL::query`], row
|
|
5
|
+
//! serialization, the `post-query` hook, and error classification —
|
|
6
|
+
//! lives here exactly once. The HTTP handler and the one-shot
|
|
7
|
+
//! `dirsql query` subcommand (#399) are thin transport adapters over
|
|
8
|
+
//! [`execute_query`], so the two surfaces cannot drift behaviorally:
|
|
9
|
+
//! per-surface code only maps [`QueryFailure`] to a status code or an
|
|
10
|
+
//! exit code.
|
|
11
|
+
|
|
12
|
+
use std::time::Duration;
|
|
13
|
+
|
|
14
|
+
use serde::Deserialize;
|
|
15
|
+
use serde_json::Value;
|
|
16
|
+
|
|
17
|
+
use super::serialize::rows_to_json;
|
|
18
|
+
use super::{AppState, PostQuery, PreQuery};
|
|
19
|
+
use crate::command::{Placeholder, run_command};
|
|
20
|
+
use crate::{DirSQL, DirSqlError};
|
|
21
|
+
|
|
22
|
+
/// Cap on the serialized result payload passed as the `{args}` argv token.
|
|
23
|
+
/// Beyond this, `{args}` is emptied and the operator is directed to stdin
|
|
24
|
+
/// (which always carries the full payload) — comfortably under Linux's 128 KiB
|
|
25
|
+
/// single-arg `MAX_ARG_STRLEN`.
|
|
26
|
+
const POST_QUERY_ARGS_MAX: usize = 96 * 1024;
|
|
27
|
+
|
|
28
|
+
/// Why a query failed, classified independently of transport. The HTTP
|
|
29
|
+
/// adapter maps each arm to a status code (400 / 408 / 500 / 503); the CLI
|
|
30
|
+
/// adapter maps every arm to stderr + a non-zero exit.
|
|
31
|
+
#[derive(Debug)]
|
|
32
|
+
pub enum QueryFailure {
|
|
33
|
+
/// Malformed input the caller can fix: unparsable body, missing/empty
|
|
34
|
+
/// `sql`, or a SQL error from the core (HTTP 400).
|
|
35
|
+
BadRequest(String),
|
|
36
|
+
/// The query exceeded the configured timeout (HTTP 408).
|
|
37
|
+
Timeout(Duration),
|
|
38
|
+
/// A server-side fault: hook failure, join error, lock poisoning
|
|
39
|
+
/// (HTTP 500).
|
|
40
|
+
Internal(String),
|
|
41
|
+
/// The index never became ready — the degraded config state (HTTP 503).
|
|
42
|
+
Unavailable(String),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
impl QueryFailure {
|
|
46
|
+
/// The diagnostic message, identical across transports (the HTTP
|
|
47
|
+
/// `{"error": …}` body and the CLI stderr line).
|
|
48
|
+
pub fn message(&self) -> String {
|
|
49
|
+
match self {
|
|
50
|
+
Self::Timeout(timeout) => format!("query exceeded {timeout:?} timeout"),
|
|
51
|
+
Self::BadRequest(msg) | Self::Internal(msg) | Self::Unavailable(msg) => msg.clone(),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#[derive(Debug, Deserialize)]
|
|
57
|
+
struct QueryBody {
|
|
58
|
+
sql: Option<String>,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Run one query end to end: resolve the SQL from `raw_body` (through the
|
|
62
|
+
/// `pre-query` hook when present, else parsed as `{"sql": …}`), execute it
|
|
63
|
+
/// against the index under `timeout`, serialize the rows, and reshape them
|
|
64
|
+
/// through the `post-query` hook when present. `raw_body` is the exact
|
|
65
|
+
/// payload a `POST /query` request carries; the CLI adapter synthesizes the
|
|
66
|
+
/// same shape so both surfaces share intake validation and hook semantics.
|
|
67
|
+
pub async fn execute_query(
|
|
68
|
+
state: &AppState,
|
|
69
|
+
raw_body: String,
|
|
70
|
+
timeout: Duration,
|
|
71
|
+
pre_query: Option<&PreQuery>,
|
|
72
|
+
post_query: Option<&PostQuery>,
|
|
73
|
+
) -> Result<Value, QueryFailure> {
|
|
74
|
+
// Resolve the SQL to run. With a `pre-query` hook the raw body is
|
|
75
|
+
// rewritten by the command; without one it is parsed as `{"sql": …}`.
|
|
76
|
+
let sql = match pre_query {
|
|
77
|
+
Some(pq) => run_pre_query(pq, raw_body).await?,
|
|
78
|
+
None => parse_sql_body(&raw_body)?,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
let db = require_ready(state)?;
|
|
82
|
+
|
|
83
|
+
let join =
|
|
84
|
+
tokio::time::timeout(timeout, tokio::task::spawn_blocking(move || db.query(&sql))).await;
|
|
85
|
+
|
|
86
|
+
match join {
|
|
87
|
+
Ok(Ok(Ok(rows))) => {
|
|
88
|
+
let rows_json = rows_to_json(&rows);
|
|
89
|
+
match post_query {
|
|
90
|
+
Some(pq) => run_post_query(pq, rows_json).await,
|
|
91
|
+
None => Ok(Value::Array(rows_json)),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
Ok(Ok(Err(err))) => Err(classify_query_error(err)),
|
|
95
|
+
Ok(Err(join_err)) => Err(QueryFailure::Internal(join_err.to_string())),
|
|
96
|
+
Err(_elapsed) => Err(QueryFailure::Timeout(timeout)),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Return a cloned [`DirSQL`] handle, or [`QueryFailure::Unavailable`] if
|
|
101
|
+
/// the index started in the degraded [`AppState::Unavailable`] state.
|
|
102
|
+
pub fn require_ready(state: &AppState) -> Result<DirSQL, QueryFailure> {
|
|
103
|
+
match state {
|
|
104
|
+
AppState::Ready(db) => Ok(db.clone()),
|
|
105
|
+
AppState::Unavailable(reason) => Err(QueryFailure::Unavailable(reason.clone())),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Parse a raw query body as `{"sql": …}` and return the trimmed SQL.
|
|
110
|
+
/// `BadRequest` on malformed JSON, and on a missing/empty `sql` field.
|
|
111
|
+
fn parse_sql_body(body: &str) -> Result<String, QueryFailure> {
|
|
112
|
+
let parsed: QueryBody =
|
|
113
|
+
serde_json::from_str(body).map_err(|err| QueryFailure::BadRequest(err.to_string()))?;
|
|
114
|
+
match parsed.sql.as_deref().map(str::trim) {
|
|
115
|
+
Some(s) if !s.is_empty() => Ok(s.to_string()),
|
|
116
|
+
Some(_) => Err(QueryFailure::BadRequest("`sql` must not be empty".into())),
|
|
117
|
+
None => Err(QueryFailure::BadRequest("missing `sql` field".into())),
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// SQL errors from the core are the caller's to fix (`BadRequest`); every
|
|
122
|
+
/// other failure (lock poisoning, watch/config faults) is server-side
|
|
123
|
+
/// (`Internal`).
|
|
124
|
+
fn classify_query_error(err: DirSqlError) -> QueryFailure {
|
|
125
|
+
match err {
|
|
126
|
+
DirSqlError::Core(_) => QueryFailure::BadRequest(err.to_string()),
|
|
127
|
+
_ => QueryFailure::Internal(err.to_string()),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// Run the `pre-query` hook over the raw request body and return the SQL it
|
|
132
|
+
/// prints. The body is passed as the injection-safe `{args}` placeholder (a
|
|
133
|
+
/// single argv token); the command's last non-empty stdout line is the SQL to
|
|
134
|
+
/// run. Any failure (non-zero exit, timeout, spawn error) maps to `Internal`
|
|
135
|
+
/// carrying the command's stderr tail.
|
|
136
|
+
async fn run_pre_query(pq: &PreQuery, raw_body: String) -> Result<String, QueryFailure> {
|
|
137
|
+
let command = pq.command.clone();
|
|
138
|
+
let config_dir = pq.config_dir.clone();
|
|
139
|
+
let timeout = pq.timeout;
|
|
140
|
+
// `run_command` is blocking — it spawns a child and joins drain threads —
|
|
141
|
+
// so run it off the async runtime. It enforces the hook's timeout
|
|
142
|
+
// (the global `[dirsql].hook-timeout`, default 30s) internally, so no outer
|
|
143
|
+
// `tokio::time::timeout` is needed.
|
|
144
|
+
let outcome = tokio::task::spawn_blocking(move || {
|
|
145
|
+
run_command(
|
|
146
|
+
&command,
|
|
147
|
+
&[Placeholder::new("args", &raw_body)],
|
|
148
|
+
&config_dir,
|
|
149
|
+
timeout,
|
|
150
|
+
None,
|
|
151
|
+
)
|
|
152
|
+
})
|
|
153
|
+
.await
|
|
154
|
+
.map_err(|join_err| QueryFailure::Internal(join_err.to_string()))?;
|
|
155
|
+
|
|
156
|
+
// `run_command` only returns `Ok` with a non-empty last stdout line
|
|
157
|
+
// (`EmptyOutput` otherwise), so the payload is the SQL as-is.
|
|
158
|
+
outcome
|
|
159
|
+
.map(|out| out.payload)
|
|
160
|
+
.map_err(|err| QueryFailure::Internal(err.to_string()))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// Run the `post-query` hook over a successful result set and return the JSON
|
|
164
|
+
/// body it prints. The rows are serialized to a JSON array and delivered two
|
|
165
|
+
/// ways: always on the child's stdin (unbounded, injection-safe), and as the
|
|
166
|
+
/// `{args}` placeholder when the payload is within [`POST_QUERY_ARGS_MAX`]
|
|
167
|
+
/// (beyond that `{args}` is emptied and a warning names the size, directing
|
|
168
|
+
/// the operator to stdin — never silent truncation). The command's last
|
|
169
|
+
/// non-empty stdout line is parsed as JSON and returned as the result;
|
|
170
|
+
/// anything that isn't valid JSON, or any failure (non-zero exit, timeout,
|
|
171
|
+
/// spawn error), maps to `Internal`.
|
|
172
|
+
async fn run_post_query(pq: &PostQuery, rows: Vec<Value>) -> Result<Value, QueryFailure> {
|
|
173
|
+
let payload =
|
|
174
|
+
serde_json::to_string(&rows).map_err(|err| QueryFailure::Internal(err.to_string()))?;
|
|
175
|
+
let command = pq.command.clone();
|
|
176
|
+
let config_dir = pq.config_dir.clone();
|
|
177
|
+
let timeout = pq.timeout;
|
|
178
|
+
// `run_command` is blocking — it spawns a child and joins drain threads —
|
|
179
|
+
// so run it off the async runtime. It enforces the hook's timeout
|
|
180
|
+
// (the global `[dirsql].hook-timeout`, default 30s) internally, so no outer
|
|
181
|
+
// `tokio::time::timeout` is needed.
|
|
182
|
+
let outcome = tokio::task::spawn_blocking(move || {
|
|
183
|
+
let args_value = if payload.len() <= POST_QUERY_ARGS_MAX {
|
|
184
|
+
payload.clone()
|
|
185
|
+
} else {
|
|
186
|
+
eprintln!(
|
|
187
|
+
"dirsql: post-query result payload is {} bytes, exceeding the \
|
|
188
|
+
{POST_QUERY_ARGS_MAX}-byte argv threshold; `{{args}}` is emptied — \
|
|
189
|
+
read the rows from stdin instead",
|
|
190
|
+
payload.len()
|
|
191
|
+
);
|
|
192
|
+
String::new()
|
|
193
|
+
};
|
|
194
|
+
run_command(
|
|
195
|
+
&command,
|
|
196
|
+
&[Placeholder::new("args", &args_value)],
|
|
197
|
+
&config_dir,
|
|
198
|
+
timeout,
|
|
199
|
+
Some(payload.as_bytes()),
|
|
200
|
+
)
|
|
201
|
+
})
|
|
202
|
+
.await
|
|
203
|
+
.map_err(|join_err| QueryFailure::Internal(join_err.to_string()))?;
|
|
204
|
+
|
|
205
|
+
let out = outcome.map_err(|err| QueryFailure::Internal(err.to_string()))?;
|
|
206
|
+
|
|
207
|
+
// The command's payload (last non-empty stdout line) is the JSON result
|
|
208
|
+
// body; reject anything that doesn't parse as JSON.
|
|
209
|
+
serde_json::from_str(&out.payload).map_err(|err| {
|
|
210
|
+
QueryFailure::Internal(format!("post-query did not return valid JSON: {err}"))
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#[cfg(test)]
|
|
215
|
+
mod tests {
|
|
216
|
+
use super::*;
|
|
217
|
+
|
|
218
|
+
// The `DirSqlError::Core => BadRequest` arm of `classify_query_error` is
|
|
219
|
+
// exercised end-to-end at the integration tier by
|
|
220
|
+
// `post_query_malformed_sql_returns_400_not_500` in
|
|
221
|
+
// `tests/cli_integration.rs`, which posts malformed SQL to `/query` and
|
|
222
|
+
// asserts the 400. Constructing a `Core` value inline would require
|
|
223
|
+
// importing the first-party `crate::db::DbError`, which the
|
|
224
|
+
// `testing-conventions` `unit lint` isolation rule forbids (a unit test
|
|
225
|
+
// may reach only `super::` and pure `std`). The non-Core arm below is
|
|
226
|
+
// pure -- it builds a `super::DirSqlError::Lock` -- so it stays inline.
|
|
227
|
+
|
|
228
|
+
#[test]
|
|
229
|
+
fn classify_non_core_error_is_internal() {
|
|
230
|
+
// Lock/watch/config failures are server-side faults -> `Internal`.
|
|
231
|
+
// This drives the `_ =>` arm of `classify_query_error`.
|
|
232
|
+
let err = DirSqlError::Lock("poisoned".into());
|
|
233
|
+
let failure = classify_query_error(err);
|
|
234
|
+
assert!(
|
|
235
|
+
matches!(failure, QueryFailure::Internal(_)),
|
|
236
|
+
"got: {failure:?}"
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#[test]
|
|
241
|
+
fn require_ready_fails_unavailable_when_degraded() {
|
|
242
|
+
// The degraded state yields `Unavailable` carrying the diagnostic
|
|
243
|
+
// verbatim instead of a `DirSQL` handle.
|
|
244
|
+
let state = AppState::Unavailable("config failed to load".into());
|
|
245
|
+
// `DirSQL` isn't `Debug`, so go through `.err()` (which drops the Ok
|
|
246
|
+
// value) rather than `expect_err`.
|
|
247
|
+
let failure = require_ready(&state)
|
|
248
|
+
.err()
|
|
249
|
+
.expect("Unavailable must not yield a db");
|
|
250
|
+
match failure {
|
|
251
|
+
QueryFailure::Unavailable(reason) => assert_eq!(reason, "config failed to load"),
|
|
252
|
+
other => panic!("expected Unavailable, got: {other:?}"),
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// `parse_sql_body` is the no-`pre-query` intake path: it is pure (serde
|
|
257
|
+
// only), so it is unit-tested here directly rather than through the async
|
|
258
|
+
// `execute_query` pipeline (which needs a live index and is covered at
|
|
259
|
+
// the integration tier).
|
|
260
|
+
|
|
261
|
+
#[test]
|
|
262
|
+
fn parse_sql_body_returns_trimmed_sql() {
|
|
263
|
+
// Surrounding whitespace is stripped; the inner SQL is returned as-is.
|
|
264
|
+
let sql = parse_sql_body(r#"{"sql": " SELECT 1 "}"#).expect("valid body");
|
|
265
|
+
assert_eq!(sql, "SELECT 1");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#[test]
|
|
269
|
+
fn parse_sql_body_rejects_malformed_json() {
|
|
270
|
+
// A body that isn't JSON fails at the serde step -> `BadRequest`.
|
|
271
|
+
let failure = parse_sql_body("not json").expect_err("malformed JSON must be rejected");
|
|
272
|
+
assert!(
|
|
273
|
+
matches!(failure, QueryFailure::BadRequest(_)),
|
|
274
|
+
"got: {failure:?}"
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
#[test]
|
|
279
|
+
fn parse_sql_body_rejects_whitespace_only_sql() {
|
|
280
|
+
// A present-but-blank `sql` trims to empty -> `BadRequest` (the
|
|
281
|
+
// `Some(_)` arm, and the `false` side of the `!s.is_empty()` guard).
|
|
282
|
+
let failure = parse_sql_body(r#"{"sql": " "}"#).expect_err("empty sql must be rejected");
|
|
283
|
+
match failure {
|
|
284
|
+
QueryFailure::BadRequest(msg) => assert_eq!(msg, "`sql` must not be empty"),
|
|
285
|
+
other => panic!("expected BadRequest, got: {other:?}"),
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
#[test]
|
|
290
|
+
fn parse_sql_body_rejects_missing_sql_field() {
|
|
291
|
+
// Valid JSON object with no `sql` key -> the `None` arm -> `BadRequest`.
|
|
292
|
+
let failure = parse_sql_body("{}").expect_err("missing sql must be rejected");
|
|
293
|
+
match failure {
|
|
294
|
+
QueryFailure::BadRequest(msg) => assert_eq!(msg, "missing `sql` field"),
|
|
295
|
+
other => panic!("expected BadRequest, got: {other:?}"),
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
#[test]
|
|
300
|
+
fn timeout_failure_message_names_the_duration() {
|
|
301
|
+
// The timeout arm formats its message from the stored duration —
|
|
302
|
+
// the exact text the HTTP 408 body carried before the extraction.
|
|
303
|
+
let failure = QueryFailure::Timeout(Duration::from_secs(30));
|
|
304
|
+
assert_eq!(failure.message(), "query exceeded 30s timeout");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
#[test]
|
|
308
|
+
fn non_timeout_failure_messages_pass_through_verbatim() {
|
|
309
|
+
// The other three arms carry their diagnostic string as-is.
|
|
310
|
+
assert_eq!(QueryFailure::BadRequest("a".into()).message(), "a");
|
|
311
|
+
assert_eq!(QueryFailure::Internal("b".into()).message(), "b");
|
|
312
|
+
assert_eq!(QueryFailure::Unavailable("c".into()).message(), "c");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
//! submodule:
|
|
16
16
|
//!
|
|
17
17
|
//! - [`server`] — bind/serve/shutdown plumbing.
|
|
18
|
-
//! - [`router`] — axum routes + request handlers.
|
|
18
|
+
//! - [`router`] — axum routes + request handlers (thin HTTP adapters).
|
|
19
|
+
//! - [`execute`] — the transport-agnostic query pipeline shared by the
|
|
20
|
+
//! HTTP handler and the one-shot `dirsql query` subcommand.
|
|
19
21
|
//! - [`serialize`] — row + event → JSON.
|
|
20
22
|
|
|
21
23
|
use std::net::SocketAddr;
|
|
@@ -29,6 +31,7 @@ use tokio::task::JoinHandle;
|
|
|
29
31
|
use crate::DirSQL;
|
|
30
32
|
use crate::command::DEFAULT_COMMAND_TIMEOUT;
|
|
31
33
|
|
|
34
|
+
pub mod execute;
|
|
32
35
|
pub mod init;
|
|
33
36
|
pub mod router;
|
|
34
37
|
pub mod serialize;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
//! Axum router, request handlers, and shared context.
|
|
2
|
+
//!
|
|
3
|
+
//! Handlers are thin transport adapters: the whole query pipeline lives in
|
|
4
|
+
//! [`super::execute`], and this module only maps its outcome onto HTTP —
|
|
5
|
+
//! [`QueryFailure`] arms onto status codes, the success value onto a JSON
|
|
6
|
+
//! body.
|
|
7
|
+
|
|
8
|
+
use std::sync::Arc;
|
|
9
|
+
use std::time::Duration;
|
|
10
|
+
|
|
11
|
+
use axum::Json;
|
|
12
|
+
use axum::Router;
|
|
13
|
+
use axum::extract::State;
|
|
14
|
+
use axum::http::{HeaderValue, StatusCode, header};
|
|
15
|
+
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
|
|
16
|
+
use axum::response::{IntoResponse, Response};
|
|
17
|
+
use axum::routing::{get, post};
|
|
18
|
+
use futures::stream::StreamExt;
|
|
19
|
+
use serde_json::json;
|
|
20
|
+
use tokio::sync::{broadcast, watch};
|
|
21
|
+
use tokio_stream::wrappers::BroadcastStream;
|
|
22
|
+
|
|
23
|
+
use super::execute::{QueryFailure, execute_query, require_ready};
|
|
24
|
+
use super::{AppState, PostQuery, PreQuery};
|
|
25
|
+
|
|
26
|
+
pub(super) struct AppContext {
|
|
27
|
+
pub state: AppState,
|
|
28
|
+
pub events: broadcast::Sender<String>,
|
|
29
|
+
pub cancel: watch::Receiver<bool>,
|
|
30
|
+
pub query_timeout: Duration,
|
|
31
|
+
/// Optional server-wide `pre-query` hook. When `Some`, `POST /query`
|
|
32
|
+
/// rewrites the request body through the command; when `None`, the body
|
|
33
|
+
/// is parsed as `{"sql": …}`.
|
|
34
|
+
pub pre_query: Option<PreQuery>,
|
|
35
|
+
/// Optional server-wide `post-query` hook. When `Some`, a successful
|
|
36
|
+
/// `POST /query` result set is reshaped by the command before responding;
|
|
37
|
+
/// when `None`, the rows are returned as-is.
|
|
38
|
+
pub post_query: Option<PostQuery>,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
pub(super) type SharedCtx = Arc<AppContext>;
|
|
42
|
+
|
|
43
|
+
pub(super) fn router(ctx: SharedCtx) -> Router {
|
|
44
|
+
Router::new()
|
|
45
|
+
.route(
|
|
46
|
+
"/query",
|
|
47
|
+
post(handle_query).on(axum::routing::MethodFilter::GET, method_not_allowed),
|
|
48
|
+
)
|
|
49
|
+
.route(
|
|
50
|
+
"/events",
|
|
51
|
+
get(handle_events).on(axum::routing::MethodFilter::POST, method_not_allowed),
|
|
52
|
+
)
|
|
53
|
+
.with_state(ctx)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async fn handle_query(State(ctx): State<SharedCtx>, body: String) -> Response {
|
|
57
|
+
match execute_query(
|
|
58
|
+
&ctx.state,
|
|
59
|
+
body,
|
|
60
|
+
ctx.query_timeout,
|
|
61
|
+
ctx.pre_query.as_ref(),
|
|
62
|
+
ctx.post_query.as_ref(),
|
|
63
|
+
)
|
|
64
|
+
.await
|
|
65
|
+
{
|
|
66
|
+
Ok(value) => Json(value).into_response(),
|
|
67
|
+
Err(failure) => failure_response(&failure),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// The HTTP status for each [`QueryFailure`] arm. This mapping — together
|
|
72
|
+
/// with [`failure_response`]'s `{"error": …}` body — is the entirety of the
|
|
73
|
+
/// HTTP adapter's own behavior; everything else is the shared pipeline.
|
|
74
|
+
fn failure_status(failure: &QueryFailure) -> StatusCode {
|
|
75
|
+
match failure {
|
|
76
|
+
QueryFailure::BadRequest(_) => StatusCode::BAD_REQUEST,
|
|
77
|
+
QueryFailure::Timeout(_) => StatusCode::REQUEST_TIMEOUT,
|
|
78
|
+
QueryFailure::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
79
|
+
QueryFailure::Unavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
fn failure_response(failure: &QueryFailure) -> Response {
|
|
84
|
+
error_response(failure_status(failure), failure.message())
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async fn handle_events(State(ctx): State<SharedCtx>) -> Response {
|
|
88
|
+
if let Err(failure) = require_ready(&ctx.state) {
|
|
89
|
+
return failure_response(&failure);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Subscribe BEFORE anything that might block so we don't drop events
|
|
93
|
+
// that fire between subscribing and the first poll.
|
|
94
|
+
let rx = ctx.events.subscribe();
|
|
95
|
+
let events = BroadcastStream::new(rx).filter_map(|res| async move {
|
|
96
|
+
match res {
|
|
97
|
+
Ok(data) => Some(Ok::<SseEvent, std::convert::Infallible>(
|
|
98
|
+
SseEvent::default().event("row").data(data),
|
|
99
|
+
)),
|
|
100
|
+
// Lagging subscriber: skip missed events rather than terminating.
|
|
101
|
+
Err(_) => None,
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Yield a ready event up front so clients have a reliable signal that
|
|
106
|
+
// the subscription is attached. Data is non-empty because SSE parsers
|
|
107
|
+
// skip events with no `data:` line.
|
|
108
|
+
let ready = futures::stream::once(async {
|
|
109
|
+
Ok::<SseEvent, std::convert::Infallible>(SseEvent::default().event("ready").data("{}"))
|
|
110
|
+
});
|
|
111
|
+
let combined = ready.chain(events);
|
|
112
|
+
|
|
113
|
+
// Close the stream when the server's cancellation signal fires so
|
|
114
|
+
// graceful shutdown actually completes (otherwise SSE streams hold
|
|
115
|
+
// axum's in-flight count at > 0 indefinitely).
|
|
116
|
+
let mut cancel = ctx.cancel.clone();
|
|
117
|
+
let stream = combined.take_until(async move {
|
|
118
|
+
let _ = cancel.wait_for(|v| *v).await;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
Sse::new(stream)
|
|
122
|
+
.keep_alive(KeepAlive::default())
|
|
123
|
+
.into_response()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async fn method_not_allowed() -> Response {
|
|
127
|
+
(StatusCode::METHOD_NOT_ALLOWED, "method not allowed").into_response()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
pub(super) fn error_response(status: StatusCode, message: impl Into<String>) -> Response {
|
|
131
|
+
let body = json!({ "error": message.into() });
|
|
132
|
+
let mut resp = (status, Json(body)).into_response();
|
|
133
|
+
resp.headers_mut().insert(
|
|
134
|
+
header::CONTENT_TYPE,
|
|
135
|
+
HeaderValue::from_static("application/json"),
|
|
136
|
+
);
|
|
137
|
+
resp
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#[cfg(test)]
|
|
141
|
+
mod tests {
|
|
142
|
+
use super::*;
|
|
143
|
+
|
|
144
|
+
// The pipeline itself (intake validation, hooks, timeout, execution,
|
|
145
|
+
// error classification) is unit-tested in `cli/execute.rs` and covered
|
|
146
|
+
// end-to-end by `tests/cli_integration.rs`. What remains here is the
|
|
147
|
+
// HTTP adapter's own behavior: the failure -> status mapping and the
|
|
148
|
+
// JSON error body.
|
|
149
|
+
|
|
150
|
+
#[test]
|
|
151
|
+
fn failure_status_maps_each_arm_to_its_http_status() {
|
|
152
|
+
assert_eq!(
|
|
153
|
+
failure_status(&QueryFailure::BadRequest("x".into())),
|
|
154
|
+
StatusCode::BAD_REQUEST
|
|
155
|
+
);
|
|
156
|
+
assert_eq!(
|
|
157
|
+
failure_status(&QueryFailure::Timeout(Duration::from_secs(30))),
|
|
158
|
+
StatusCode::REQUEST_TIMEOUT
|
|
159
|
+
);
|
|
160
|
+
assert_eq!(
|
|
161
|
+
failure_status(&QueryFailure::Internal("x".into())),
|
|
162
|
+
StatusCode::INTERNAL_SERVER_ERROR
|
|
163
|
+
);
|
|
164
|
+
assert_eq!(
|
|
165
|
+
failure_status(&QueryFailure::Unavailable("x".into())),
|
|
166
|
+
StatusCode::SERVICE_UNAVAILABLE
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#[test]
|
|
171
|
+
fn failure_response_carries_the_status_and_message() {
|
|
172
|
+
// The degraded arm renders as the 503 JSON error the server always
|
|
173
|
+
// returned; the message travels through `QueryFailure::message`.
|
|
174
|
+
let resp = failure_response(&QueryFailure::Unavailable("config failed to load".into()));
|
|
175
|
+
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
176
|
+
assert_eq!(
|
|
177
|
+
resp.headers().get(header::CONTENT_TYPE).unwrap(),
|
|
178
|
+
"application/json"
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#[test]
|
|
183
|
+
fn error_response_sets_json_content_type() {
|
|
184
|
+
let resp = error_response(StatusCode::BAD_REQUEST, "boom");
|
|
185
|
+
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
186
|
+
assert_eq!(
|
|
187
|
+
resp.headers().get(header::CONTENT_TYPE).unwrap(),
|
|
188
|
+
"application/json"
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|