Skip to main content

faucet_core/
capabilities.rs

1//! Declared connector capabilities (RFC 0013, #876).
2//!
3//! A connector says what it can do through [`Capabilities`]: the write modes it
4//! applies, whether it commits a watermark atomically, whether it can clean up,
5//! roll back, evolve a schema, report lag, digest a key range, and so on. Two
6//! places carry the declaration:
7//!
8//! - [`Source::capabilities`] /
9//!   [`Sink::capabilities`] on a built connector.
10//!   The default derives every field from the older per-capability trait
11//!   methods, so a connector that overrides only those keeps working.
12//! - A static [`ConnectorDescriptor`] registered with the connector's factory.
13//!   Its `fn(&Value) -> Capabilities` answers from the connector's config alone
14//!   — pure, no I/O — so load-time gates (`faucet validate`, expand) can check
15//!   a config before anything connects.
16//!
17//! The two must agree for every config: `faucet-conformance`'s
18//! `assert_descriptor_matches_*` checks it, and every built-in connector calls
19//! it from its `tests/conformance.rs`.
20//!
21//! **Config-dependent capabilities.** A capability that depends on the config
22//! (a SQL sink's cleanup needs column mapping, a sink only dedups by key under
23//! `write_mode: upsert` with a `key`) is answered by the descriptor function
24//! reading the relevant fields of the raw config value. Descriptors read field
25//! by field and fall back to the connector's defaults for anything missing or
26//! not yet interpolated, so a config that has not been resolved yet still gets
27//! an answer; they never fail. Called with `Value::Null` instead of a config
28//! object, a descriptor answers the kind-level view
29//! ([`ConnectorDescriptor::kind_capabilities`]): every capability some valid
30//! config enables (config-only facts such as `keyed_dedup` stay off).
31
32use crate::idempotency::{ReplayGuarantee, SinkGuarantee};
33use crate::traits::{Sink, Source};
34use crate::write_mode::{WriteMode, WriteSpec};
35use serde::{Deserialize, Serialize};
36use serde_json::Value;
37
38/// How a sink treats a record field its destination has no column for — the
39/// fact the schema-drift policy needs to decide whether it can act (#883).
40#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42#[non_exhaustive]
43pub enum SchemaReport {
44    /// The sink has not declared it.
45    #[default]
46    Undeclared,
47    /// [`Sink::current_schema`] reports the live
48    /// destination schema, so the drift pass sees every column change.
49    Reported,
50    /// The sink reports no schema and writes every field it is given.
51    Schemaless,
52    /// The sink reports no schema and silently drops fields its destination
53    /// schema lacks. A drift policy is blind against it, so `on_drift: fail`
54    /// is refused at load time rather than silently inert.
55    DropsUnknownFields,
56}
57
58impl SchemaReport {
59    /// Wire name, for messages and docs.
60    pub fn as_str(&self) -> &'static str {
61        match self {
62            SchemaReport::Undeclared => "undeclared",
63            SchemaReport::Reported => "reported",
64            SchemaReport::Schemaless => "schemaless",
65            SchemaReport::DropsUnknownFields => "drops_unknown_fields",
66        }
67    }
68}
69
70/// What a connector can do, as declared by the connector itself.
71///
72/// Build one with [`Capabilities::builder`]; read it through the accessor
73/// methods. Source-side and sink-side fields live in one type so a registry
74/// can treat both alike; the fields that do not apply to a role stay at their
75/// defaults.
76#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
77#[non_exhaustive]
78pub struct Capabilities {
79    write_modes: Vec<WriteMode>,
80    idempotent_writes: bool,
81    keyed_upsert: bool,
82    keyed_dedup: bool,
83    cleanup: bool,
84    rollback: bool,
85    schema_evolution: bool,
86    schema_report: SchemaReport,
87    scoped_overwrite: bool,
88    direct_overwrite: bool,
89    staged_load: bool,
90    exactly_once: bool,
91    discover: bool,
92    shardable: bool,
93    range_read: bool,
94    range_digest: bool,
95    reports_lag: bool,
96    columnar: bool,
97    native: bool,
98    table_routing: bool,
99    read_side_effects: bool,
100}
101
102const MODE_ORDER: [WriteMode; 4] = [
103    WriteMode::Append,
104    WriteMode::Upsert,
105    WriteMode::Delete,
106    WriteMode::Overwrite,
107];
108
109fn mode_rank(m: &WriteMode) -> usize {
110    MODE_ORDER
111        .iter()
112        .position(|o| o == m)
113        .unwrap_or(MODE_ORDER.len())
114}
115
116impl Capabilities {
117    /// A builder with every capability off and no write modes.
118    pub fn builder() -> CapabilitiesBuilder {
119        CapabilitiesBuilder(Capabilities::default())
120    }
121
122    /// A builder starting from these capabilities, to add or remove one.
123    pub fn to_builder(&self) -> CapabilitiesBuilder {
124        CapabilitiesBuilder(self.clone())
125    }
126
127    /// What a plain source declares: nothing.
128    pub fn plain_source() -> Self {
129        Self::default()
130    }
131
132    /// What a plain sink declares: append only.
133    pub fn append_only_sink() -> Self {
134        Self::builder().write_modes([WriteMode::Append]).build()
135    }
136
137    /// Derive a source's capabilities from its per-capability trait methods.
138    /// The default [`Source::capabilities`]; an
139    /// override that adds a capability no trait method expresses starts here
140    /// (`Capabilities::of_source(self).to_builder().range_digest(true).build()`).
141    pub fn of_source<S: Source + ?Sized>(source: &S) -> Self {
142        #[cfg(feature = "arrow")]
143        let columnar = source.supports_columnar();
144        #[cfg(not(feature = "arrow"))]
145        let columnar = false;
146        Self::builder()
147            .exactly_once(source.replay_guarantee() == ReplayGuarantee::Deterministic)
148            .discover(source.supports_discover())
149            .shardable(source.is_shardable())
150            .columnar(columnar)
151            .native(!source.native_output_formats().is_empty())
152            .build()
153    }
154
155    /// Derive a sink's capabilities from its per-capability trait methods.
156    /// The default [`Sink::capabilities`].
157    pub fn of_sink<S: Sink + ?Sized>(sink: &S) -> Self {
158        #[cfg(feature = "arrow")]
159        let columnar = sink.supports_columnar();
160        #[cfg(not(feature = "arrow"))]
161        let columnar = false;
162        let modes = sink.supported_write_modes();
163        let schema_evolution = sink.supports_schema_evolution();
164        Self::builder()
165            .write_modes(modes.iter().copied())
166            .idempotent_writes(sink.supports_idempotent_writes())
167            .keyed_upsert(
168                modes.contains(&WriteMode::Upsert)
169                    && sink.sink_guarantee() != SinkGuarantee::AtLeastOnce,
170            )
171            .keyed_dedup(sink.dedups_by_key())
172            .cleanup(sink.supports_cleanup())
173            .rollback(sink.supports_rollback())
174            .schema_evolution(schema_evolution)
175            .schema_report(if schema_evolution {
176                SchemaReport::Reported
177            } else {
178                SchemaReport::Undeclared
179            })
180            .staged_load(sink.supports_staged_load())
181            .columnar(columnar)
182            .native(!sink.native_load_capabilities().is_empty())
183            .build()
184    }
185
186    /// A decorator's capabilities: every field a trait method expresses is
187    /// derived from the decorator (which forwards or deliberately withholds
188    /// it), the rest is taken from the wrapped source's `inner` declaration.
189    pub fn decorated_source<S: Source + ?Sized>(outer: &S, inner: &Capabilities) -> Self {
190        Self::of_source(outer)
191            .to_builder()
192            .range_read(inner.range_read)
193            .range_digest(inner.range_digest)
194            .reports_lag(inner.reports_lag)
195            .table_routing(inner.table_routing)
196            .read_side_effects(inner.read_side_effects)
197            .build()
198    }
199
200    /// [`decorated_source`](Self::decorated_source) for sinks.
201    pub fn decorated_sink<S: Sink + ?Sized>(outer: &S, inner: &Capabilities) -> Self {
202        Self::of_sink(outer)
203            .to_builder()
204            .schema_report(inner.schema_report)
205            .scoped_overwrite(inner.scoped_overwrite)
206            .direct_overwrite(inner.direct_overwrite)
207            .build()
208    }
209
210    /// Write modes the sink applies, in canonical order.
211    pub fn write_modes(&self) -> &[WriteMode] {
212        &self.write_modes
213    }
214
215    /// Whether the sink applies `mode`.
216    pub fn supports_write_mode(&self, mode: WriteMode) -> bool {
217        self.write_modes.contains(&mode)
218    }
219
220    /// `write_mode: overwrite` (atomic full-destination replacement).
221    pub fn overwrite(&self) -> bool {
222        self.supports_write_mode(WriteMode::Overwrite)
223    }
224
225    /// Commits a page's rows and a commit token atomically.
226    pub fn idempotent_writes(&self) -> bool {
227        self.idempotent_writes
228    }
229
230    /// Converges by key under `write_mode: upsert` — the keyed-upsert
231    /// effectively-once mechanism. A sink that forwards `key` to a target it
232    /// cannot vouch for accepts upsert without this.
233    pub fn keyed_upsert(&self) -> bool {
234        self.keyed_upsert
235    }
236
237    /// This config dedups by key (`write_mode: upsert|delete` with a `key`).
238    pub fn keyed_dedup(&self) -> bool {
239        self.keyed_dedup
240    }
241
242    /// Scoped cleanup of rows the run did not write (#478).
243    pub fn cleanup(&self) -> bool {
244        self.cleanup
245    }
246
247    /// Undoing a run it wrote (#706).
248    pub fn rollback(&self) -> bool {
249        self.rollback
250    }
251
252    /// Additive / widening DDL (`on_drift: evolve`).
253    pub fn schema_evolution(&self) -> bool {
254        self.schema_evolution
255    }
256
257    /// How the sink treats fields its destination lacks.
258    pub fn schema_report(&self) -> SchemaReport {
259        self.schema_report
260    }
261
262    /// A windowed `overwrite.scope` (#518).
263    pub fn scoped_overwrite(&self) -> bool {
264        self.scoped_overwrite
265    }
266
267    /// A staging-free overwrite that understands the grouped staging signal.
268    pub fn direct_overwrite(&self) -> bool {
269        self.direct_overwrite
270    }
271
272    /// Bulk load through an object-store stage (#528).
273    pub fn staged_load(&self) -> bool {
274        self.staged_load
275    }
276
277    /// Deterministic replay from a bookmark (exactly-once source).
278    pub fn exactly_once(&self) -> bool {
279        self.exactly_once
280    }
281
282    /// Dataset discovery (`faucet discover`).
283    pub fn discover(&self) -> bool {
284        self.discover
285    }
286
287    /// Splits into shards for clustered execution.
288    pub fn shardable(&self) -> bool {
289        self.shardable
290    }
291
292    /// Reads one integer-key range through `apply_shard` without `shard:`
293    /// config (`faucet verify` range mode).
294    pub fn range_read(&self) -> bool {
295        self.range_read
296    }
297
298    /// Computes a server-side digest of a key range (`Source::range_digest`).
299    pub fn range_digest(&self) -> bool {
300        self.range_digest
301    }
302
303    /// Reports how far behind its head it is (`Source::lag`).
304    pub fn reports_lag(&self) -> bool {
305        self.reports_lag
306    }
307
308    /// Speaks Arrow on the columnar path.
309    pub fn columnar(&self) -> bool {
310        self.columnar
311    }
312
313    /// Takes part in the native byte-passthrough path.
314    pub fn native(&self) -> bool {
315        self.native
316    }
317
318    /// Names the table each record belongs to (`Source::record_table`), so
319    /// one change stream can drive a multi-table mirror.
320    pub fn table_routing(&self) -> bool {
321        self.table_routing
322    }
323
324    /// Reading changes state outside the pipeline — creates or advances a
325    /// replication slot or capture position, joins a consumer group, opens a
326    /// listener or a live session — so a read-only preview must not read it.
327    pub fn read_side_effects(&self) -> bool {
328        self.read_side_effects
329    }
330
331    /// The source's typed replay guarantee.
332    pub fn replay_guarantee(&self) -> ReplayGuarantee {
333        if self.exactly_once {
334            ReplayGuarantee::Deterministic
335        } else {
336            ReplayGuarantee::NonDeterministic
337        }
338    }
339
340    /// The strongest delivery guarantee the sink can uphold.
341    pub fn sink_guarantee(&self) -> SinkGuarantee {
342        if self.idempotent_writes {
343            SinkGuarantee::AtomicWatermark
344        } else if self.keyed_upsert {
345            SinkGuarantee::KeyedUpsert
346        } else {
347            SinkGuarantee::AtLeastOnce
348        }
349    }
350}
351
352/// Builder for [`Capabilities`].
353#[derive(Debug, Clone, Default)]
354pub struct CapabilitiesBuilder(Capabilities);
355
356macro_rules! flag {
357    ($($name:ident),* $(,)?) => {
358        $(
359            #[doc = concat!("Set `", stringify!($name), "`.")]
360            #[must_use]
361            pub fn $name(mut self, on: bool) -> Self {
362                self.0.$name = on;
363                self
364            }
365        )*
366    };
367}
368
369impl CapabilitiesBuilder {
370    /// Set the write modes (deduplicated, canonical order).
371    #[must_use]
372    pub fn write_modes(mut self, modes: impl IntoIterator<Item = WriteMode>) -> Self {
373        let mut v: Vec<WriteMode> = Vec::new();
374        for m in modes {
375            if !v.contains(&m) {
376                v.push(m);
377            }
378        }
379        v.sort_by_key(mode_rank);
380        self.0.write_modes = v;
381        self
382    }
383
384    /// Set the schema report.
385    #[must_use]
386    pub fn schema_report(mut self, report: SchemaReport) -> Self {
387        self.0.schema_report = report;
388        self
389    }
390
391    flag!(
392        idempotent_writes,
393        keyed_upsert,
394        keyed_dedup,
395        cleanup,
396        rollback,
397        schema_evolution,
398        scoped_overwrite,
399        direct_overwrite,
400        staged_load,
401        exactly_once,
402        discover,
403        shardable,
404        range_read,
405        range_digest,
406        reports_lag,
407        columnar,
408        native,
409        table_routing,
410        read_side_effects,
411    );
412
413    /// Finish.
414    pub fn build(self) -> Capabilities {
415        self.0
416    }
417}
418
419/// A connector's static description: its `type:` name and a pure function
420/// from its config to its [`Capabilities`]. Registered with the connector's
421/// factory so load-time gates can read capabilities without building it.
422#[derive(Clone, Copy)]
423#[non_exhaustive]
424pub struct ConnectorDescriptor {
425    kind: &'static str,
426    capabilities: fn(&Value) -> Capabilities,
427}
428
429impl ConnectorDescriptor {
430    /// Describe connector `kind` by `capabilities`, which must be pure (no
431    /// I/O) and must agree with the built connector's `capabilities()`.
432    pub const fn new(kind: &'static str, capabilities: fn(&Value) -> Capabilities) -> Self {
433        Self { kind, capabilities }
434    }
435
436    /// The connector's `type:` name.
437    pub fn kind(&self) -> &'static str {
438        self.kind
439    }
440
441    /// The capabilities the connector has under `config` (a config object).
442    pub fn capabilities(&self, config: &Value) -> Capabilities {
443        (self.capabilities)(config)
444    }
445
446    /// The kind-level view: every capability some config of this connector
447    /// enables — what listings and "supported: …" messages show. The
448    /// descriptor function answers it when called with `Value::Null`.
449    pub fn kind_capabilities(&self) -> Capabilities {
450        (self.capabilities)(&Value::Null)
451    }
452}
453
454impl std::fmt::Debug for ConnectorDescriptor {
455    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
456        f.debug_struct("ConnectorDescriptor")
457            .field("kind", &self.kind)
458            .finish_non_exhaustive()
459    }
460}
461
462/// A descriptor function for a source with no capabilities.
463pub fn plain_source(_config: &Value) -> Capabilities {
464    Capabilities::plain_source()
465}
466
467/// A descriptor function for an append-only sink.
468pub fn append_only_sink(_config: &Value) -> Capabilities {
469    Capabilities::append_only_sink()
470}
471
472/// Helpers descriptor functions use to read a raw config value leniently.
473pub mod config {
474    use super::*;
475
476    /// The flattened [`WriteSpec`] of a sink config, or the default when the
477    /// fields are absent or not yet interpolated.
478    pub fn write_spec(config: &Value) -> WriteSpec {
479        let mut spec = WriteSpec::default();
480        if let Some(mode) = config.get("write_mode")
481            && let Ok(m) = serde_json::from_value::<WriteMode>(mode.clone())
482        {
483            spec.write_mode = m;
484        }
485        if let Some(Value::Array(keys)) = config.get("key") {
486            spec.key = keys
487                .iter()
488                .filter_map(|k| k.as_str().map(str::to_string))
489                .collect();
490        }
491        spec
492    }
493
494    /// Whether the config dedups by key.
495    pub fn dedups_by_key(config: &Value) -> bool {
496        write_spec(config).dedups_by_key()
497    }
498
499    /// A string field, when present.
500    pub fn str_field<'a>(config: &'a Value, field: &str) -> Option<&'a str> {
501        config.get(field).and_then(Value::as_str)
502    }
503
504    /// A boolean field, or `default` when absent or not a boolean.
505    pub fn bool_field(config: &Value, field: &str, default: bool) -> bool {
506        config
507            .get(field)
508            .and_then(Value::as_bool)
509            .unwrap_or(default)
510    }
511
512    /// Whether `field` is present and not null.
513    pub fn is_set(config: &Value, field: &str) -> bool {
514        config.get(field).is_some_and(|v| !v.is_null())
515    }
516
517    /// The tag of an enum field serialized as a string or as `{ "type": … }`
518    /// / a single-key object (`{"auto_map": {…}}`).
519    pub fn tag<'a>(config: &'a Value, field: &str) -> Option<&'a str> {
520        match config.get(field)? {
521            Value::String(s) => Some(s),
522            Value::Object(o) => o
523                .get("type")
524                .and_then(Value::as_str)
525                .or_else(|| (o.len() == 1).then(|| o.keys().next().map(String::as_str))?),
526            _ => None,
527        }
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use crate::FaucetError;
535    use async_trait::async_trait;
536    use serde_json::json;
537
538    struct Plain;
539    #[async_trait]
540    impl Source for Plain {
541        async fn fetch_with_context(
542            &self,
543            _: &std::collections::HashMap<String, Value>,
544        ) -> Result<Vec<Value>, FaucetError> {
545            Ok(vec![])
546        }
547    }
548    #[async_trait]
549    impl Sink for Plain {
550        async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
551            Ok(r.len())
552        }
553    }
554
555    struct Rich;
556    #[async_trait]
557    impl Source for Rich {
558        async fn fetch_with_context(
559            &self,
560            _: &std::collections::HashMap<String, Value>,
561        ) -> Result<Vec<Value>, FaucetError> {
562            Ok(vec![])
563        }
564        fn supports_exactly_once(&self) -> bool {
565            true
566        }
567        fn supports_discover(&self) -> bool {
568            true
569        }
570        fn is_shardable(&self) -> bool {
571            true
572        }
573        fn native_output_formats(&self) -> &'static [crate::native::NativeFormat] {
574            &[crate::native::NativeFormat::NdJson]
575        }
576    }
577    #[async_trait]
578    impl Sink for Rich {
579        async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
580            Ok(r.len())
581        }
582        fn supported_write_modes(&self) -> &'static [WriteMode] {
583            &[WriteMode::Overwrite, WriteMode::Upsert, WriteMode::Append]
584        }
585        fn supports_idempotent_writes(&self) -> bool {
586            true
587        }
588        fn dedups_by_key(&self) -> bool {
589            true
590        }
591        fn supports_cleanup(&self) -> bool {
592            true
593        }
594        fn supports_rollback(&self) -> bool {
595            true
596        }
597        fn supports_schema_evolution(&self) -> bool {
598            true
599        }
600        fn supports_staged_load(&self) -> bool {
601            true
602        }
603    }
604
605    /// A descriptor built at run time answers like a `const` one, and the
606    /// test connectors' read and write paths behave like real ones.
607    #[tokio::test]
608    async fn descriptors_built_at_run_time_and_the_test_connectors() {
609        let d = ConnectorDescriptor::new("rich", |_| Sink::capabilities(&Rich));
610        assert_eq!(d.kind(), "rich");
611        assert!(d.kind_capabilities().idempotent_writes());
612        let rows = [json!({"a": 1})];
613        for sink in [&Plain as &dyn Sink, &Rich] {
614            assert_eq!(sink.write_batch(&rows).await.unwrap(), 1);
615        }
616        for source in [&Plain as &dyn Source, &Rich] {
617            assert!(source.fetch_all().await.unwrap().is_empty());
618        }
619    }
620
621    #[test]
622    fn plain_connectors_derive_nothing() {
623        assert_eq!(Source::capabilities(&Plain), Capabilities::plain_source());
624        assert_eq!(Sink::capabilities(&Plain), Capabilities::append_only_sink());
625        assert_eq!(plain_source(&Value::Null), Capabilities::plain_source());
626        assert_eq!(
627            append_only_sink(&Value::Null),
628            Capabilities::append_only_sink()
629        );
630        let s = Capabilities::append_only_sink();
631        assert_eq!(s.sink_guarantee(), SinkGuarantee::AtLeastOnce);
632        assert_eq!(s.replay_guarantee(), ReplayGuarantee::NonDeterministic);
633        assert!(!s.overwrite());
634        assert_eq!(s.schema_report(), SchemaReport::Undeclared);
635    }
636
637    #[test]
638    fn derivation_reads_every_trait_method() {
639        let src = Source::capabilities(&Rich);
640        assert!(src.exactly_once() && src.discover() && src.shardable() && src.native());
641        assert!(!src.columnar() && !src.range_read() && !src.range_digest());
642        assert!(!src.reports_lag());
643        assert_eq!(src.replay_guarantee(), ReplayGuarantee::Deterministic);
644
645        let sink = Sink::capabilities(&Rich);
646        assert_eq!(
647            sink.write_modes(),
648            &[WriteMode::Append, WriteMode::Upsert, WriteMode::Overwrite]
649        );
650        assert!(sink.idempotent_writes() && sink.keyed_upsert() && sink.keyed_dedup());
651        assert!(sink.cleanup() && sink.rollback() && sink.schema_evolution());
652        assert!(sink.staged_load() && sink.overwrite());
653        assert!(!sink.scoped_overwrite() && !sink.direct_overwrite() && !sink.native());
654        assert_eq!(sink.schema_report(), SchemaReport::Reported);
655        assert_eq!(sink.sink_guarantee(), SinkGuarantee::AtomicWatermark);
656    }
657
658    #[test]
659    fn decorated_keeps_inner_declarations_and_outer_derivations() {
660        let inner = Capabilities::builder()
661            .range_read(true)
662            .range_digest(true)
663            .reports_lag(true)
664            .table_routing(true)
665            .read_side_effects(true)
666            .schema_report(SchemaReport::DropsUnknownFields)
667            .scoped_overwrite(true)
668            .direct_overwrite(true)
669            .discover(true)
670            .build();
671        let src = Capabilities::decorated_source(&Plain, &inner);
672        assert!(src.range_read() && src.range_digest() && src.reports_lag());
673        assert!(src.table_routing() && src.read_side_effects());
674        assert!(!src.discover(), "derived from the outer connector");
675        let sink = Capabilities::decorated_sink(&Plain, &inner);
676        assert_eq!(sink.schema_report(), SchemaReport::DropsUnknownFields);
677        assert!(sink.scoped_overwrite() && sink.direct_overwrite());
678        assert_eq!(sink.write_modes(), &[WriteMode::Append]);
679    }
680
681    #[test]
682    fn builder_sets_every_flag_and_round_trips() {
683        let c = Capabilities::builder()
684            .write_modes([WriteMode::Delete, WriteMode::Append, WriteMode::Delete])
685            .keyed_upsert(true)
686            .scoped_overwrite(true)
687            .direct_overwrite(true)
688            .range_read(true)
689            .range_digest(true)
690            .reports_lag(true)
691            .columnar(true)
692            .schema_report(SchemaReport::DropsUnknownFields)
693            .build();
694        assert_eq!(c.write_modes(), &[WriteMode::Append, WriteMode::Delete]);
695        assert!(c.supports_write_mode(WriteMode::Delete));
696        assert!(c.scoped_overwrite() && c.direct_overwrite());
697        assert!(c.range_read() && c.range_digest() && c.reports_lag() && c.columnar());
698        assert_eq!(c.sink_guarantee(), SinkGuarantee::KeyedUpsert);
699        assert_eq!(c.to_builder().build(), c);
700        let off = c.to_builder().columnar(false).build();
701        assert!(!off.columnar());
702        assert!(!c.table_routing() && !c.read_side_effects());
703        let cdc = c
704            .to_builder()
705            .table_routing(true)
706            .read_side_effects(true)
707            .build();
708        assert!(cdc.table_routing() && cdc.read_side_effects());
709        let v = serde_json::to_value(&c).unwrap();
710        assert_eq!(v["schema_report"], "drops_unknown_fields");
711        assert_eq!(v["write_modes"], json!(["append", "delete"]));
712    }
713
714    #[test]
715    fn schema_report_names() {
716        for (r, n) in [
717            (SchemaReport::Undeclared, "undeclared"),
718            (SchemaReport::Reported, "reported"),
719            (SchemaReport::Schemaless, "schemaless"),
720            (SchemaReport::DropsUnknownFields, "drops_unknown_fields"),
721        ] {
722            assert_eq!(r.as_str(), n);
723            assert_eq!(serde_json::to_value(r).unwrap(), json!(n));
724        }
725    }
726
727    fn rich(c: &Value) -> Capabilities {
728        Capabilities::append_only_sink()
729            .to_builder()
730            .keyed_dedup(config::dedups_by_key(c))
731            .build()
732    }
733
734    #[test]
735    fn descriptor_answers_from_config() {
736        const D: ConnectorDescriptor = ConnectorDescriptor::new("rich", rich);
737        assert_eq!(D.kind(), "rich");
738        assert_eq!(D.kind_capabilities(), Capabilities::append_only_sink());
739        assert!(
740            D.capabilities(&json!({"write_mode": "upsert", "key": ["id"]}))
741                .keyed_dedup()
742        );
743        assert!(
744            !D.capabilities(&json!({"write_mode": "upsert"}))
745                .keyed_dedup()
746        );
747        assert!(!D.capabilities(&json!({"write_mode": "${x}"})).keyed_dedup());
748        assert!(format!("{D:?}").contains("rich"));
749    }
750
751    #[test]
752    fn config_helpers_are_lenient() {
753        let c = json!({
754            "write_mode": "delete", "key": ["a", 1], "s": "x", "b": true,
755            "n": null, "m": {"auto_map": {}}, "t": {"type": "jsonb"}, "e": "plain"
756        });
757        let spec = config::write_spec(&c);
758        assert_eq!(spec.write_mode, WriteMode::Delete);
759        assert_eq!(spec.key, vec!["a".to_string()]);
760        assert_eq!(config::str_field(&c, "s"), Some("x"));
761        assert!(config::bool_field(&c, "b", false));
762        assert!(config::bool_field(&c, "missing", true));
763        assert!(!config::is_set(&c, "n") && config::is_set(&c, "s"));
764        assert_eq!(config::tag(&c, "m"), Some("auto_map"));
765        assert_eq!(config::tag(&c, "t"), Some("jsonb"));
766        assert_eq!(config::tag(&c, "e"), Some("plain"));
767        assert_eq!(config::tag(&c, "b"), None);
768        assert_eq!(config::tag(&c, "missing"), None);
769        assert_eq!(
770            config::write_spec(&Value::Null).write_mode,
771            WriteMode::Append
772        );
773    }
774}