Skip to main content

pylon_core/schema/
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
20// ── Deletion policies ──────────────────────────────────────────────────────────
21
22#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
23pub enum DeleteSide {
24    Target,
25    Source,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
29pub enum DeleteAction {
30    Allow,
31    Restrict,
32    DeferredRestrict,
33    DeleteSource,
34    DeleteTarget,
35    DeleteTargetIfOrphan,
36}
37
38#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
39pub struct OnDeletePolicy {
40    pub side: DeleteSide,
41    pub action: DeleteAction,
42}
43
44// ── Mutation rewrites ──────────────────────────────────────────────────────────
45
46#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
47pub struct RewriteEntry {
48    /// Bitmask: 1=Insert, 2=Update, 4=Delete (mirrors Python On IntFlag).
49    pub on: u8,
50    /// PyQL expression evaluated as the new value.
51    pub handler: String,
52}
53
54// ── Post-commit signal registrations ───────────────────────────────────────────
55
56/// One `on=` registration for a type from the Python-side signal registry —
57/// just the operation bitmask, never the handler itself (the actual
58/// callable stays Python-only and never crosses into this descriptor).
59/// Combined across every handler registered for a type, this drives
60/// whether the DDL emitter attaches a capture trigger to that type's table
61/// at all (see `export::signal_trigger_infos`).
62#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
63pub struct SignalEntry {
64    /// Bitmask: 1=Insert, 2=Update, 4=Delete (mirrors Python On IntFlag,
65    /// same convention as `RewriteEntry.on`).
66    pub on: u8,
67}
68
69// ── Pointer descriptors ────────────────────────────────────────────────────────
70
71#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
72pub struct PropertyDescriptor {
73    pub name: String,
74    /// PostgreSQL column type, e.g. `text`, `int8`, `uuid`. Always a plain
75    /// base type — every read/write/cast/comparison site relies on that, so
76    /// a registered custom scalar's own DOMAIN name lives in `column_type`
77    /// instead, never here.
78    pub pg_type: String,
79    pub nullable: bool,
80    /// SQL expression for the column DEFAULT clause.
81    pub default_sql: Option<String>,
82    /// PyQL expression to be compiled to SQL at DDL-emit time.
83    /// Takes precedence over `default_sql` when both could be set (they won't be).
84    pub default_pyql: Option<String>,
85    pub description: Option<String>,
86    /// Pre-compiled SQL CHECK expressions, e.g. `"price >= 0"`.
87    pub check_constraints: Vec<String>,
88    /// True when a UNIQUE constraint applies to this column alone.
89    pub is_exclusive: bool,
90    /// True when this column is the table primary key.
91    pub is_pk: bool,
92    /// True when the transpiler should reject PyQL updates targeting this pointer.
93    pub is_readonly: bool,
94    pub rewrites: Vec<RewriteEntry>,
95    /// `Some` only for a structural `pylon.Tuple[...]`-typed property — its
96    /// element shape, for decode-time `ShapeNode` building. A *nominal*
97    /// `@pylon.named_tuple`-typed property instead carries its shape via the
98    /// `__nt__:module::Name` `pg_type` marker + `NamedTupleDescriptor.members`.
99    pub tuple_members: Option<Vec<TupleMemberDescriptor>>,
100    /// `Some("\"schema\".\"Name\"")` only when this property's scalar type is
101    /// a *registered* custom scalar (see `pylon.scalar(..., name=...)` /
102    /// the `@pylon.scalar` decorator form) — the schema-qualified name of
103    /// the PostgreSQL DOMAIN that scalar compiles to (see
104    /// `export::emit_scalars`). Consulted only for the column's own DDL
105    /// type (`CREATE TABLE` / `ADD COLUMN`); every other use of this
106    /// property (casts, comparisons, wire decode) keeps using `pg_type`'s
107    /// plain base type, so a domain-typed column still round-trips exactly
108    /// like its base type — Postgres enforces the domain's CHECK on writes
109    /// regardless of which type name the read/write path itself uses.
110    pub column_type: Option<String>,
111}
112
113#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
114pub struct LinkDescriptor {
115    pub name: String,
116    /// Qualified name of the target type, e.g. `catalog::Category`.
117    pub target: String,
118    pub nullable: bool,
119    pub description: Option<String>,
120    pub default_pyql: Option<String>,
121    /// True when a UNIQUE constraint applies to this FK column alone.
122    pub is_exclusive: bool,
123    /// True when the transpiler should reject PyQL updates targeting this pointer.
124    pub is_readonly: bool,
125    pub rewrites: Vec<RewriteEntry>,
126    pub on_delete: Vec<OnDeletePolicy>,
127    /// Qualified name of the explicit junction type, if any — when set, this
128    /// link is backed by a junction table (source, target, plus the
129    /// junction's own properties) instead of a `{name}_id` FK column on the
130    /// source table, the same storage MultiLink's own `through` already
131    /// uses, just constrained to at most one row per source.
132    pub through: Option<String>,
133}
134
135impl LinkDescriptor {
136    pub fn is_junction_backed(&self) -> bool {
137        self.through.is_some()
138    }
139}
140
141#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
142pub struct MultiLinkDescriptor {
143    pub name: String,
144    /// Qualified name of the target type.
145    pub target: String,
146    /// Qualified name of the explicit junction type, if any.
147    pub through: Option<String>,
148    pub nullable: bool,
149    pub description: Option<String>,
150    pub default_pyql: Option<String>,
151    pub on_delete: Vec<OnDeletePolicy>,
152    /// True when a target may be linked from at most one source, which makes
153    /// the backlink single.
154    #[serde(default)]
155    pub is_exclusive: bool,
156}
157
158#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
159pub struct ComputedDescriptor {
160    pub name: String,
161    /// PyQL expression evaluated at query time.
162    pub expression: String,
163    /// PostgreSQL return type, if known at schema-build time.
164    pub return_type: Option<String>,
165    /// The qualified object type a link-valued computed selects
166    /// (`Computed[Link[Email], "…"]`) — `None` for a scalar computed, whose
167    /// type is `return_type` instead. A consumer that has only the schema to
168    /// go on (the schema browser, an editor) cannot tell the two apart
169    /// otherwise: both carry a bare expression string.
170    #[serde(default)]
171    pub link_target: Option<String>,
172    /// Whether that link-valued computed selects many.
173    #[serde(default)]
174    pub link_multi: bool,
175}
176
177// ── Type-level constructs ──────────────────────────────────────────────────────
178
179// ── Search index ───────────────────────────────────────────────────────────────
180
181#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
182pub enum SearchBackend {
183    Postgres,
184    OpenSearch,
185    Meilisearch,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
189pub enum SearchWeight {
190    A,
191    B,
192    C,
193    D,
194}
195
196impl SearchWeight {
197    pub fn as_str(&self) -> &'static str {
198        match self {
199            SearchWeight::A => "A",
200            SearchWeight::B => "B",
201            SearchWeight::C => "C",
202            SearchWeight::D => "D",
203        }
204    }
205}
206
207#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
208pub struct SearchPointerDescriptor {
209    pub name: String,
210    pub weight: SearchWeight,
211}
212
213#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
214pub struct SearchIndexDescriptor {
215    pub index_name: Option<String>,
216    pub backend: SearchBackend,
217    pub pointers: Vec<SearchPointerDescriptor>,
218}
219
220impl SearchIndexDescriptor {
221    pub fn column_name(&self) -> String {
222        match &self.index_name {
223            None => "__search__".to_string(),
224            Some(name) => format!("__search_{}__", name),
225        }
226    }
227
228    /// Deferred search index name: `"module__table[__name]"` in lowercase.
229    pub fn deferred_index_name(&self, module: &str, type_name: &str) -> String {
230        let base = format!("{}__{}", module, type_name).to_lowercase();
231        match &self.index_name {
232            None => base,
233            Some(n) => format!("{}__{}", base, n.to_lowercase()),
234        }
235    }
236}
237
238/// How a partitioned type's ranges are sized.
239///
240/// Range partitioning on a time column only — the case declarative
241/// partitioning actually pays off for, and the only one that has a sensible
242/// automatic maintenance story (create the next few ranges, drop the ones
243/// past retention). List and hash partitioning need a key set known up
244/// front, which is a different feature, not a parameter of this one.
245#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
246pub enum PartitionInterval {
247    Daily,
248    Weekly,
249    Monthly,
250    Yearly,
251}
252
253impl PartitionInterval {
254    /// The PostgreSQL interval literal for one partition's width.
255    pub fn as_pg_interval(self) -> &'static str {
256        match self {
257            PartitionInterval::Daily => "1 day",
258            PartitionInterval::Weekly => "1 week",
259            PartitionInterval::Monthly => "1 month",
260            PartitionInterval::Yearly => "1 year",
261        }
262    }
263
264    pub fn as_str(self) -> &'static str {
265        match self {
266            PartitionInterval::Daily => "daily",
267            PartitionInterval::Weekly => "weekly",
268            PartitionInterval::Monthly => "monthly",
269            PartitionInterval::Yearly => "yearly",
270        }
271    }
272
273    pub fn parse(s: &str) -> Option<Self> {
274        match s {
275            "daily" => Some(PartitionInterval::Daily),
276            "weekly" => Some(PartitionInterval::Weekly),
277            "monthly" => Some(PartitionInterval::Monthly),
278            "yearly" => Some(PartitionInterval::Yearly),
279            _ => None,
280        }
281    }
282}
283
284/// Declarative range partitioning for one type, maintained by pg_partman.
285///
286/// A type carries at most one of these: a table has exactly one partition
287/// key, so a second declaration isn't a refinement, it's a contradiction.
288/// See `validate_partitions`.
289#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
290pub struct PartitionDescriptor {
291    /// The property partitioned on. Must be a non-nullable date/timestamp
292    /// property of this type — PostgreSQL requires the partition key to be
293    /// part of the primary key and to never be NULL.
294    pub pointer: String,
295    pub interval: PartitionInterval,
296    /// How many future partitions to keep pre-created. A write landing in a
297    /// range that doesn't exist yet fails, so this is the safety margin
298    /// against maintenance falling behind.
299    pub premake: u32,
300    /// Drop partitions older than this many intervals. `None` keeps
301    /// everything — the safe default, since the alternative silently deletes
302    /// data on a schedule.
303    pub retention: Option<u32>,
304}
305
306impl PartitionDescriptor {
307    /// `retention` as a PostgreSQL interval literal, for `part_config`.
308    pub fn retention_interval(&self) -> Option<String> {
309        self.retention.map(|n| match self.interval {
310            PartitionInterval::Daily => format!("{n} days"),
311            PartitionInterval::Weekly => format!("{n} weeks"),
312            PartitionInterval::Monthly => format!("{n} months"),
313            PartitionInterval::Yearly => format!("{n} years"),
314        })
315    }
316}
317
318#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
319pub struct VectorIndexDescriptor {
320    /// `None` = default (bare) index; `Some(name)` = named index.
321    pub index_name: Option<String>,
322    /// Source pointers whose text is concatenated to form the embedding input.
323    pub pointers: Vec<String>,
324    /// Embedding model identifier, e.g. `"mistral-embed"`.
325    pub model: String,
326    /// Distance metric: `"cosine"` | `"euclidean"` | `"inner_product"`.
327    pub metric: String,
328    /// Embedding dimension, e.g. `1024`.
329    pub dimensions: u32,
330}
331
332impl VectorIndexDescriptor {
333    /// PostgreSQL column name for this index's vector column.
334    pub fn column_name(&self) -> String {
335        match &self.index_name {
336            None => "__vector__".to_string(),
337            Some(name) => format!("__vector_{}__", name),
338        }
339    }
340
341    /// pgvector operator class for the configured metric.
342    pub fn ops_class(&self) -> &'static str {
343        match self.metric.as_str() {
344            "euclidean" => "vector_l2_ops",
345            "inner_product" => "vector_ip_ops",
346            _ => "vector_cosine_ops",
347        }
348    }
349}
350
351#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
352pub struct IndexDescriptor {
353    /// Pointer names for a simple or composite index; empty when is_expression=true.
354    pub pointers: Vec<String>,
355    /// PyQL expression for an expression index.
356    pub expression: Option<String>,
357    pub unique: bool,
358    /// PyQL partial-index predicate.
359    pub unless: Option<String>,
360}
361
362#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
363pub struct TriggerDescriptor {
364    /// Bitmask: 1=Insert, 2=Update, 4=Delete.
365    pub on: u8,
366    /// "Before" | "After" | "InsteadOf"
367    pub timing: String,
368    pub handler: String,
369}
370
371#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
372pub enum TypeConstraint {
373    /// Composite UNIQUE INDEX across multiple pointers.
374    Exclusive {
375        pointers: Vec<String>,
376        unless: Option<String>,
377    },
378    /// Arbitrary CHECK constraint expressed as a PyQL boolean expression.
379    Expression { expr: String },
380}
381
382// ── Type descriptor ────────────────────────────────────────────────────────────
383
384#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
385pub struct TypeDescriptor {
386    /// Unqualified name, e.g. `Product`.
387    pub name: String,
388    /// Pylon module, e.g. `catalog`.
389    pub module: String,
390    /// PostgreSQL table (or view) name, e.g. `catalog_product`.
391    pub table: String,
392    /// True for `@pylon.abstract` / `@pylon.interface`.
393    pub abstract_: bool,
394    /// True when a PostgreSQL VIEW is emitted (always true for `@pylon.interface`).
395    pub materialized: bool,
396    pub description: Option<String>,
397    /// Qualified names of abstract (non-materialized) parents.
398    pub parents: Vec<String>,
399    /// Qualified names of interface parents.
400    pub interfaces: Vec<String>,
401    /// Qualified names of the concrete types this one extends, nearest first
402    /// (`BrandAddonBundle` extends `BrandAddon`). Each has a table of its own,
403    /// and reading one of them reads this type's rows too.
404    #[serde(default)]
405    pub bases: Vec<String>,
406    /// Flattened properties (includes inherited from abstract parents).
407    pub properties: Vec<PropertyDescriptor>,
408    /// Flattened links.
409    pub links: Vec<LinkDescriptor>,
410    /// Flattened multi-links.
411    pub multilinks: Vec<MultiLinkDescriptor>,
412    /// Computed (virtual) pointers.
413    pub computed: Vec<ComputedDescriptor>,
414    /// Composite UNIQUE and CHECK constraints (own + inherited from abstract parents).
415    pub constraints: Vec<TypeConstraint>,
416    /// Non-unique indexes (own + inherited from abstract parents).
417    pub indexes: Vec<IndexDescriptor>,
418    /// Declarative range partitioning, at most one per type. `None` for an
419    /// ordinary table.
420    #[serde(default)]
421    pub partition: Option<PartitionDescriptor>,
422    /// Vector (embedding) indexes.
423    pub vector_indexes: Vec<VectorIndexDescriptor>,
424    /// Full-text search indexes.
425    pub search_indexes: Vec<SearchIndexDescriptor>,
426    /// Triggers (own + inherited from abstract parents).
427    pub triggers: Vec<TriggerDescriptor>,
428    /// True for `@pylon.junction` — type is a junction table for a MultiLink.
429    pub junction: bool,
430    /// Post-commit signal registrations from the Python-side registry — one
431    /// entry per distinct `on=` bitmask a handler was registered with (not
432    /// one per handler). Empty unless at least one signal targets this
433    /// type; drives whether a capture trigger gets attached at all.
434    pub signals: Vec<SignalEntry>,
435}
436
437// ── Scalar / enum / global descriptors ────────────────────────────────────────
438
439#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
440pub struct ScalarDescriptor {
441    /// Qualified scalar name, e.g. `default::Email`.
442    pub name: String,
443    pub module: String,
444    /// Pylon base scalar, e.g. `Str`, `Int64`.
445    pub base: String,
446    /// PostgreSQL base type, e.g. `text`, `int8`.
447    pub pg_type: String,
448    /// Pre-compiled SQL CHECK expressions for the DOMAIN constraint.
449    pub check_constraints: Vec<String>,
450    /// True when the scalar extends `pylon.Sequence` — generates a PostgreSQL SEQUENCE.
451    pub is_sequence: bool,
452}
453
454#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
455pub struct EnumDescriptor {
456    pub name: String,
457    pub module: String,
458    pub members: Vec<String>,
459}
460
461/// One member's type within a named-tuple/tuple-shaped value — recursive so a
462/// member can itself be a nested tuple. Drives decode-time shape building
463/// (`ShapeNode`) so a jsonb-backed tuple value decodes with real per-member
464/// types instead of an opaque dict/list.
465#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
466pub enum TupleMemberKind {
467    Scalar {
468        pg_type: String,
469    },
470    Enum {
471        module: String,
472        name: String,
473    },
474    /// A member typed as a registered `@pylon.named_tuple` class.
475    NamedTuple {
476        module: String,
477        name: String,
478    },
479    /// A member typed as a nested structural `pylon.Tuple[...]`.
480    Tuple {
481        members: Vec<TupleMemberDescriptor>,
482    },
483}
484
485#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
486pub struct TupleMemberDescriptor {
487    /// `None` for an unnamed/positional element of a structural tuple.
488    pub name: Option<String>,
489    pub kind: TupleMemberKind,
490}
491
492/// A registered (nominal) `@pylon.named_tuple` type — used both for cast-target
493/// resolution (`<module::Name>expr` → jsonb) and, via `members`, for decoding a
494/// value read back from a column/cast of this type with real per-member types
495/// instead of an opaque dict.
496#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
497pub struct NamedTupleDescriptor {
498    pub name: String,
499    pub module: String,
500    pub members: Vec<TupleMemberDescriptor>,
501}
502
503#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
504pub struct GlobalDescriptor {
505    pub name: String,
506    pub module: String,
507    pub scalar_type: String,
508    pub required: bool,
509    pub default_expr: Option<String>,
510    /// PyQL expression string for computed globals (e.g. `select User filter .id = global current_user_id`).
511    /// When set, the global is computed from this expression at query time rather than injected as a parameter.
512    pub computed_expr: Option<String>,
513}
514
515// ── Function descriptors ────────────────────────────────────────────────────────
516
517#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
518pub struct FunctionParamDescriptor {
519    pub name: String,
520    /// PostgreSQL type string, e.g. `int8`, `text`, `uuid`.
521    pub pg_type: String,
522}
523
524#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
525pub struct FunctionDescriptor {
526    pub name: String,
527    pub module: String,
528    pub params: Vec<FunctionParamDescriptor>,
529    /// For scalar returns: the PG type (e.g. `int8`).
530    /// For object returns: the qualified type name (e.g. `default::Account`).
531    pub return_pg_type: String,
532    pub return_is_object: bool,
533    /// True when the return is `set[T]`.
534    pub return_is_set: bool,
535    /// True when the return type is a polymorphic interface (abstract + materialized).
536    pub return_is_polymorphic: bool,
537    /// "immutable" | "stable" | "volatile"
538    pub volatility: String,
539    /// PyQL expression string (the function body from the docstring).
540    pub body: String,
541}
542
543// ── Alias descriptor ───────────────────────────────────────────────────────────
544
545#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
546pub struct AliasDescriptor {
547    pub name: String,
548    pub module: String,
549    /// PyQL expression string (the alias body).
550    pub expr: String,
551}
552
553// ── Channel descriptor ──────────────────────────────────────────────────────────
554
555/// The shape of a `Channel`'s payload, as declared in the schema DSL.
556#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
557pub enum ChannelPayload {
558    /// A registered object type, by its qualified name (e.g. `default::User`) —
559    /// `notify()` sends the object, `listen()` decodes into that type.
560    Type(String),
561    /// A plain scalar's Postgres type (e.g. `text`, `int8`).
562    Scalar(String),
563    /// An ad hoc named-field payload (`pylon.Object(...)`) — field name to
564    /// scalar Postgres type, in declaration order. No backing table; decodes
565    /// client-side into a `pylon.Object` instance.
566    Object(Vec<(String, String)>),
567}
568
569/// A PostgreSQL pub/sub channel (`NOTIFY`/`LISTEN`) declared in the schema.
570/// Has zero physical DDL footprint — nothing here ever produces a DDL step
571/// in `diff_schema_steps`; its mere presence in a schema is exactly the kind
572/// of content-only change `schema_content_changed` exists to catch.
573#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
574pub struct ChannelDescriptor {
575    pub name: String,
576    pub module: String,
577    /// The actual PostgreSQL NOTIFY/LISTEN channel identifier. Unlike every
578    /// other named construct in this descriptor, Postgres channels have no
579    /// schema namespacing at all — a flat, database-wide identifier — so
580    /// this is already fully disambiguated (module folded in) by the time
581    /// it gets here; see `pylon.schema._channels.wire_name_for_channel`.
582    pub wire_name: String,
583    pub payload: ChannelPayload,
584    pub description: Option<String>,
585}
586
587// ── Top-level schema ───────────────────────────────────────────────────────────
588
589#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
590pub struct SchemaDescriptor {
591    pub types: Vec<TypeDescriptor>,
592    pub scalars: Vec<ScalarDescriptor>,
593    pub enums: Vec<EnumDescriptor>,
594    pub named_tuples: Vec<NamedTupleDescriptor>,
595    pub globals: Vec<GlobalDescriptor>,
596    pub functions: Vec<FunctionDescriptor>,
597    pub aliases: Vec<AliasDescriptor>,
598    pub channels: Vec<ChannelDescriptor>,
599    /// `ir::functions_needing_globals`, which compiles every function body
600    /// and is asked for by every compile that calls one. Filled on first use,
601    /// so a schema must not change once it has been compiled against.
602    #[serde(skip)]
603    pub functions_needing_globals: Derived<std::collections::HashSet<String>>,
604}
605
606/// A value computed from the schema on first use. A clone starts out empty,
607/// so a copy that is then changed never reads the original's value.
608#[derive(Debug)]
609pub struct Derived<T>(std::sync::OnceLock<T>);
610
611impl<T> Derived<T> {
612    pub fn get_or_init(&self, init: impl FnOnce() -> T) -> &T {
613        self.0.get_or_init(init)
614    }
615}
616
617impl<T> Default for Derived<T> {
618    fn default() -> Self {
619        Derived(std::sync::OnceLock::new())
620    }
621}
622
623impl<T> Clone for Derived<T> {
624    fn clone(&self) -> Self {
625        Derived::default()
626    }
627}
628
629impl SchemaDescriptor {
630    /// Find a declared `Channel` by bare or `module::name` reference — the
631    /// single lookup both `notify()` (`ir::compiler::Compiler::resolve_channel`)
632    /// and `pylon-client`'s `Client::listen()` resolve a channel argument
633    /// through, so a schema author's `notify(Foo, ...)` and a client's
634    /// `listen("Foo")` agree on exactly the same name.
635    pub fn find_channel(&self, name: &str) -> Option<&ChannelDescriptor> {
636        self.channels
637            .iter()
638            .find(|c| c.name == name || format!("{}::{}", c.module, c.name) == name)
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    fn schema_with_one_channel() -> SchemaDescriptor {
647        SchemaDescriptor {
648            channels: vec![ChannelDescriptor {
649                name: "Pings".into(),
650                module: "shop".into(),
651                wire_name: "shop__pings".into(),
652                payload: ChannelPayload::Scalar("text".into()),
653                description: None,
654            }],
655            ..Default::default()
656        }
657    }
658
659    #[test]
660    fn find_channel_matches_bare_name() {
661        let schema = schema_with_one_channel();
662        assert_eq!(
663            schema.find_channel("Pings").map(|c| c.wire_name.as_str()),
664            Some("shop__pings")
665        );
666    }
667
668    #[test]
669    fn find_channel_matches_qualified_name() {
670        let schema = schema_with_one_channel();
671        assert_eq!(
672            schema.find_channel("shop::Pings").map(|c| c.wire_name.as_str()),
673            Some("shop__pings")
674        );
675    }
676
677    #[test]
678    fn find_channel_returns_none_for_unknown_name() {
679        let schema = schema_with_one_channel();
680        assert!(schema.find_channel("NoSuchChannel").is_none());
681    }
682}