Skip to main content

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}