Skip to content

Commit ec05acc

Browse files
committed
Merge origin/security into security
2 parents ec34eb8 + dd96527 commit ec05acc

202 files changed

Lines changed: 9836 additions & 4420 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/full-text-search.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ CREATE SEARCH INDEX ON articles FIELDS title, body
3939
ANALYZER 'english'
4040
FUZZY true;
4141

42+
-- `CREATE FULLTEXT INDEX` is an exact alias, and the column list may also be
43+
-- written in parentheses. ANALYZER takes 'standard' or a supported language
44+
-- name; an unknown name is rejected rather than silently falling back.
45+
-- FUZZY sets the collection default — queries may still opt in per call.
46+
CREATE FULLTEXT INDEX idx_articles_text ON articles (title, body) ANALYZER 'english';
47+
4248
-- Basic search with text_match
4349
SELECT title, bm25_score(body, 'distributed database rust') AS score
4450
FROM articles

docs/spatial.md

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ Spatial is a **columnar profile**. Collections with a `SPATIAL_INDEX` column mod
1717
- **R\*-tree index** — Bulk loading, nearest neighbor, range queries
1818
- **Geohash** — Encode/decode, neighbor cells, area covering
1919
- **H3 hexagonal index** — Uber's H3 via h3o for uniform-area spatial binning
20-
- **OGC predicates**`ST_Contains`, `ST_Intersects`, `ST_Within`, `ST_DWithin`, `ST_Distance`, `ST_Intersection`, `ST_Buffer`, `ST_Envelope`, `ST_Union`
21-
- **Geometry constructors**`ST_MakePoint`, `ST_GeomFromText` (WKT), `ST_GeomFromWKB`, `ST_GeomFromGeoJSON` — usable directly in `INSERT VALUES`
20+
- **OGC predicates**`ST_Contains`, `ST_Intersects`, `ST_Within`, `ST_Disjoint`, `ST_DWithin`, `ST_IsValid`
21+
- **Geometry constructors**`ST_Point`, `ST_MakePoint`, `ST_GeomFromText` (WKT), `ST_GeomFromWKB`, `ST_GeomFromGeoJSON`, `ST_MakeLine`, `ST_MakePolygon`, `ST_MakeEnvelope`
22+
- **Accessors and measures**`ST_AsText`, `ST_AsGeoJSON`, `ST_X`, `ST_Y`, `ST_GeometryType`, `ST_NPoints`, `ST_SRID`, `ST_Distance`, `ST_Length`, `ST_Perimeter`, `ST_Area`
23+
- **Geometry operations**`ST_Buffer`, `ST_Envelope`, `ST_Centroid`, `ST_Union`, `ST_Intersection`
2224
- **Format support** — WKB, WKT, GeoJSON interchange. GeoParquet v1.1.0 + GeoArrow metadata.
2325
- **Hybrid spatial-vector** — Spatial R\*-tree narrows candidates by location, then HNSW ranks by semantic similarity in one query
2426
- **Spatial join** — R\*-tree probe join between two collections
@@ -82,6 +84,84 @@ FROM restaurants r, delivery_zones z
8284
WHERE ST_Contains(z.boundary, r.location);
8385
```
8486

87+
## Geometry Functions
88+
89+
Every geometry function works in every position a geometry expression may
90+
appear: an `INSERT` value, a `SELECT` projection, and a spatial predicate's
91+
query-geometry argument. Constructors nest, so `ST_Buffer(ST_Point(...), 500)`
92+
is a valid search area.
93+
94+
A string literal in geometry position is read as either WKT or GeoJSON, so
95+
`ST_DWithin(loc, 'POINT(1 2)', 500)` and
96+
`ST_DWithin(loc, '{"type":"Point","coordinates":[1,2]}', 500)` are equivalent.
97+
98+
### Constructors
99+
100+
| Function | Result |
101+
| --- | --- |
102+
| `ST_Point(lng, lat)` | Point |
103+
| `ST_MakePoint(x, y [, z])` | Point |
104+
| `ST_GeomFromText(wkt [, srid])` | Geometry parsed from WKT |
105+
| `ST_GeomFromGeoJSON(json)` | Geometry parsed from GeoJSON |
106+
| `ST_GeomFromWKB(bytes [, srid])` | Geometry parsed from WKB; accepts an `X'...'` literal or its hex string |
107+
| `ST_MakeLine(point, point, ...)` | LineString |
108+
| `ST_MakePolygon(ring, ...)` | Polygon, each ring an array of `[lng, lat]` pairs |
109+
| `ST_MakeEnvelope(min_lng, min_lat, max_lng, max_lat)` | Rectangular Polygon |
110+
111+
### Accessors
112+
113+
| Function | Result |
114+
| --- | --- |
115+
| `ST_AsText(geom)` | WKT rendering |
116+
| `ST_AsGeoJSON(geom)` | GeoJSON rendering |
117+
| `ST_X(geom)` / `ST_Y(geom)` | Ordinate of a Point; NULL for any other geometry |
118+
| `ST_GeometryType(geom)` | Type name, e.g. `Point` |
119+
| `ST_NPoints(geom)` | Total vertex count |
120+
| `ST_SRID(geom)` | Always `4326` — geometry is stored as GeoJSON, which RFC 7946 defines as WGS 84 |
121+
| `ST_IsValid(geom)` | Whether the geometry is well-formed |
122+
123+
### Measures
124+
125+
Every measure is geodesic: lengths in meters, areas in square meters.
126+
127+
| Function | Result |
128+
| --- | --- |
129+
| `ST_Distance(a, b)` | Distance between two geometries |
130+
| `ST_Length(geom)` | Total length of linear components; `0` for points and polygons |
131+
| `ST_Perimeter(geom)` | Boundary length of areal components, holes included |
132+
| `ST_Area(geom)` | Area of areal components, less holes; `0` for points and lines |
133+
134+
### Predicates and operations
135+
136+
| Function | Result |
137+
| --- | --- |
138+
| `ST_Contains(a, b)` | Whether `a` contains `b` |
139+
| `ST_Within(a, b)` | Whether `a` lies within `b` — the converse of `ST_Contains` |
140+
| `ST_Intersects(a, b)` / `ST_Disjoint(a, b)` | Whether the two geometries share any point |
141+
| `ST_DWithin(a, b, meters)` | Whether the two geometries lie within a geodesic distance |
142+
| `ST_Buffer(geom, meters [, segments])` | Geometry grown by a distance |
143+
| `ST_Envelope(geom)` | Bounding rectangle |
144+
| `ST_Centroid(geom)` | Centroid, weighted by the highest dimension present |
145+
| `ST_Union(a, b)` / `ST_Intersection(a, b)` | Combined / shared geometry |
146+
147+
`ST_Contains`, `ST_Within`, `ST_Intersects` and `ST_DWithin` are answered from
148+
the R\*-tree when the first argument is an indexed geometry column; the rest
149+
evaluate per row.
150+
151+
```sql
152+
-- Read stored geometry back in either interchange format
153+
SELECT name, ST_AsText(location), ST_X(location), ST_Y(location)
154+
FROM restaurants;
155+
156+
-- Measure and describe
157+
SELECT name, ST_GeometryType(boundary), ST_Area(boundary), ST_Perimeter(boundary)
158+
FROM delivery_zones;
159+
160+
-- A buffered point is a valid query geometry
161+
SELECT name FROM restaurants
162+
WHERE ST_Within(location, ST_Buffer(ST_Point(-73.990, 40.750), 1000));
163+
```
164+
85165
## Spatial as a Peer Engine
86166

87167
The `SPATIAL_INDEX` column modifier designates a geometry column for automatic R\*-tree indexing. Spatial is a peer engine alongside columnar, timeseries, and the rest:

docs/vectors.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ NodeDB's vector engine is built for production semantic search — not vectors s
3636
CREATE COLLECTION articles;
3737
CREATE VECTOR INDEX idx_articles_embedding ON articles METRIC cosine DIM 384;
3838

39+
-- DIM is required and is enforced on every write: an embedding of any other
40+
-- width is rejected at INSERT rather than quietly indexed at the wrong size.
41+
-- Name the column explicitly to carry several embeddings on one collection.
42+
CREATE VECTOR INDEX idx_articles_clip ON articles (clip_embedding) METRIC cosine DIM 512;
43+
3944
-- Insert documents with embeddings
4045
INSERT INTO articles {
4146
title: 'Understanding Transformers',

nodedb-cluster-tests/tests/cluster_array.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ async fn cluster_array_vector_prefilter_distributed() {
285285
.expect("CREATE COLLECTION genes");
286286
cluster
287287
.exec_ddl_on_any_leader(
288-
"CREATE VECTOR INDEX idx_genes_emb ON genes FIELD embedding METRIC cosine DIM 3",
288+
"CREATE VECTOR INDEX idx_genes_emb ON genes (embedding) METRIC cosine DIM 3",
289289
)
290290
.await
291291
.expect("CREATE VECTOR INDEX");

nodedb-fts/src/index/analyzer_config.rs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
//! Per-collection analyzer configuration stored in backend metadata.
44
//!
55
//! Uses structural `(tid, collection, subkey)` meta blobs:
6-
//! - `subkey = "analyzer"` → analyzer name (e.g. "german", "cjk_bigram")
6+
//! - `subkey = "analyzer"` → analyzer name (e.g. "german", "standard")
77
//! - `subkey = "language"` → lang code (e.g. "de", "ja")
8+
//! - `subkey = "fuzzy"` → `"1"` when the collection defaults to fuzzy matching
89
//!
910
//! Applied automatically at both index time and query time.
1011
@@ -49,6 +50,37 @@ impl<B: FtsBackend> FtsIndex<B> {
4950
)
5051
}
5152

53+
/// Set whether searches over this collection fall back to fuzzy
54+
/// (Levenshtein) matching by default. Persists to backend metadata.
55+
pub fn set_collection_fuzzy(
56+
&self,
57+
database_id: u64,
58+
tid: u64,
59+
collection: &str,
60+
fuzzy: bool,
61+
) -> Result<(), B::Error> {
62+
self.backend.write_meta(
63+
database_id,
64+
tid,
65+
collection,
66+
"fuzzy",
67+
if fuzzy { b"1" } else { b"0" },
68+
)
69+
}
70+
71+
/// Whether this collection defaults to fuzzy matching. `false` when unset.
72+
pub fn get_collection_fuzzy(
73+
&self,
74+
database_id: u64,
75+
tid: u64,
76+
collection: &str,
77+
) -> Result<bool, B::Error> {
78+
Ok(self
79+
.backend
80+
.read_meta(database_id, tid, collection, "fuzzy")?
81+
.is_some_and(|bytes| bytes.as_slice() == b"1"))
82+
}
83+
5284
/// Get the configured analyzer name for a collection.
5385
pub fn get_collection_analyzer(
5486
&self,
@@ -115,6 +147,20 @@ impl<B: FtsBackend> FtsIndex<B> {
115147
}
116148
}
117149

150+
/// Whether `name` resolves to a real analyzer.
151+
///
152+
/// [`resolve_analyzer`] falls back to the standard analyzer for anything it
153+
/// does not know, which silently turns a misspelled analyzer name into a
154+
/// different analyzer. Callers that accept a name from a user — DDL, config —
155+
/// gate on this first so the fallback is only ever reached for a name that
156+
/// was already checked. The two must stay in step: every arm below has a
157+
/// matching arm there.
158+
pub fn analyzer_exists(name: &str) -> bool {
159+
name == "standard"
160+
|| LanguageAnalyzer::new(name).is_some()
161+
|| NoStemAnalyzer::new(name).is_some()
162+
}
163+
118164
/// Resolve an analyzer name to a `Box<dyn TextAnalyzer>`.
119165
fn resolve_analyzer(name: &str) -> Box<dyn TextAnalyzer> {
120166
match name {

nodedb-fts/src/search/bm25_search.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@ impl<B: FtsBackend> FtsIndex<B> {
8282
mode,
8383
prefilter,
8484
} = params;
85+
// A collection configured with `FUZZY true` falls back to fuzzy
86+
// matching even when the query did not ask for it — that is what
87+
// makes it an index property rather than a per-query flag. Resolved
88+
// here, at the one point every search path funnels through, so no
89+
// caller can be wired up without it.
90+
let fuzzy_enabled = fuzzy_enabled
91+
|| self
92+
.get_collection_fuzzy(database_id, tid, collection)
93+
.map_err(FtsIndexError::backend)?;
94+
8595
// Parse the query for NOT / - negation operators before analysis.
8696
let parsed = parse_query(query)?;
8797

nodedb-physical/src/physical_plan/document/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub mod enforcement_types;
66
pub mod merge_types;
77
pub mod ollp_edge;
88
pub mod op;
9+
pub mod timeseries_schema;
910
pub mod types;
1011
pub mod update_value;
1112

@@ -15,6 +16,7 @@ pub use enforcement_types::{
1516
pub use merge_types::{MergeActionOp, MergeClauseKind as MergeClauseKindOp, MergeClauseOp};
1617
pub use ollp_edge::OllpPredictedEdge;
1718
pub use op::DocumentOp;
19+
pub use timeseries_schema::TimeseriesSchema;
1820
pub use types::{
1921
BalancedDef, EnforcementOptions, GeneratedColumnSpec, MaterializedSumBinding, PeriodLockConfig,
2022
RegisteredIndex, RegisteredIndexState, ReturningColumns, ReturningItem, ReturningSpec,

nodedb-physical/src/physical_plan/document/op.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use nodedb_types::{Surrogate, SurrogateBitmap, SystemTimeScope};
44

55
use super::merge_types::MergeClauseOp;
66
use super::ollp_edge::OllpPredictedEdge;
7+
use super::timeseries_schema::TimeseriesSchema;
78
use super::types::{EnforcementOptions, RegisteredIndex, ReturningSpec, StorageMode, UpdateValue};
89

910
/// Document engine physical operations (schemaless + strict + DML).
@@ -184,6 +185,12 @@ pub enum DocumentOp {
184185
/// `None` = no explicit policy persisted; the registry falls back to
185186
/// the ephemeral default.
186187
conflict_policy: Option<String>,
188+
/// Declared columns + designated `TIME_KEY` for a timeseries
189+
/// collection. `Some` for every `engine='timeseries'` collection;
190+
/// `None` for every other engine. The Data Plane builds the
191+
/// collection's memtable schema from this instead of inferring one
192+
/// from the first ingested batch.
193+
timeseries: Option<Box<TimeseriesSchema>>,
187194
},
188195

189196
/// Lookup documents by secondary index value.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
//! The declared shape of a timeseries collection, propagated from the
4+
//! catalog to the Data Plane on collection registration.
5+
6+
/// A timeseries collection's DDL-declared column list plus its designated
7+
/// `TIME_KEY` column.
8+
///
9+
/// This is the authority for the collection's storage layout. Without it the
10+
/// Data Plane would have to derive a schema from whatever the first ingested
11+
/// batch happened to contain, which cannot recover the declared column order,
12+
/// the declared types, or — critically — which column is the time key. Every
13+
/// path that needs to know "which column carries this collection's time"
14+
/// reads [`Self::time_key`]; none may guess it from a column name.
15+
#[derive(
16+
Debug,
17+
Clone,
18+
PartialEq,
19+
Eq,
20+
serde::Serialize,
21+
serde::Deserialize,
22+
zerompk::ToMessagePack,
23+
zerompk::FromMessagePack,
24+
)]
25+
pub struct TimeseriesSchema {
26+
/// Name of the designated time-key column, exactly as declared.
27+
pub time_key: String,
28+
/// Declared `(column_name, type_str)` pairs in declaration order.
29+
pub columns: Vec<(String, String)>,
30+
}
31+
32+
impl TimeseriesSchema {
33+
/// Position of the time key in `columns`, or `None` when the declared
34+
/// column list does not contain it (an inconsistent catalog record).
35+
pub fn time_key_index(&self) -> Option<usize> {
36+
self.columns.iter().position(|(n, _)| *n == self.time_key)
37+
}
38+
}

nodedb-physical/src/physical_plan/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ pub use crdt::CrdtOp;
3333
pub use document::{
3434
BalancedDef, DocumentOp, EnforcementOptions, GeneratedColumnSpec, MaterializedSumBinding,
3535
OllpPredictedEdge, PeriodLockConfig, RegisteredIndex, RegisteredIndexState, ReturningColumns,
36-
ReturningItem, ReturningSpec, StorageMode, UpdateValue,
36+
ReturningItem, ReturningSpec, StorageMode, TimeseriesSchema, UpdateValue,
3737
};
3838
pub use exchange::{ExchangeMode, ExchangeOp};
3939
pub use graph::{
@@ -45,7 +45,7 @@ pub use query::{AggregateSpec, GroupKeySpec, JoinProjection, QueryOp};
4545
pub use routing::plan_contains_cluster_partitioned_leaf;
4646
pub use spatial::{SpatialOp, SpatialPredicate};
4747
pub use text::TextOp;
48-
pub use timeseries::TimeseriesOp;
48+
pub use timeseries::{TimeseriesOp, UNBOUNDED_TIME_RANGE};
4949
pub use vector::VectorOp;
5050
pub use wire::{decode, encode};
5151

@@ -279,7 +279,7 @@ impl PhysicalPlan {
279279
| PhysicalPlan::Text(TextOp::BM25ScoreScan { collection, .. })
280280
| PhysicalPlan::Text(TextOp::FtsIndexDoc { collection, .. })
281281
| PhysicalPlan::Text(TextOp::FtsDeleteDoc { collection, .. })
282-
| PhysicalPlan::Text(TextOp::SetAnalyzer { collection, .. })
282+
| PhysicalPlan::Text(TextOp::SetTextConfig { collection, .. })
283283
| PhysicalPlan::Query(QueryOp::PartialAggregate { collection, .. })
284284
| PhysicalPlan::Query(QueryOp::FacetCounts { collection, .. })
285285
| PhysicalPlan::Document(DocumentOp::BulkUpdate { collection, .. })

0 commit comments

Comments
 (0)