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}