@@ -8,6 +8,7 @@ use std::{
8
8
sync:: LazyLock ,
9
9
} ;
10
10
11
+ use anyhow:: bail;
11
12
use common:: {
12
13
bootstrap_model:: index:: database_index:: IndexedFields ,
13
14
document:: CREATION_TIME_FIELD_PATH ,
@@ -30,6 +31,7 @@ use common::{
30
31
} ;
31
32
use convex_fivetran_common:: fivetran_sdk:: {
32
33
self ,
34
+ Column ,
33
35
DataType as FivetranDataType ,
34
36
} ;
35
37
@@ -52,11 +54,13 @@ use crate::{
52
54
SYNCED_FIVETRAN_FIELD_NAME ,
53
55
} ,
54
56
error:: {
57
+ DescribeTableError ,
55
58
DestinationError ,
56
59
SuggestedIndex ,
57
60
SuggestedTable ,
58
61
TableSchemaError ,
59
62
} ,
63
+ log,
60
64
} ;
61
65
62
66
/// The default name of the sync index suggested to the user in error messages.
@@ -540,6 +544,165 @@ pub fn validate_destination_schema_table(
540
544
Ok ( ( ) )
541
545
}
542
546
547
+ /// Converts the given Convex schema table to a Fivetran table. This is used in
548
+ /// the implementation of the `AlterTable` endpoint so that Fivetran can be
549
+ /// aware of the current state of the Convex destination.
550
+ #[ allow( dead_code) ]
551
+ pub fn to_fivetran_table (
552
+ convex_table : & TableDefinition ,
553
+ ) -> anyhow:: Result < fivetran_sdk:: Table , DescribeTableError > {
554
+ let fivetran_columns = to_fivetran_columns ( convex_table) ?;
555
+
556
+ Ok ( fivetran_sdk:: Table {
557
+ name : convex_table. table_name . to_string ( ) ,
558
+ columns : fivetran_columns,
559
+ } )
560
+ }
561
+
562
+ /// Returns the validator for the `fivetran` field of the given Convex table
563
+ /// definition.
564
+ ///
565
+ /// Returns `None` if the `fivetran` field isn’t specified or is incorrectly
566
+ /// specified.
567
+ fn metadata_field_validator ( validator : & ObjectValidator ) -> Option < & ObjectValidator > {
568
+ // System columns
569
+ let field_validator = validator. 0 . get ( & METADATA_CONVEX_FIELD_NAME . clone ( ) ) ?;
570
+ let Validator :: Object ( metadata_object_validator) = field_validator. validator ( ) else {
571
+ return None ;
572
+ } ;
573
+
574
+ Some ( metadata_object_validator)
575
+ }
576
+
577
+ fn user_columns ( table_def : & TableDefinition , validator : & ObjectValidator ) -> Vec < Column > {
578
+ let primary_key_index = table_def. indexes . get ( & PRIMARY_KEY_INDEX_DESCRIPTOR ) ;
579
+ if primary_key_index. is_none ( ) {
580
+ log ( & format ! (
581
+ "The table {} in your Convex schema is missing a `by_primary_key` index, so Fivetran \
582
+ will not able to identify the columns of its primary key.",
583
+ table_def. table_name
584
+ ) ) ;
585
+ }
586
+
587
+ validator
588
+ . 0
589
+ . iter ( )
590
+ . filter ( |( field_name, _) | * * field_name != * METADATA_CONVEX_FIELD_NAME )
591
+ . flat_map ( |( field_name, field_validator) | {
592
+ let fivetran_data_type = recognize_fivetran_type ( field_validator. validator ( ) ) . ok ( ) ;
593
+ if fivetran_data_type. is_none ( ) {
594
+ log ( & format ! (
595
+ "The type of the field `field_name` in the table `{}` isn’t supported by \
596
+ Fivetran.",
597
+ table_def. table_name
598
+ ) )
599
+ }
600
+
601
+ Some ( fivetran_sdk:: Column {
602
+ name : field_name. to_string ( ) ,
603
+ r#type : fivetran_data_type. unwrap_or ( FivetranDataType :: Unspecified ) as i32 ,
604
+ primary_key : primary_key_index. is_some_and ( |primary_key_index| {
605
+ primary_key_index
606
+ . fields
607
+ . contains ( & FieldPath :: for_root_field ( field_name. clone ( ) ) )
608
+ } ) ,
609
+ decimal : None ,
610
+ } )
611
+ } )
612
+ . collect ( )
613
+ }
614
+
615
+ fn to_fivetran_columns (
616
+ table_def : & TableDefinition ,
617
+ ) -> Result < Vec < fivetran_sdk:: Column > , DescribeTableError > {
618
+ let Some ( DocumentSchema :: Union ( validators) ) = & table_def. document_type else {
619
+ return Err ( DescribeTableError :: DestinationHasAnySchema (
620
+ table_def. table_name . clone ( ) ,
621
+ ) ) ;
622
+ } ;
623
+ let [ validator] = & validators[ ..] else {
624
+ return Err ( DescribeTableError :: DestinationHasMultipleSchemas (
625
+ table_def. table_name . clone ( ) ,
626
+ ) ) ;
627
+ } ;
628
+
629
+ let mut columns: Vec < fivetran_sdk:: Column > = Vec :: new ( ) ;
630
+
631
+ // System columns
632
+ let metadata_validator = metadata_field_validator ( validator) ;
633
+ if let Some ( metadata_validator) = metadata_validator {
634
+ // Soft delete
635
+ if metadata_validator
636
+ . 0
637
+ . contains_key ( & SOFT_DELETE_CONVEX_FIELD_NAME . clone ( ) )
638
+ {
639
+ columns. push ( fivetran_sdk:: Column {
640
+ name : SOFT_DELETE_FIVETRAN_FIELD_NAME . to_string ( ) ,
641
+ r#type : FivetranDataType :: Boolean as i32 ,
642
+ primary_key : false ,
643
+ decimal : None ,
644
+ } ) ;
645
+ }
646
+
647
+ // Fivetran pseudo-ID
648
+ if let Some ( field_validator) = metadata_validator. 0 . get ( & ID_CONVEX_FIELD_NAME . clone ( ) ) {
649
+ let id_field_type = recognize_fivetran_type ( field_validator. validator ( ) ) . ok ( ) ;
650
+ if id_field_type. is_none ( ) {
651
+ log ( & format ! (
652
+ "The type of the field `convex.id` in the table `{}` isn’t supported by \
653
+ Fivetran.",
654
+ table_def. table_name
655
+ ) )
656
+ }
657
+
658
+ columns. push ( fivetran_sdk:: Column {
659
+ name : ID_FIVETRAN_FIELD_NAME . to_string ( ) ,
660
+ r#type : id_field_type. unwrap_or ( FivetranDataType :: Unspecified ) as i32 ,
661
+ primary_key : true ,
662
+ decimal : None ,
663
+ } ) ;
664
+ }
665
+
666
+ // Synchronization timestamp
667
+ columns. push ( fivetran_sdk:: Column {
668
+ name : SYNCED_FIVETRAN_FIELD_NAME . to_string ( ) ,
669
+ r#type : FivetranDataType :: UtcDatetime as i32 ,
670
+ primary_key : false ,
671
+ decimal : None ,
672
+ } ) ;
673
+ }
674
+
675
+ // User columns
676
+ columns. append ( & mut user_columns ( table_def, validator) ) ;
677
+
678
+ Ok ( columns)
679
+ }
680
+
681
+ fn recognize_fivetran_type ( validator : & Validator ) -> anyhow:: Result < FivetranDataType > {
682
+ match validator {
683
+ Validator :: Float64 => Ok ( FivetranDataType :: Double ) ,
684
+ Validator :: Int64 => Ok ( FivetranDataType :: Long ) ,
685
+ Validator :: Boolean => Ok ( FivetranDataType :: Boolean ) ,
686
+ Validator :: String => Ok ( FivetranDataType :: String ) ,
687
+ Validator :: Bytes => Ok ( FivetranDataType :: Binary ) ,
688
+ Validator :: Object ( _) | Validator :: Array ( _) => Ok ( FivetranDataType :: Json ) ,
689
+
690
+ // Allow nullable types
691
+ Validator :: Union ( validators) => match & validators[ ..] {
692
+ [ v] | [ Validator :: Null , v] | [ v, Validator :: Null ] => recognize_fivetran_type ( v) ,
693
+ _ => bail ! ( "Unsupported union" ) ,
694
+ } ,
695
+
696
+ Validator :: Null
697
+ | Validator :: Literal ( _)
698
+ | Validator :: Id ( _)
699
+ | Validator :: Set ( _)
700
+ | Validator :: Record ( ..)
701
+ | Validator :: Map ( ..)
702
+ | Validator :: Any => bail ! ( "The type of this Convex column isn’t supported by Fivetran." ) ,
703
+ }
704
+ }
705
+
543
706
#[ cfg( test) ]
544
707
mod tests {
545
708
use std:: {
@@ -593,6 +756,7 @@ mod tests {
593
756
} ;
594
757
use crate :: {
595
758
error:: DestinationError ,
759
+ schema:: to_fivetran_table,
596
760
testing:: fivetran_table_strategy,
597
761
} ;
598
762
@@ -1132,6 +1296,140 @@ mod tests {
1132
1296
Ok ( ( ) )
1133
1297
}
1134
1298
1299
+ #[ test]
1300
+ fn it_converts_convex_tables_to_fivetran_tables ( ) -> anyhow:: Result < ( ) > {
1301
+ assert_eq ! (
1302
+ to_fivetran_table( & convex_table(
1303
+ btreemap! {
1304
+ "id" => FieldValidator :: required_field_type( Validator :: Int64 ) ,
1305
+ "name" => FieldValidator :: required_field_type( Validator :: Union ( vec![
1306
+ Validator :: Null ,
1307
+ Validator :: String ,
1308
+ ] ) ) ,
1309
+ "fivetran" => FieldValidator :: required_field_type( Validator :: Object (
1310
+ object_validator!(
1311
+ "synced" => FieldValidator :: required_field_type( Validator :: Float64 ) ,
1312
+ ) ,
1313
+ ) ) ,
1314
+ } ,
1315
+ btreemap! {
1316
+ "by_primary_key" => vec![
1317
+ FieldPath :: new( vec![
1318
+ IdentifierFieldName :: from_str( "id" ) ?,
1319
+ ] ) ?,
1320
+ CREATION_TIME_FIELD_PATH . clone( ) ,
1321
+ ] ,
1322
+ "sync_index" => vec![
1323
+ FieldPath :: new( vec![
1324
+ IdentifierFieldName :: from_str( "fivetran" ) ?,
1325
+ IdentifierFieldName :: from_str( "synced" ) ?,
1326
+ ] ) ?,
1327
+ CREATION_TIME_FIELD_PATH . clone( ) ,
1328
+ ] ,
1329
+ } ,
1330
+ ) ) ?,
1331
+ Table {
1332
+ name: "table_name" . into( ) ,
1333
+ columns: vec![
1334
+ Column {
1335
+ name: "_fivetran_synced" . to_string( ) ,
1336
+ r#type: FivetranDataType :: UtcDatetime as i32 ,
1337
+ primary_key: false ,
1338
+ decimal: None ,
1339
+ } ,
1340
+ Column {
1341
+ name: "id" . to_string( ) ,
1342
+ r#type: FivetranDataType :: Long as i32 ,
1343
+ primary_key: true ,
1344
+ decimal: None ,
1345
+ } ,
1346
+ Column {
1347
+ name: "name" . to_string( ) ,
1348
+ r#type: FivetranDataType :: String as i32 ,
1349
+ primary_key: false ,
1350
+ decimal: None ,
1351
+ } ,
1352
+ ] ,
1353
+ }
1354
+ ) ;
1355
+
1356
+ Ok ( ( ) )
1357
+ }
1358
+
1359
+ #[ test]
1360
+ fn it_converts_convex_tables_to_fivetran_tables_with_soft_deletes_and_fivetran_id (
1361
+ ) -> anyhow:: Result < ( ) > {
1362
+ assert_eq ! (
1363
+ to_fivetran_table( & convex_table(
1364
+ btreemap! {
1365
+ "data" => FieldValidator :: required_field_type( Validator :: Bytes ) ,
1366
+ "fivetran" => FieldValidator :: required_field_type( Validator :: Object (
1367
+ object_validator!(
1368
+ "synced" => FieldValidator :: required_field_type( Validator :: Float64 ) ,
1369
+ "id" => FieldValidator :: required_field_type( Validator :: String ) ,
1370
+ "deleted" => FieldValidator :: required_field_type( Validator :: Boolean ) ,
1371
+ ) ,
1372
+ ) ) ,
1373
+ } ,
1374
+ btreemap! {
1375
+ "by_primary_key" => vec![
1376
+ FieldPath :: new( vec![
1377
+ IdentifierFieldName :: from_str( "fivetran" ) ?,
1378
+ IdentifierFieldName :: from_str( "deleted" ) ?,
1379
+ ] ) ?,
1380
+ FieldPath :: new( vec![
1381
+ IdentifierFieldName :: from_str( "fivetran" ) ?,
1382
+ IdentifierFieldName :: from_str( "id" ) ?,
1383
+ ] ) ?,
1384
+ CREATION_TIME_FIELD_PATH . clone( ) ,
1385
+ ] ,
1386
+ "sync_index" => vec![
1387
+ FieldPath :: new( vec![
1388
+ IdentifierFieldName :: from_str( "fivetran" ) ?,
1389
+ IdentifierFieldName :: from_str( "deleted" ) ?,
1390
+ ] ) ?,
1391
+ FieldPath :: new( vec![
1392
+ IdentifierFieldName :: from_str( "fivetran" ) ?,
1393
+ IdentifierFieldName :: from_str( "synced" ) ?,
1394
+ ] ) ?,
1395
+ CREATION_TIME_FIELD_PATH . clone( ) ,
1396
+ ] ,
1397
+ } ,
1398
+ ) ) ?,
1399
+ Table {
1400
+ name: "table_name" . into( ) ,
1401
+ columns: vec![
1402
+ Column {
1403
+ name: "_fivetran_deleted" . to_string( ) ,
1404
+ r#type: FivetranDataType :: Boolean as i32 ,
1405
+ primary_key: false ,
1406
+ decimal: None ,
1407
+ } ,
1408
+ Column {
1409
+ name: "_fivetran_id" . to_string( ) ,
1410
+ r#type: FivetranDataType :: String as i32 ,
1411
+ primary_key: true ,
1412
+ decimal: None ,
1413
+ } ,
1414
+ Column {
1415
+ name: "_fivetran_synced" . to_string( ) ,
1416
+ r#type: FivetranDataType :: UtcDatetime as i32 ,
1417
+ primary_key: false ,
1418
+ decimal: None ,
1419
+ } ,
1420
+ Column {
1421
+ name: "data" . to_string( ) ,
1422
+ r#type: FivetranDataType :: Binary as i32 ,
1423
+ primary_key: false ,
1424
+ decimal: None ,
1425
+ } ,
1426
+ ] ,
1427
+ }
1428
+ ) ;
1429
+
1430
+ Ok ( ( ) )
1431
+ }
1432
+
1135
1433
#[ test]
1136
1434
fn it_suggests_convex_tables ( ) -> anyhow:: Result < ( ) > {
1137
1435
let fivetran_table = fivetran_table_schema (
0 commit comments