Skip to content

Commit 708329c

Browse files
committed
refactor(pgwire): split ddl/dsl.rs into per-concern modules
dsl.rs has grown to cover DDL parsing for search indexes, CRDT merge, FTS, vector, sparse, and fusion in a single flat file. Split it into a dsl/ directory with one module per concern: crdt_merge, fulltext_index, search_fusion, search_index, search_vector, sparse_index, vector_index, helpers, mod No logic changes — pure file reorganisation.
1 parent fe6a3eb commit 708329c

10 files changed

Lines changed: 705 additions & 594 deletions

File tree

nodedb/src/control/server/pgwire/ddl/dsl.rs

Lines changed: 0 additions & 594 deletions
This file was deleted.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
//! `CRDT MERGE INTO` DSL handler.
2+
3+
use std::time::Duration;
4+
5+
use pgwire::api::results::{Response, Tag};
6+
use pgwire::error::PgWireResult;
7+
8+
use crate::bridge::envelope::PhysicalPlan;
9+
use crate::bridge::physical_plan::CrdtOp;
10+
use crate::control::security::identity::AuthenticatedIdentity;
11+
use crate::control::server::pgwire::types::sqlstate_error;
12+
use crate::control::state::SharedState;
13+
14+
/// CRDT MERGE INTO <collection> FROM '<source_id>' TO '<target_id>'
15+
pub async fn crdt_merge(
16+
state: &SharedState,
17+
identity: &AuthenticatedIdentity,
18+
parts: &[&str],
19+
) -> PgWireResult<Vec<Response>> {
20+
if parts.len() < 7 {
21+
return Err(sqlstate_error(
22+
"42601",
23+
"syntax: CRDT MERGE INTO <collection> FROM '<source_id>' TO '<target_id>'",
24+
));
25+
}
26+
27+
let collection = parts[3];
28+
let tenant_id = identity.tenant_id;
29+
30+
let from_idx = parts
31+
.iter()
32+
.position(|p| p.eq_ignore_ascii_case("FROM"))
33+
.ok_or_else(|| sqlstate_error("42601", "expected FROM keyword"))?;
34+
let to_idx = parts
35+
.iter()
36+
.position(|p| p.eq_ignore_ascii_case("TO"))
37+
.ok_or_else(|| sqlstate_error("42601", "expected TO keyword"))?;
38+
39+
let source_id = parts
40+
.get(from_idx + 1)
41+
.map(|s| s.trim_matches('\'').trim_matches('"'))
42+
.ok_or_else(|| sqlstate_error("42601", "missing source document ID"))?;
43+
let target_id = parts
44+
.get(to_idx + 1)
45+
.map(|s| s.trim_matches('\'').trim_matches('"'))
46+
.ok_or_else(|| sqlstate_error("42601", "missing target document ID"))?;
47+
48+
let source_plan = PhysicalPlan::Crdt(CrdtOp::Read {
49+
collection: collection.to_string(),
50+
document_id: source_id.to_string(),
51+
});
52+
53+
let source_bytes = crate::control::server::pgwire::ddl::sync_dispatch::dispatch_async(
54+
state,
55+
tenant_id,
56+
collection,
57+
source_plan,
58+
Duration::from_secs(state.tuning.network.default_deadline_secs),
59+
)
60+
.await
61+
.map_err(|e| sqlstate_error("XX000", &e.to_string()))?;
62+
if source_bytes.is_empty() {
63+
return Err(sqlstate_error(
64+
"02000",
65+
&format!("source document '{source_id}' not found"),
66+
));
67+
}
68+
69+
let apply_plan = PhysicalPlan::Crdt(CrdtOp::Apply {
70+
collection: collection.to_string(),
71+
document_id: target_id.to_string(),
72+
delta: source_bytes,
73+
peer_id: identity.user_id,
74+
mutation_id: 0,
75+
});
76+
77+
crate::control::server::pgwire::ddl::sync_dispatch::dispatch_async(
78+
state,
79+
tenant_id,
80+
collection,
81+
apply_plan,
82+
Duration::from_secs(state.tuning.network.default_deadline_secs),
83+
)
84+
.await
85+
.map_err(|e| sqlstate_error("XX000", &e.to_string()))?;
86+
87+
state.audit_record(
88+
crate::control::security::audit::AuditEvent::AdminAction,
89+
Some(tenant_id),
90+
&identity.username,
91+
&format!("CRDT merge: {source_id} → {target_id} in '{collection}'"),
92+
);
93+
94+
Ok(vec![Response::Execution(Tag::new("CRDT MERGE"))])
95+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//! `CREATE FULLTEXT INDEX` DSL handler.
2+
3+
use pgwire::api::results::{Response, Tag};
4+
use pgwire::error::PgWireResult;
5+
6+
use crate::control::security::identity::AuthenticatedIdentity;
7+
use crate::control::server::pgwire::types::sqlstate_error;
8+
use crate::control::state::SharedState;
9+
10+
/// CREATE FULLTEXT INDEX <name> ON <collection> (<field>)
11+
pub fn create_fulltext_index(
12+
state: &SharedState,
13+
identity: &AuthenticatedIdentity,
14+
parts: &[&str],
15+
) -> PgWireResult<Vec<Response>> {
16+
if parts.len() < 7 {
17+
return Err(sqlstate_error(
18+
"42601",
19+
"syntax: CREATE FULLTEXT INDEX <name> ON <collection> (<field>)",
20+
));
21+
}
22+
23+
let index_name = parts[3];
24+
if !parts[4].eq_ignore_ascii_case("ON") {
25+
return Err(sqlstate_error("42601", "expected ON after index name"));
26+
}
27+
let collection = parts[5];
28+
let field = parts[6].trim_matches(|c| c == '(' || c == ')');
29+
let tenant_id = identity.tenant_id;
30+
31+
super::super::owner_propose::propose_owner(
32+
state,
33+
"fulltext_index",
34+
tenant_id,
35+
index_name,
36+
&identity.username,
37+
)?;
38+
39+
state.audit_record(
40+
crate::control::security::audit::AuditEvent::AdminAction,
41+
Some(tenant_id),
42+
&identity.username,
43+
&format!("created fulltext index '{index_name}' on '{collection}' ({field})"),
44+
);
45+
46+
Ok(vec![Response::Execution(Tag::new("CREATE FULLTEXT INDEX"))])
47+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//! Shared parameter-extraction helpers for DSL handlers.
2+
3+
pub(super) fn extract_param(upper: &str, name: &str) -> Option<usize> {
4+
let idx = upper.find(name)?;
5+
let rest = &upper[idx + name.len()..];
6+
rest.split(|c: char| !c.is_ascii_digit())
7+
.find(|s| !s.is_empty())
8+
.and_then(|s| s.parse().ok())
9+
}
10+
11+
pub(super) fn extract_string_param(sql: &str, name: &str) -> Option<String> {
12+
let upper = sql.to_uppercase();
13+
let idx = upper.find(name)?;
14+
let rest = &sql[idx + name.len()..];
15+
let rest = rest.trim();
16+
if rest.starts_with('\'') || rest.starts_with('"') {
17+
let quote = rest.chars().next()?;
18+
let end = rest[1..].find(quote)?;
19+
Some(rest[1..end + 1].to_string())
20+
} else {
21+
rest.split_whitespace().next().map(|s| s.to_string())
22+
}
23+
}
24+
25+
pub(super) fn find_param_str(upper_parts: &[String], name: &str) -> Option<String> {
26+
let idx = upper_parts.iter().position(|p| p == name)?;
27+
upper_parts.get(idx + 1).cloned()
28+
}
29+
30+
pub(super) fn find_param_usize(upper_parts: &[String], name: &str) -> Option<usize> {
31+
let idx = upper_parts.iter().position(|p| p == name)?;
32+
upper_parts
33+
.get(idx + 1)
34+
.and_then(|s| s.parse::<usize>().ok())
35+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//! NodeDB DSL extensions — custom SQL-like commands beyond standard SQL.
2+
//!
3+
//! - SEARCH <collection> USING VECTOR(<field>, ARRAY[...], <k>)
4+
//! - SEARCH <collection> USING FUSION(vector=..., graph=..., top_k=...)
5+
//! - CREATE VECTOR INDEX <name> ON <collection> [METRIC ...] [M ...] [EF_CONSTRUCTION ...] [DIM ...]
6+
//! [INDEX_TYPE hnsw|hnsw_pq|ivf_pq] [PQ_M ...] [IVF_CELLS ...] [IVF_NPROBE ...]
7+
//! - CREATE FULLTEXT INDEX <name> ON <collection> (<field>)
8+
//! - CREATE SEARCH INDEX ON <collection> FIELDS ...
9+
//! - CREATE SPARSE INDEX [name] ON <collection> (<field>)
10+
//! - CRDT MERGE INTO <collection> FROM <source_id> TO <target_id>
11+
12+
mod crdt_merge;
13+
mod fulltext_index;
14+
mod helpers;
15+
mod search_fusion;
16+
mod search_index;
17+
mod search_vector;
18+
mod sparse_index;
19+
mod vector_index;
20+
21+
pub use crdt_merge::crdt_merge;
22+
pub use fulltext_index::create_fulltext_index;
23+
pub use search_fusion::search_fusion;
24+
pub use search_index::create_search_index;
25+
pub use search_vector::search_vector;
26+
pub use sparse_index::create_sparse_index;
27+
pub use vector_index::create_vector_index;
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
//! `SEARCH <collection> USING FUSION(...)` DSL (vector + graph fusion).
2+
3+
use std::sync::Arc;
4+
use std::time::Duration;
5+
6+
use futures::stream;
7+
use pgwire::api::results::{DataRowEncoder, QueryResponse, Response};
8+
use pgwire::error::PgWireResult;
9+
10+
use crate::bridge::envelope::PhysicalPlan;
11+
use crate::bridge::physical_plan::GraphOp;
12+
use crate::control::security::identity::AuthenticatedIdentity;
13+
use crate::control::server::pgwire::types::{sqlstate_error, text_field};
14+
use crate::control::state::SharedState;
15+
16+
use super::helpers::{extract_param, extract_string_param};
17+
18+
/// SEARCH <collection> USING FUSION(VECTOR(ARRAY[...], <k>), GRAPH(<label>, <depth>), TOP <n>)
19+
pub async fn search_fusion(
20+
state: &SharedState,
21+
identity: &AuthenticatedIdentity,
22+
sql: &str,
23+
) -> PgWireResult<Vec<Response>> {
24+
let parts: Vec<&str> = sql.split_whitespace().collect();
25+
if parts.len() < 4 {
26+
return Err(sqlstate_error(
27+
"42601",
28+
"syntax: SEARCH <collection> USING FUSION(...)",
29+
));
30+
}
31+
let collection = parts[1];
32+
let tenant_id = identity.tenant_id;
33+
34+
let array_start = sql.find("ARRAY[").or_else(|| sql.find("array["));
35+
let array_start = match array_start {
36+
Some(i) => i + 6,
37+
None => {
38+
return Err(sqlstate_error("42601", "expected ARRAY[...] in FUSION"));
39+
}
40+
};
41+
let array_end = sql[array_start..].find(']').map(|i| i + array_start);
42+
let array_end = match array_end {
43+
Some(i) => i,
44+
None => {
45+
return Err(sqlstate_error("42601", "unterminated ARRAY["));
46+
}
47+
};
48+
49+
let vector_str = &sql[array_start..array_end];
50+
let query_vector: Vec<f32> = vector_str
51+
.split(',')
52+
.filter_map(|s| s.trim().parse::<f32>().ok())
53+
.collect();
54+
55+
if query_vector.is_empty() {
56+
return Err(sqlstate_error("42601", "empty query vector in FUSION"));
57+
}
58+
59+
let upper = sql.to_uppercase();
60+
let vector_top_k = extract_param(&upper, "VECTOR_TOP_K").unwrap_or(20);
61+
let expansion_depth = extract_param(&upper, "DEPTH").unwrap_or(2);
62+
let final_top_k = extract_param(&upper, "TOP").unwrap_or(10);
63+
64+
let edge_label = extract_string_param(sql, "LABEL");
65+
66+
let plan = PhysicalPlan::Graph(GraphOp::RagFusion {
67+
collection: collection.to_string(),
68+
query_vector: Arc::from(query_vector.as_slice()),
69+
vector_top_k,
70+
edge_label,
71+
direction: crate::engine::graph::edge_store::Direction::Out,
72+
expansion_depth,
73+
final_top_k,
74+
rrf_k: (60.0, 60.0),
75+
options: crate::engine::graph::traversal_options::GraphTraversalOptions::default(),
76+
});
77+
78+
let payload = crate::control::server::pgwire::ddl::sync_dispatch::dispatch_async(
79+
state,
80+
tenant_id,
81+
collection,
82+
plan,
83+
Duration::from_secs(state.tuning.network.default_deadline_secs),
84+
)
85+
.await
86+
.map_err(|e| sqlstate_error("XX000", &e.to_string()))?;
87+
88+
let schema = Arc::new(vec![text_field("result")]);
89+
let text = crate::data::executor::response_codec::decode_payload_to_json(&payload);
90+
let mut encoder = DataRowEncoder::new(schema.clone());
91+
encoder
92+
.encode_field(&text)
93+
.map_err(|e| sqlstate_error("XX000", &e.to_string()))?;
94+
let row = encoder.take_row();
95+
96+
Ok(vec![Response::Query(QueryResponse::new(
97+
schema,
98+
stream::iter(vec![Ok(row)]),
99+
))])
100+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//! `CREATE SEARCH INDEX` DSL handler (higher-level alias for fulltext).
2+
3+
use pgwire::api::results::{Response, Tag};
4+
use pgwire::error::PgWireResult;
5+
6+
use crate::control::security::identity::AuthenticatedIdentity;
7+
use crate::control::server::pgwire::types::sqlstate_error;
8+
use crate::control::state::SharedState;
9+
10+
/// CREATE SEARCH INDEX ON <collection> FIELDS <field1>[, <field2>...] [ANALYZER '<name>'] [FUZZY true|false]
11+
pub fn create_search_index(
12+
state: &SharedState,
13+
identity: &AuthenticatedIdentity,
14+
sql: &str,
15+
) -> PgWireResult<Vec<Response>> {
16+
let upper = sql.to_uppercase();
17+
18+
let on_pos = upper.find(" ON ").ok_or_else(|| {
19+
sqlstate_error(
20+
"42601",
21+
"syntax: CREATE SEARCH INDEX ON <collection> FIELDS <field> [ANALYZER 'name'] [FUZZY true]",
22+
)
23+
})?;
24+
let after_on = sql[on_pos + 4..].trim_start();
25+
let fields_pos = upper.find(" FIELDS ").ok_or_else(|| {
26+
sqlstate_error(
27+
"42601",
28+
"syntax: CREATE SEARCH INDEX ON <collection> FIELDS <field> [ANALYZER 'name'] [FUZZY true]",
29+
)
30+
})?;
31+
32+
let collection = after_on[..fields_pos - on_pos - 4].trim().to_lowercase();
33+
if collection.is_empty() {
34+
return Err(sqlstate_error("42601", "missing collection name"));
35+
}
36+
37+
let after_fields = &sql[fields_pos + 8..];
38+
let fields_end = upper[fields_pos + 8..]
39+
.find(" ANALYZER ")
40+
.or_else(|| upper[fields_pos + 8..].find(" FUZZY "))
41+
.unwrap_or(after_fields.len());
42+
let fields_str = after_fields[..fields_end].trim();
43+
let fields: Vec<&str> = fields_str.split(',').map(|s| s.trim()).collect();
44+
45+
if fields.is_empty() || fields[0].is_empty() {
46+
return Err(sqlstate_error("42601", "missing field list"));
47+
}
48+
49+
let tenant_id = identity.tenant_id;
50+
51+
for field in &fields {
52+
let index_name = format!("fts_{}_{}", collection, field);
53+
54+
super::super::owner_propose::propose_owner(
55+
state,
56+
"fulltext_index",
57+
tenant_id,
58+
&index_name,
59+
&identity.username,
60+
)?;
61+
62+
state.audit_record(
63+
crate::control::security::audit::AuditEvent::AdminAction,
64+
Some(tenant_id),
65+
&identity.username,
66+
&format!("created search index '{index_name}' on '{collection}' ({field})"),
67+
);
68+
}
69+
70+
Ok(vec![Response::Execution(Tag::new("CREATE SEARCH INDEX"))])
71+
}

0 commit comments

Comments
 (0)