pylon_core/query/mod.rs
1//
2// This source file is part of the Pylon open source project.
3//
4// Copyright (c) 2026 Jaldis B.V.
5//
6// Licensed under the MIT OR Apache-2.0 license (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10// https://opensource.org/licenses/MIT
11// https://www.apache.org/licenses/LICENSE-2.0
12//
13// Unless required by applicable law or agreed to in writing, software
14// distributed under the License is distributed on an "AS IS" BASIS,
15// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16// See the License for the specific language governing permissions and
17// limitations under the License.
18//
19
20use lru::LruCache;
21use std::collections::hash_map::DefaultHasher;
22use std::hash::{Hash, Hasher};
23use std::num::NonZeroUsize;
24use std::sync::{Arc, OnceLock, RwLock};
25
26use crate::analyze::ShapePathAlias;
27use crate::error::PyQLError;
28use crate::schema::SchemaDescriptor;
29use crate::{analyze, ir, parse, sql};
30use pylon_value::DecodedValue;
31
32const CACHE_CAPACITY: usize = 1024;
33
34/// Number of independently-locked cache shards.
35///
36/// `LruCache::get` has to reorder the recency list, so it needs a *write*
37/// lock even on a hit — meaning a single map serializes every compile in the
38/// process behind one lock. Sharding by key hash keeps LRU semantics while
39/// cutting that contention by this factor. Must be a power of two, since
40/// shard selection masks the low bits of the hash.
41const CACHE_SHARDS: usize = 16;
42
43/// One cached compilation, stored behind an `Arc` so a hit hands out a
44/// pointer rather than deep-cloning the SQL string, parameter names, tag
45/// list and entire shape tree (measured at 0.94 µs of a 1.05 µs cache hit).
46///
47/// The key is kept alongside the value and re-checked on every hit: lookup
48/// is by hash alone (so it costs no allocation), and a hash collision must
49/// read as a miss, never as "here is some other query's SQL."
50struct CacheEntry {
51 query: String,
52 config: ir::SessionConfig,
53 compiled: Arc<CompiledQuery>,
54}
55
56// Keyed by (query text, session config) — not query text alone. Compilation
57// success/shape/SQL can depend on the config (e.g. an INSERT assigning `id`
58// compiles under allow_user_specified_id=true and errors otherwise), so two
59// requests for the same query text under different configs must never share
60// a cache entry.
61type CacheShard = RwLock<LruCache<u64, CacheEntry>>;
62
63static QUERY_CACHE: OnceLock<Vec<CacheShard>> = OnceLock::new();
64
65fn query_cache() -> &'static [CacheShard] {
66 QUERY_CACHE.get_or_init(|| {
67 let per_shard = NonZeroUsize::new(CACHE_CAPACITY / CACHE_SHARDS).unwrap();
68 (0..CACHE_SHARDS)
69 .map(|_| RwLock::new(LruCache::new(per_shard)))
70 .collect()
71 })
72}
73
74fn cache_key_hash(query: &str, config: &ir::SessionConfig) -> u64 {
75 let mut hasher = DefaultHasher::new();
76 query.hash(&mut hasher);
77 config.hash(&mut hasher);
78 hasher.finish()
79}
80
81/// Discard all cached compiled queries. Call when the schema is reloaded.
82pub fn clear_query_cache() {
83 if let Some(shards) = QUERY_CACHE.get() {
84 for shard in shards {
85 shard.write().unwrap().clear();
86 }
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum Cardinality {
92 Required,
93 Optional,
94 Many,
95}
96
97/// Internal tree describing one position in the query output shape.
98/// Opaque to Python — only the Rust deserializer inspects it.
99#[derive(Debug, Clone)]
100pub enum ShapeNode {
101 /// Leaf value; native PG type.
102 Scalar { name: String, position: usize },
103 /// The `result` column IS the value — not wrapped in ROW(). Used for array literals
104 /// where an array is returned as its own top-level column.
105 RawScalar,
106 /// Like RawScalar but the value is a decoded JSON object (from a <json> cast).
107 /// REPL displays it as `Json("...")`.
108 JsonScalar,
109 /// Object shape.
110 /// `type_name = Some(s)` → named schema type decoded to a registered dataclass.
111 /// `type_name = None` → free type decoded to Pylon's generic Object dataclass.
112 /// `name` is the pointer name within the parent (empty string for the root).
113 Object {
114 name: String,
115 type_name: Option<String>,
116 position: usize,
117 cardinality: Cardinality,
118 pointers: Vec<ShapeNode>,
119 /// True when `pointers[0]` is an `id` the compiler added to a shape
120 /// that did not select one — see `IrScalarPointer::implicit_id`. Every
121 /// decoder hands it to the caller like any other property; only JSON
122 /// output drops it.
123 has_implicit_id: bool,
124 },
125 /// `record[]` column decoded to a Python list.
126 Array {
127 name: String,
128 position: usize,
129 element: Box<ShapeNode>,
130 },
131 /// A tuple read as the composite row it is: each element at its own
132 /// position, carrying its own PostgreSQL type, rather than out of a
133 /// jsonb value where every number is one type (see `NamedTuple`).
134 ///
135 /// `position` follows the same convention as every other node — the
136 /// index of this value inside the row that holds it — except at the
137 /// root, where the row *is* the tuple and there is nothing to index out
138 /// of. Decoders dispatch those two cases separately (`decode_row` /
139 /// `decode_at_root` against `decode` in the hydrators).
140 ///
141 /// `names` is `None` for a positional tuple, which hydrates to a plain
142 /// tuple; `Some` for a named one, which hydrates to a `NamedTupleValue`
143 /// — or, when `type_name` names a registered `@pylon.named_tuple`
144 /// class, to that class.
145 Tuple {
146 /// The pointer name within the parent object, empty at the root —
147 /// the same convention `Scalar`/`Object`/`Array` follow, and what
148 /// lets a tuple-typed property name itself in a shape.
149 name: String,
150 position: usize,
151 elements: Vec<ShapeNode>,
152 names: Option<Vec<String>>,
153 type_name: Option<String>,
154 },
155 /// Named tuple decoded from jsonb. When `type_name` is Some, hydrated to the registered class.
156 /// `members` carries the full per-member decode plan when statically known (a registered
157 /// NamedTupleDescriptor's members, a structural `pylon.Tuple[...]` property's tuple_members,
158 /// or a `<tuple<...>>`/nominal cast's own target type) — `None` falls back to decoding the
159 /// raw jsonb value generically (dict/list, no per-member typing).
160 NamedTuple {
161 name: String,
162 position: usize,
163 type_name: Option<String>,
164 members: Option<Vec<JsonMember>>,
165 /// True when this value came from `{ x := 1.0 }` (curly-brace free-
166 /// object syntax) rather than `(x := 1.0)` (paren tuple syntax) —
167 /// decoded identically either way (both are jsonb), but the
168 /// frontend's own value-shape-tag tree (pylon/query.py's
169 /// shape_value_tags) uses this to tell the Python API layer to
170 /// describe it as an "object" rather than a "namedTuple", so
171 /// JsonTree renders an expandable `Object {x: 1.0}` instead of a
172 /// non-expandable `(x := 1.0)` tuple literal.
173 is_free_object: bool,
174 },
175 /// Enum value arrived as text; hydrated to the Python enum class keyed by `enum_type`.
176 Enum {
177 name: String,
178 position: usize,
179 /// Pylon-qualified name, e.g. `default::Gender`.
180 enum_type: String,
181 },
182 /// Result of a `vector::search` statement.
183 /// The outer `result` tuple has three slots:
184 /// 0 → NULL (virtual type, no registry lookup)
185 /// `object_position` → the object sub-tuple (decoded as a Pylon object)
186 /// `distance_position` → the distance scalar (float64)
187 VectorSearch {
188 object_position: usize,
189 distance_position: usize,
190 object_node: Box<ShapeNode>,
191 },
192 /// Result of a `fts::search` statement.
193 /// Outer tuple layout mirrors `VectorSearch`: pos 0 = NULL, pos 1 = object, pos 2 = score.
194 FtsSearch {
195 object_position: usize,
196 rank_position: usize,
197 object_node: Box<ShapeNode>,
198 },
199 /// Result of a `group` statement: each row is a free object with key/grouping/elements.
200 Group {
201 /// One ShapeNode per grouping key (carries name, position, and type).
202 /// Positions are 1-based in the outer tuple (pos 0 is the NULL type slot).
203 key_nodes: Vec<ShapeNode>,
204 /// Position of the `ARRAY[key_names...]::text[]` in the outer tuple.
205 grouping_position: usize,
206 /// Position of the `array_agg(elements)` in the outer tuple.
207 elements_position: usize,
208 /// Shape node for each element in the elements array.
209 element: Box<ShapeNode>,
210 },
211}
212
213/// One member's decode plan within a jsonb-backed tuple value
214/// (`ShapeNode::NamedTuple.members`) — recursive so a member can itself be a
215/// nested tuple.
216#[derive(Debug, Clone)]
217pub struct JsonMember {
218 /// `None` for a positional/unnamed element of a structural tuple
219 /// (the value is a jsonb array); `Some` for a named member (a jsonb
220 /// object key) — either a nominal named-tuple field or a named
221 /// structural element.
222 pub key: Option<String>,
223 pub kind: JsonMemberKind,
224}
225
226#[derive(Debug, Clone)]
227pub enum JsonMemberKind {
228 /// Plain scalar — the jsonb value's own native JSON type is already
229 /// correct (number/string/bool), used as-is.
230 Scalar,
231 /// A `decimal` member. jsonb has one number type and cannot say which
232 /// of its numbers were written as decimals, so the declaration does:
233 /// the digits travel as they were written (see
234 /// `DecodedValue::JsonNumber`) and this is what tells a reader to build
235 /// a decimal from them rather than a float.
236 Decimal,
237 /// Value arrived as a jsonb string; hydrate to the Python enum class
238 /// keyed by `enum_type` (Pylon-qualified name, e.g. `default::Gender`).
239 Enum { enum_type: String },
240 /// Nested tuple member — recurse. `type_name` hydrates to a registered
241 /// dataclass when present (nominal); `None` decodes to a plain tuple
242 /// (all-positional members) or a dynamically-built dataclass (named,
243 /// unregistered structural).
244 Tuple {
245 type_name: Option<String>,
246 members: Vec<JsonMember>,
247 },
248}
249
250/// Opaque handle to the output shape of a compiled query.
251#[derive(Debug, Clone)]
252pub struct ShapeDescriptor {
253 pub root: ShapeNode,
254}
255
256/// Typed query parameter value produced during compilation.
257#[derive(Debug, Clone)]
258pub enum QueryParam {
259 Null,
260 Bool(bool),
261 Int(i64),
262 Float(f64),
263 Text(String),
264 Bytes(Vec<u8>),
265 Uuid([u8; 16]),
266}
267
268/// Pre-execution inference plan — set when the query requires an external model call
269/// before the SQL can be executed. Python switches on the variant.
270#[derive(Debug, Clone)]
271pub enum InferencePlan {
272 /// `fts::search` with a remote backend (OpenSearch or Meilisearch).
273 /// Python fetches (id, score) pairs from the backend, then injects them as
274 /// `__deferred_ids__` / `__deferred_scores__` params and runs `sql` against Postgres.
275 Search {
276 /// Remote backend identifier: `"opensearch"` or `"meilisearch"`.
277 backend: String,
278 /// Remote index name.
279 index_name: String,
280 /// Name of the user's query-text param; empty string when an inline literal.
281 query_param_name: String,
282 /// Inline literal query text.
283 query_literal: Option<String>,
284 /// Requested result size (limit), if known at compile time.
285 size: Option<usize>,
286 },
287 /// `vector::search(TypeName, query := $text)` text overload.
288 /// Python embeds the text via the configured model provider, then injects the
289 /// resulting vector as `__deferred_vec__` and runs `sql` against Postgres.
290 Embedding {
291 /// Embedding model identifier from the schema, e.g. `"mistral-embed"`.
292 model_name: String,
293 /// Qualified type name for provider lookup, e.g. `"default::Product"`.
294 type_name: String,
295 /// Vector index name for provider lookup (`None` = default index).
296 index_name: Option<String>,
297 /// Name of the user's `query :=` param; empty string when an inline literal.
298 query_param_name: String,
299 /// Inline literal query text.
300 query_literal: Option<String>,
301 },
302}
303
304/// The tuple type a parameter is cast to — `<tuple<…>>$p`, a nominal
305/// `<module::Point>$p`, or an array of either.
306///
307/// A tuple value travels as jsonb, and a named member is a *key* in it, so a
308/// client holding the value positionally (a Python tuple, a named-tuple
309/// instance) needs the member names to build what the cast asks for. The
310/// cast is the only place those names exist, so compilation records them
311/// here rather than leaving the client to guess from the value's own shape.
312#[derive(Debug, Clone)]
313pub struct ParamTupleType {
314 /// `<array<tuple<…>>>` — `members` then describes one element.
315 pub is_array: bool,
316 /// The registered name for a nominal named tuple; `None` for a
317 /// structural `tuple<…>`.
318 pub type_name: Option<String>,
319 pub members: Vec<JsonMember>,
320 /// True when the parameter is bound as a value of the tuple's own
321 /// composite type rather than as jsonb — so the value travels as a
322 /// positional row in member order, and every member keeps the
323 /// PostgreSQL type it is declared with.
324 ///
325 /// jsonb cannot carry several of them at all: a `bytes` member becomes
326 /// text, a non-finite `float64` becomes nothing, and a `datetime`
327 /// arrives as something no timestamp accepts. Only a *declared* tuple
328 /// type has a composite to bind against, so a structural `tuple<…>`
329 /// whose shape nothing declares still travels as jsonb.
330 pub as_composite: bool,
331}
332
333/// A bound parameter in the shape the cast it feeds asks for.
334///
335/// A tuple parameter bound as its own composite type travels as a
336/// *positional* row in member order (see `ParamTupleType::as_composite`),
337/// but a caller holds one however is natural for them — a JSON object from
338/// the web UI, a map from the Rust client. Reshaping it is the step between
339/// the two, and every client needs it: without it the value is written as
340/// jsonb into a composite slot, which PostgreSQL reads as a corrupt row
341/// header ("wrong number of columns: 24846958, expected 2").
342///
343/// `pylon-py` does this same job directly from Python objects, where the
344/// value has no `DecodedValue` form yet (see `pgvalue::tuple_to_cached`);
345/// the two have to agree on member order.
346pub fn bind_tuple_param(value: DecodedValue, plan: &ParamTupleType) -> DecodedValue {
347 if !plan.as_composite {
348 // A tuple travelling as jsonb is already in the shape it needs.
349 return value;
350 }
351 if plan.is_array {
352 let DecodedValue::Array(elements) = value else {
353 return value;
354 };
355 return DecodedValue::Array(
356 elements
357 .into_iter()
358 .map(|element| tuple_row(element, &plan.members))
359 .collect(),
360 );
361 }
362 tuple_row(value, &plan.members)
363}
364
365/// One tuple as the positional row its composite type expects, reading a
366/// named value by key and an unnamed one by position. Recurses, since a
367/// member that is itself a tuple is a row inside the row.
368fn tuple_row(value: DecodedValue, members: &[JsonMember]) -> DecodedValue {
369 let nested = |value: DecodedValue, member: &JsonMember| match &member.kind {
370 JsonMemberKind::Tuple { members, .. } => tuple_row(value, members),
371 _ => value,
372 };
373 match value {
374 DecodedValue::Null => DecodedValue::Null,
375 DecodedValue::Object(entries) => DecodedValue::Composite(
376 members
377 .iter()
378 .map(|member| {
379 let key = member.key.as_deref().unwrap_or_default();
380 let found = entries
381 .iter()
382 .find(|(k, _)| k == key)
383 .map(|(_, v)| v.clone())
384 .unwrap_or(DecodedValue::Null);
385 nested(found, member)
386 })
387 .collect(),
388 ),
389 // Already positional — a row read back from an earlier query, or a
390 // caller who held the members in order.
391 DecodedValue::Composite(items) | DecodedValue::Array(items) => DecodedValue::Composite(
392 members
393 .iter()
394 .zip(items.into_iter().chain(std::iter::repeat(DecodedValue::Null)))
395 .map(|(member, item)| nested(item, member))
396 .collect(),
397 ),
398 // Not a tuple-shaped value at all; leave it for the encoder to
399 // refuse with its own message rather than inventing a row here.
400 other => other,
401 }
402}
403
404#[cfg(test)]
405mod bind_tuple_param_tests {
406 use super::*;
407
408 fn member(key: &str) -> JsonMember {
409 JsonMember {
410 key: Some(key.to_string()),
411 kind: JsonMemberKind::Scalar,
412 }
413 }
414
415 fn plan(members: Vec<JsonMember>, is_array: bool, as_composite: bool) -> ParamTupleType {
416 ParamTupleType {
417 is_array,
418 type_name: Some("default::HttpHeader".to_string()),
419 members,
420 as_composite,
421 }
422 }
423
424 fn object(pairs: &[(&str, &str)]) -> DecodedValue {
425 DecodedValue::Object(
426 pairs
427 .iter()
428 .map(|(k, v)| ((*k).to_string(), DecodedValue::Str((*v).to_string())))
429 .collect(),
430 )
431 }
432
433 #[test]
434 fn an_object_becomes_a_row_in_member_order() {
435 // The web UI sends a JSON object, so the members arrive keyed and in
436 // whatever order the caller wrote them.
437 let plan = plan(vec![member("name"), member("value")], false, true);
438 let bound = bind_tuple_param(object(&[("value", "Bar"), ("name", "X-Foo")]), &plan);
439 assert_eq!(
440 bound,
441 DecodedValue::Composite(vec![DecodedValue::Str("X-Foo".into()), DecodedValue::Str("Bar".into())])
442 );
443 }
444
445 #[test]
446 fn an_array_parameter_is_reshaped_element_wise() {
447 let plan = plan(vec![member("name"), member("value")], true, true);
448 let bound = bind_tuple_param(
449 DecodedValue::Array(vec![object(&[("name", "a"), ("value", "b")])]),
450 &plan,
451 );
452 assert_eq!(
453 bound,
454 DecodedValue::Array(vec![DecodedValue::Composite(vec![
455 DecodedValue::Str("a".into()),
456 DecodedValue::Str("b".into())
457 ])])
458 );
459 }
460
461 #[test]
462 fn a_member_the_caller_left_out_is_absent_not_shifted() {
463 let plan = plan(vec![member("name"), member("value")], false, true);
464 let bound = bind_tuple_param(object(&[("value", "Bar")]), &plan);
465 assert_eq!(
466 bound,
467 DecodedValue::Composite(vec![DecodedValue::Null, DecodedValue::Str("Bar".into())])
468 );
469 }
470
471 #[test]
472 fn a_value_already_held_positionally_keeps_its_order() {
473 let plan = plan(vec![member("name"), member("value")], false, true);
474 let positional =
475 DecodedValue::Composite(vec![DecodedValue::Str("X-Foo".into()), DecodedValue::Str("Bar".into())]);
476 assert_eq!(bind_tuple_param(positional.clone(), &plan), positional);
477 }
478
479 #[test]
480 fn a_nested_tuple_member_is_a_row_inside_the_row() {
481 let plan = plan(
482 vec![
483 JsonMember {
484 key: Some("at".into()),
485 kind: JsonMemberKind::Tuple {
486 type_name: None,
487 members: vec![member("x"), member("y")],
488 },
489 },
490 member("label"),
491 ],
492 false,
493 true,
494 );
495 let bound = bind_tuple_param(
496 DecodedValue::Object(vec![
497 ("at".into(), object(&[("y", "2"), ("x", "1")])),
498 ("label".into(), DecodedValue::Str("l".into())),
499 ]),
500 &plan,
501 );
502 assert_eq!(
503 bound,
504 DecodedValue::Composite(vec![
505 DecodedValue::Composite(vec![DecodedValue::Str("1".into()), DecodedValue::Str("2".into())]),
506 DecodedValue::Str("l".into()),
507 ])
508 );
509 }
510
511 #[test]
512 fn a_tuple_travelling_as_jsonb_is_left_alone() {
513 // An undeclared structural shape has no composite to bind against,
514 // and its jsonb form is already what the encoder wants.
515 let plan = plan(vec![member("name"), member("value")], false, false);
516 let value = object(&[("name", "a"), ("value", "b")]);
517 assert_eq!(bind_tuple_param(value.clone(), &plan), value);
518 }
519
520 #[test]
521 fn an_absent_value_stays_absent() {
522 let plan = plan(vec![member("name")], false, true);
523 assert_eq!(bind_tuple_param(DecodedValue::Null, &plan), DecodedValue::Null);
524 }
525}
526
527/// The output of a successful PyQL compilation.
528/// Immutable and safe to cache and reuse across requests.
529#[derive(Debug, Clone)]
530pub struct CompiledQuery {
531 /// PostgreSQL SQL string ready for execution.
532 pub sql: String,
533 /// Ordered parameter names matching $1, $2, … in the SQL.
534 /// The client uses this to map kwargs to positional arguments.
535 pub param_names: Vec<String>,
536 /// Positionally matching `param_names`: the tuple type each parameter is
537 /// cast to, where it is cast to one (see `ParamTupleType`).
538 pub param_tuple_types: Vec<Option<ParamTupleType>>,
539 /// Typed bound parameters — populated at execution time, empty after compilation.
540 pub params: Vec<QueryParam>,
541 /// Opaque shape handle — consumed by the Rust deserializer.
542 pub shape: ShapeDescriptor,
543 /// Non-fatal warnings produced during compilation.
544 pub warnings: Vec<String>,
545 /// Set when the query requires a pre-execution model call.
546 pub inference_plan: Option<InferencePlan>,
547 /// Every schema-qualified table (`"schema.table"`) this statement reads
548 /// from or writes to — see `ir::tags::collect_tags`. For a SELECT, the
549 /// set of tags to cache this result under; for an INSERT/UPDATE/DELETE,
550 /// the set of tags a cache layer must invalidate after the write commits.
551 pub tags: Vec<String>,
552 /// True when executing this statement writes to any of `tags`. Lets a
553 /// cache layer act on the invalidation duty described above without
554 /// re-parsing the SQL to guess whether a write happened.
555 pub mutates: bool,
556 /// `Some` only for `analyze <query>` — the shape-path↔SQL-alias map an
557 /// `analyze` execution needs to correlate Postgres's `EXPLAIN` plan
558 /// nodes back to the query's own shape (see `analyze` module). `None`
559 /// for every other query, which doesn't pay for this extra shape walk.
560 pub analyze_paths: Option<Vec<ShapePathAlias>>,
561 /// This query's stable shape id — see `crate::shape_id` and `shape_id()`.
562 /// Computed once at compile time; fill it with `derive_shape_id` if you
563 /// ever build a `CompiledQuery` outside `compile_uncached`.
564 pub shape_id: Arc<str>,
565}
566
567/// The shape id for a given SQL string and result shape — the derivation
568/// `compile_uncached` uses to populate `CompiledQuery::shape_id`, exposed so
569/// anything else constructing a `CompiledQuery` by hand can fill that field
570/// consistently rather than inventing its own value.
571pub fn derive_shape_id(sql: &str, shape: &ShapeDescriptor) -> Arc<str> {
572 crate::shape_id::query_shape_id(sql, &format!("{shape:?}")).into()
573}
574
575impl CompiledQuery {
576 /// This query's stable shape id — see `crate::shape_id`.
577 ///
578 /// Independent of the values bound to it, which is what makes it safe as
579 /// a metric label: a query run a million times with different parameters
580 /// reports one label value, not a million.
581 ///
582 /// Precomputed rather than derived per call. Every execution reports this
583 /// label, and deriving it meant `Debug`-formatting the entire shape tree
584 /// into a throwaway `String` and hashing it — measured at 3.65 µs per
585 /// execution on a 20-field shape, all of it repeated work, since a
586 /// `CompiledQuery` is immutable.
587 pub fn shape_id(&self) -> Arc<str> {
588 self.shape_id.clone()
589 }
590
591 /// A stable hash of this query's SQL, for callers that need to key a
592 /// cache entry on the statement without moving the SQL text itself
593 /// around. Same derivation as `shape_id`, minus the shape.
594 pub fn sql_id(&self) -> &str {
595 // The shape id already covers the SQL — a query's shape can't change
596 // without its SQL changing — so a second hash would be redundant.
597 &self.shape_id
598 }
599}
600
601/// A coarse bucket for a failed query, for use as a metric label.
602///
603/// Deliberately coarse: an unbounded label (a raw error message, a SQLSTATE,
604/// a constraint name) is exactly the thing that blows up a metrics backend's
605/// cardinality. Anything finer belongs on a span or in a log line.
606#[derive(Debug, Clone, Copy, PartialEq, Eq)]
607pub enum ErrorClass {
608 /// A constraint the write violated — unique, foreign key, check, not-null.
609 ConstraintViolation,
610 /// Serialization failure or deadlock: the caller can retry.
611 Contention,
612 /// Statement or lock timeout.
613 Timeout,
614 /// Couldn't reach or stay connected to the database.
615 Connection,
616 /// The query never reached the database — bad PyQL, unknown type/pointer.
617 Compile,
618 Other,
619}
620
621impl ErrorClass {
622 /// The label value. `&'static str` so it can't accidentally become
623 /// unbounded.
624 pub fn as_label(self) -> &'static str {
625 match self {
626 ErrorClass::ConstraintViolation => "constraint_violation",
627 ErrorClass::Contention => "contention",
628 ErrorClass::Timeout => "timeout",
629 ErrorClass::Connection => "connection",
630 ErrorClass::Compile => "compile",
631 ErrorClass::Other => "other",
632 }
633 }
634
635 /// Buckets a SQLSTATE by its two-character class, which is how the
636 /// standard already groups them — so a code this was never written
637 /// against still lands somewhere sensible instead of in `Other`.
638 pub fn from_sqlstate(code: &str) -> Self {
639 match code {
640 "40001" => ErrorClass::Contention,
641 "40P01" => ErrorClass::Contention,
642 "57014" => ErrorClass::Timeout,
643 "55P03" => ErrorClass::Timeout,
644 _ => match code.get(..2) {
645 // 23 = integrity constraint violation.
646 Some("23") => ErrorClass::ConstraintViolation,
647 // 08 = connection exception.
648 Some("08") => ErrorClass::Connection,
649 // 40 = transaction rollback.
650 Some("40") => ErrorClass::Contention,
651 _ => ErrorClass::Other,
652 },
653 }
654 }
655}
656
657/// Whether a query succeeded, and if not, how it failed.
658#[derive(Debug, Clone, Copy, PartialEq, Eq)]
659pub enum Outcome {
660 Ok,
661 Error(ErrorClass),
662}
663
664impl Outcome {
665 pub fn as_label(self) -> &'static str {
666 match self {
667 Outcome::Ok => "success",
668 Outcome::Error(_) => "error",
669 }
670 }
671}
672
673/// Timing and result facts about one query execution.
674///
675/// Returned alongside the result rather than reported from inside the
676/// execution path, so the caller decides what to do with it — record a
677/// metric, attach it to a span, log it, or ignore it. Keeping the decision
678/// out here is what lets the same execution path serve an instrumented
679/// server and an uninstrumented script.
680#[derive(Debug, Clone)]
681pub struct ExecutionMetadata {
682 /// PyQL → SQL compilation. Zero on a compile-cache hit, which is itself
683 /// the signal that the cache is working.
684 pub compile_duration: std::time::Duration,
685 /// Time in the database, from handing over the SQL to having the rows.
686 pub execute_duration: std::time::Duration,
687 /// See `CompiledQuery::shape_id`.
688 pub query_shape_id: String,
689 /// `None` for a statement that returns no rows, which is distinct from
690 /// `Some(0)` — a query that ran and matched nothing.
691 pub rows_returned: Option<u64>,
692 pub outcome: Outcome,
693}
694
695impl ExecutionMetadata {
696 /// Compile plus execute — what a caller timing "the query" means.
697 pub fn total_duration(&self) -> std::time::Duration {
698 self.compile_duration + self.execute_duration
699 }
700}
701
702/// Compile a PyQL expression string in the context of a named type to a bare SQL
703/// expression suitable for use in an UPDATE SET clause.
704///
705/// Pointer references (`.name`) are emitted without a table alias because UPDATE
706/// SET expressions reference the current row directly. Query parameters (`$name`)
707/// are rejected — fill expressions must be literal values or pointer references.
708pub fn compile_fill_expr(
709 type_name: &str,
710 expr_str: &str,
711 schema: &SchemaDescriptor,
712) -> Result<String, crate::error::PyQLError> {
713 let expr_ast = parse::parse_expr(expr_str)?;
714 let (ir_expr, params) = ir::compile_expr_unaliased(&expr_ast, type_name, schema)?;
715 if !params.is_empty() {
716 return Err(crate::error::PyQLError::Syntax(crate::error::PyQLSyntaxError {
717 message: "fill expressions may not contain query parameters".into(),
718 position: crate::error::Position { line: 0, col: 0 },
719 }));
720 }
721 Ok(sql::emit_expr(&ir_expr))
722}
723
724/// Compile a schema `Trigger`'s `handler` PyQL statement (e.g. `insert Note
725/// { note := __new__.name }`) to a full SQL statement, for embedding in the
726/// generated plpgsql trigger function body — see `ir::compile_trigger_handler`
727/// for the `__new__`/`__old__` row-context binding rules. `on_mask` is
728/// Pylon's `On` bitmask (1=Insert, 2=Update, 4=Delete), matching
729/// `TriggerDescriptor::on`. Query parameters (`$name`) are rejected, same
730/// rule as `compile_fill_expr` — a trigger handler has no caller to supply
731/// them.
732pub fn compile_trigger_handler(
733 handler: &str,
734 type_name: &str,
735 on_mask: u8,
736 schema: &SchemaDescriptor,
737) -> Result<String, crate::error::PyQLError> {
738 let ir_out = ir::compile_trigger_handler(handler, type_name, on_mask, schema)?;
739 if !ir_out.params.is_empty() {
740 return Err(crate::error::PyQLError::Syntax(crate::error::PyQLSyntaxError {
741 message: "trigger handlers may not contain query parameters".into(),
742 position: crate::error::Position { line: 0, col: 0 },
743 }));
744 }
745 Ok(sql::emit(&ir_out).sql)
746}
747
748/// Compile a PyQL query string to SQL against `schema`, using default
749/// session config (see `ir::SessionConfig`) — for schema-time/test callers
750/// with no live client-supplied config. `compile_with_config` is the real
751/// entry a query request uses.
752///
753/// Results are cached in a process-global LRU (capacity 1024). Call
754/// `clear_query_cache()` when the schema is reloaded to avoid stale entries.
755/// Synchronous — compilation is CPU-bound; async lives at the DB execution layer.
756/// Raises `PyQLError` on any grammar, type, or resolution failure.
757pub fn compile(query: &str, schema: &SchemaDescriptor) -> Result<Arc<CompiledQuery>, PyQLError> {
758 compile_with_config(query, schema, &ir::SessionConfig::default())
759}
760
761/// Every statement of a script, compiled in the order written.
762///
763/// A single statement comes back as a one-element list, and takes the cached
764/// path — so this is safe to call for any query, not only ones with semicolons
765/// in them.
766pub fn compile_script(
767 query: &str,
768 schema: &SchemaDescriptor,
769 config: &ir::SessionConfig,
770) -> Result<Vec<Arc<CompiledQuery>>, PyQLError> {
771 let statements = parse::parse_script(query)?;
772 if statements.len() == 1 {
773 return Ok(vec![compile_with_config(query, schema, config)?]);
774 }
775 statements
776 .iter()
777 .map(|ast| compile_ast(ast, schema, config).map(Arc::new))
778 .collect()
779}
780
781/// Like `compile`, but honors a caller-supplied `SessionConfig` for this query.
782///
783/// Returns an `Arc` — callers share one immutable compilation rather than
784/// each getting a deep copy of it.
785pub fn compile_with_config(
786 query: &str,
787 schema: &SchemaDescriptor,
788 config: &ir::SessionConfig,
789) -> Result<Arc<CompiledQuery>, PyQLError> {
790 let hash = cache_key_hash(query, config);
791 let shard = &query_cache()[(hash as usize) % CACHE_SHARDS];
792 {
793 let mut cache = shard.write().unwrap();
794 if let Some(entry) = cache.get(&hash) {
795 // Verify, don't assume: a 64-bit collision is vanishingly rare
796 // but handing back the wrong query's SQL would be silent and
797 // catastrophic, so a mismatch falls through to a real compile.
798 if entry.query == query && &entry.config == config {
799 return Ok(entry.compiled.clone());
800 }
801 }
802 }
803 let compiled = Arc::new(compile_uncached(query, schema, config)?);
804 shard.write().unwrap().put(
805 hash,
806 CacheEntry {
807 query: query.to_string(),
808 config: config.clone(),
809 compiled: compiled.clone(),
810 },
811 );
812 Ok(compiled)
813}
814
815fn compile_uncached(
816 query: &str,
817 schema: &SchemaDescriptor,
818 config: &ir::SessionConfig,
819) -> Result<CompiledQuery, PyQLError> {
820 let ast = parse::parse(query)?;
821 compile_ast(&ast, schema, config)
822}
823
824/// Compile one already-parsed statement. Shared by the single-statement path
825/// and by `compile_script`, which has several of them and no source text to
826/// hand back to the parser per statement.
827fn compile_ast(
828 ast: &parse::Stmt,
829 schema: &SchemaDescriptor,
830 config: &ir::SessionConfig,
831) -> Result<CompiledQuery, PyQLError> {
832 let is_analyze = matches!(ast, parse::Stmt::Analyze(_));
833 let ir_out = ir::compile_with_config(ast, schema, config)?;
834 // `analyze`'s own shape-path walk is skipped for every other query — no
835 // reason to pay for it when nothing will read `analyze_paths`.
836 let analyze_paths = is_analyze.then(|| {
837 let mut paths = analyze::collect_shape_path_aliases(&ir_out.stmt);
838 // The root marker has no IR pointer of its own to carry it (see
839 // `root_marker_offset`'s own doc comment) — filled in here from the
840 // original AST, still in scope at this point.
841 if let Some(root) = paths.iter_mut().find(|p| p.path == "root") {
842 root.marker_offset = analyze::root_marker_offset(ast);
843 }
844 paths
845 });
846 let tags = ir::tags::collect_tags(&ir_out);
847 let mutates = stmt_mutates(&ir_out.stmt) || ir_out.ctes.iter().any(|c| stmt_mutates(&c.stmt));
848 let sql_out = sql::emit(&ir_out);
849 let shape_id = derive_shape_id(&sql_out.sql, &sql_out.shape);
850 Ok(CompiledQuery {
851 sql: sql_out.sql,
852 param_names: ir_out.params,
853 param_tuple_types: ir_out.param_tuple_types,
854 params: Vec::new(),
855 shape: sql_out.shape,
856 warnings: ir_out.warnings,
857 inference_plan: sql_out.inference_plan,
858 tags,
859 mutates,
860 analyze_paths,
861 shape_id,
862 })
863}
864
865/// Whether executing this statement writes to any of its `tags`.
866///
867/// Not just the outermost node: a `FOR` body, a `WITH` binding
868/// (`with c := (insert Company {...}) select c`), and a select over a DML
869/// source (`select (insert Person {...}) { id }`) all write while presenting
870/// as something else.
871fn stmt_mutates(stmt: &ir::IrStmt) -> bool {
872 match stmt {
873 ir::IrStmt::Insert(_) | ir::IrStmt::Update(_) | ir::IrStmt::Delete(_) => true,
874 ir::IrStmt::For(f) => stmt_mutates(&f.body),
875 ir::IrStmt::Select(sel) => sel.dml_source.as_deref().is_some_and(stmt_mutates),
876 _ => false,
877 }
878}
879
880#[cfg(test)]
881mod tests {
882 use super::*;
883 use crate::schema::{PropertyDescriptor, TypeDescriptor};
884
885 fn make_schema() -> SchemaDescriptor {
886 SchemaDescriptor {
887 types: vec![TypeDescriptor {
888 name: "Person".into(),
889 module: "default".into(),
890 table: "person".into(),
891 abstract_: false,
892 materialized: false,
893 description: None,
894 parents: vec![],
895 interfaces: vec![],
896 bases: vec![],
897 properties: vec![PropertyDescriptor {
898 name: "id".into(),
899 pg_type: "uuid".into(),
900 nullable: false,
901 default_sql: Some("uuidv7()".into()),
902 default_pyql: None,
903 description: None,
904 check_constraints: vec![],
905 is_exclusive: true,
906 is_pk: true,
907 is_readonly: true,
908 rewrites: vec![],
909 tuple_members: None,
910 column_type: None,
911 }],
912 links: vec![],
913 multilinks: vec![],
914 computed: vec![],
915 constraints: vec![],
916 indexes: vec![],
917 partition: None,
918 vector_indexes: vec![],
919 search_indexes: vec![],
920 triggers: vec![],
921 junction: false,
922 signals: vec![],
923 }],
924 scalars: vec![],
925 enums: vec![],
926 named_tuples: vec![],
927 globals: vec![],
928 functions: vec![],
929 aliases: vec![],
930 channels: vec![],
931 ..Default::default()
932 }
933 }
934
935 #[test]
936 fn test_analyze_paths_is_none_for_a_plain_query() {
937 let schema = make_schema();
938 let compiled = compile("select Person { id }", &schema).unwrap();
939 assert!(compiled.analyze_paths.is_none());
940 }
941
942 #[test]
943 fn test_analyze_paths_is_populated_for_an_analyze_query() {
944 let schema = make_schema();
945 let query = "analyze select Person { id }";
946 let compiled = compile(query, &schema).unwrap();
947 let paths = compiled
948 .analyze_paths
949 .as_ref()
950 .expect("analyze query should populate analyze_paths");
951 assert_eq!(paths.len(), 1);
952 assert_eq!(paths[0].path, "root");
953 let offset = paths[0].marker_offset.expect("root path should carry a marker offset");
954 assert_eq!(&query[offset..offset + "Person".len()], "Person");
955 }
956}