• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

supabase / pg_graphql / 19839478330

01 Dec 2025 10:20PM UTC coverage: 83.863% (-7.5%) from 91.36%
19839478330

Pull #613

github

web-flow
Merge 73f7cb2de into 7951d27bb
Pull Request #613: Transpiler re-write to use an AST vs string concatenation

3931 of 5222 new or added lines in 20 files covered. (75.28%)

2 existing lines in 1 file now uncovered.

10160 of 12115 relevant lines covered (83.86%)

919.92 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.3
/src/ast/transpile_node.rs
1
//! AST-based transpilation for NodeBuilder
2
//!
3
//! This module implements the ToAst trait for NodeBuilder, converting it
4
//! to a type-safe AST that can be rendered to SQL.
5

6
use super::{
7
    add_param_from_json, column_ref, func_call, jsonb_build_object, string_literal,
8
    AstBuildContext, ToAst,
9
};
10
use crate::ast::{
11
    BinaryOperator, Expr, FromClause, FunctionArg, Ident, ParamCollector, SelectColumn, SelectStmt,
12
    Stmt,
13
};
14
use crate::builder::{
15
    ColumnBuilder, FunctionSelection, NodeBuilder, NodeIdBuilder, NodeIdInstance, NodeSelection,
16
};
17
use crate::error::{GraphQLError, GraphQLResult};
18
use crate::sql_types::{Table, TypeDetails};
19

20
/// The result of transpiling a NodeBuilder to AST for entrypoint queries
21
pub struct NodeAst {
22
    /// The complete SQL statement
23
    pub stmt: Stmt,
24
}
25

26
/// The result of transpiling a NodeBuilder to an expression (for nested selections)
27
pub struct NodeExprAst {
28
    /// The expression representing this node
29
    pub expr: Expr,
30
}
31

32
impl ToAst for NodeBuilder {
33
    type Ast = NodeAst;
34

35
    fn to_ast(&self, params: &mut ParamCollector) -> GraphQLResult<Self::Ast> {
8✔
36
        let ctx = AstBuildContext::new();
8✔
37
        let block_name = ctx.block_name.clone();
8✔
38

39
        // Build the object clause from selections
40
        let object_expr = build_node_object_expr(&self.selections, &block_name, params)?;
8✔
41

42
        // Build WHERE clause from node_id
43
        let node_id = self
8✔
44
            .node_id
8✔
45
            .as_ref()
8✔
46
            .ok_or("Expected nodeId argument missing")?;
8✔
47

48
        let where_clause = build_node_id_filter(node_id, &self.table, &block_name, params)?;
8✔
49

50
        // Build the SELECT statement
51
        let select = SelectStmt {
8✔
52
            ctes: vec![],
8✔
53
            columns: vec![SelectColumn::expr(object_expr)],
8✔
54
            from: Some(FromClause::Table {
8✔
55
                schema: Some(Ident::new(self.table.schema.clone())),
8✔
56
                name: Ident::new(self.table.name.clone()),
8✔
57
                alias: Some(Ident::new(block_name)),
8✔
58
            }),
8✔
59
            where_clause: Some(where_clause),
8✔
60
            group_by: vec![],
8✔
61
            having: None,
8✔
62
            order_by: vec![],
8✔
63
            limit: None,
8✔
64
            offset: None,
8✔
65
        };
8✔
66

67
        // Wrap in a subquery expression
68
        Ok(NodeAst {
8✔
69
            stmt: Stmt::Select(select),
8✔
70
        })
8✔
71
    }
8✔
72
}
73

74
/// Build the node_id filter as a WHERE clause expression
75
fn build_node_id_filter(
8✔
76
    node_id: &NodeIdInstance,
8✔
77
    table: &Table,
8✔
78
    block_name: &str,
8✔
79
    params: &mut ParamCollector,
8✔
80
) -> GraphQLResult<Expr> {
8✔
81
    // Validate that nodeId belongs to this table
82
    if (&node_id.schema_name, &node_id.table_name) != (&table.schema, &table.name) {
8✔
NEW
83
        return Err(GraphQLError::validation(
×
NEW
84
            "nodeId belongs to a different collection",
×
NEW
85
        ));
×
86
    }
8✔
87

88
    let pk_columns = table.primary_key_columns();
8✔
89
    let mut conditions = Vec::new();
8✔
90

91
    for (col, val) in pk_columns.iter().zip(node_id.values.iter()) {
9✔
92
        let col_expr = column_ref(block_name, &col.name);
9✔
93
        let val_expr = add_param_from_json(params, val, &col.type_name)?;
9✔
94

95
        conditions.push(Expr::BinaryOp {
9✔
96
            left: Box::new(col_expr),
9✔
97
            op: BinaryOperator::Eq,
9✔
98
            right: Box::new(val_expr),
9✔
99
        });
9✔
100
    }
101

102
    // Combine with AND
103
    if conditions.len() == 1 {
8✔
104
        Ok(conditions.remove(0))
7✔
105
    } else {
106
        let mut combined = conditions.remove(0);
1✔
107
        for cond in conditions {
2✔
108
            combined = Expr::BinaryOp {
1✔
109
                left: Box::new(combined),
1✔
110
                op: BinaryOperator::And,
1✔
111
                right: Box::new(cond),
1✔
112
            };
1✔
113
        }
1✔
114
        Ok(combined)
1✔
115
    }
116
}
8✔
117

118
/// Build expression for a NodeBuilder's selections (jsonb_build_object)
119
pub fn build_node_object_expr(
291✔
120
    selections: &[NodeSelection],
291✔
121
    block_name: &str,
291✔
122
    params: &mut ParamCollector,
291✔
123
) -> GraphQLResult<Expr> {
291✔
124
    let mut all_pairs: Vec<(String, Expr)> = Vec::new();
291✔
125

126
    for selection in selections {
1,114✔
127
        match selection {
823✔
128
            NodeSelection::Column(col_builder) => {
742✔
129
                let col_expr = build_column_expr(col_builder, block_name);
742✔
130
                all_pairs.push((col_builder.alias.clone(), col_expr));
742✔
131
            }
742✔
132
            NodeSelection::Typename { alias, typename } => {
7✔
133
                all_pairs.push((alias.clone(), string_literal(typename)));
7✔
134
            }
7✔
135
            NodeSelection::NodeId(node_id_builder) => {
17✔
136
                let node_id_expr = build_node_id_expr(node_id_builder, block_name);
17✔
137
                all_pairs.push((node_id_builder.alias.clone(), node_id_expr));
17✔
138
            }
17✔
139
            NodeSelection::Function(func_builder) => {
13✔
140
                let func_expr = build_function_expr(func_builder, block_name, params)?;
13✔
141
                all_pairs.push((func_builder.alias.clone(), func_expr));
13✔
142
            }
143
            NodeSelection::Connection(conn_builder) => {
18✔
144
                // Connection needs a subquery - will be implemented in transpile_connection
145
                let conn_expr = build_connection_subquery_expr(conn_builder, block_name, params)?;
18✔
146
                all_pairs.push((conn_builder.alias.clone(), conn_expr));
18✔
147
            }
148
            NodeSelection::Node(nested_node) => {
26✔
149
                // Nested node relation - build as subquery
150
                let node_expr = build_relation_subquery_expr(nested_node, block_name, params)?;
26✔
151
                all_pairs.push((nested_node.alias.clone(), node_expr));
26✔
152
            }
153
        }
154
    }
155

156
    // jsonb_build_object has a limit of 100 arguments (50 pairs)
157
    // If we have more, we need to chunk and concatenate with ||
158
    const MAX_PAIRS_PER_CALL: usize = 50;
159

160
    if all_pairs.len() <= MAX_PAIRS_PER_CALL {
291✔
161
        Ok(jsonb_build_object(all_pairs))
288✔
162
    } else {
163
        // Chunk into multiple jsonb_build_object calls and concatenate
164
        let mut chunks: Vec<Expr> = all_pairs
3✔
165
            .chunks(MAX_PAIRS_PER_CALL)
3✔
166
            .map(|chunk| jsonb_build_object(chunk.to_vec()))
9✔
167
            .collect();
3✔
168

169
        // Concatenate with || operator (JsonConcat for JSONB)
170
        let mut result = chunks.remove(0);
3✔
171
        for chunk in chunks {
9✔
172
            result = Expr::BinaryOp {
6✔
173
                left: Box::new(result),
6✔
174
                op: BinaryOperator::JsonConcat,
6✔
175
                right: Box::new(chunk),
6✔
176
            };
6✔
177
        }
6✔
178
        Ok(result)
3✔
179
    }
180
}
291✔
181

182
/// Build expression for a column selection
183
///
184
/// This handles enum mappings by generating CASE expressions when the column
185
/// has an enum type with custom mappings defined.
186
pub fn build_column_expr(col_builder: &ColumnBuilder, block_name: &str) -> Expr {
902✔
187
    let col_ref = column_ref(block_name, &col_builder.column.name);
902✔
188

189
    // Check if this is an enum column with mappings
190
    let maybe_enum = col_builder
902✔
191
        .column
902✔
192
        .type_
902✔
193
        .as_ref()
902✔
194
        .and_then(|t| match &t.details {
902✔
195
            Some(TypeDetails::Enum(enum_)) => Some(enum_),
14✔
196
            _ => None,
888✔
197
        });
902✔
198

199
    if let Some(enum_) = maybe_enum {
902✔
200
        if let Some(ref mappings) = enum_.directives.mappings {
14✔
201
            // Build CASE expression for enum mappings
202
            // case when col = 'pg_value1' then 'graphql_value1' when col = 'pg_value2' then 'graphql_value2' else col::text end
203
            let when_clauses: Vec<(Expr, Expr)> = mappings
4✔
204
                .iter()
4✔
205
                .map(|(pg_val, graphql_val)| {
8✔
206
                    (
8✔
207
                        Expr::BinaryOp {
8✔
208
                            left: Box::new(col_ref.clone()),
8✔
209
                            op: BinaryOperator::Eq,
8✔
210
                            right: Box::new(string_literal(pg_val)),
8✔
211
                        },
8✔
212
                        string_literal(graphql_val),
8✔
213
                    )
8✔
214
                })
8✔
215
                .collect();
4✔
216

217
            let else_clause = Expr::Cast {
4✔
218
                expr: Box::new(col_ref),
4✔
219
                target_type: super::type_name_to_sql_type("text"),
4✔
220
            };
4✔
221

222
            return Expr::Case(super::CaseExpr::searched(when_clauses, Some(else_clause)));
4✔
223
        }
10✔
224
    }
888✔
225

226
    // Apply type adjustment for special OIDs
227
    apply_type_cast(col_ref, col_builder.column.type_oid)
898✔
228
}
902✔
229

230
/// Apply suffix casts for types that need special handling
231
///
232
/// This handles types that need to be converted for GraphQL output:
233
/// - bigint (20) -> text (prevents precision loss in JSON)
234
/// - json/jsonb (114/3802) -> text via #>> '{}'
235
/// - numeric (1700) -> text (prevents precision loss)
236
/// - bigint[] (1016) -> text[]
237
/// - json[]/jsonb[] (199/3807) -> text[]
238
/// - numeric[] (1231) -> text[]
239
pub fn apply_type_cast(expr: Expr, type_oid: u32) -> Expr {
994✔
240
    match type_oid {
994✔
241
        20 => Expr::Cast {
20✔
242
            // bigints as text
20✔
243
            expr: Box::new(expr),
20✔
244
            target_type: super::type_name_to_sql_type("text"),
20✔
245
        },
20✔
246
        114 | 3802 => Expr::BinaryOp {
18✔
247
            // json/b as stringified using #>> '{}' (empty path extracts root as text)
18✔
248
            // Use string literal '{}' which PostgreSQL interprets as text[] for the path
18✔
249
            left: Box::new(expr),
18✔
250
            op: BinaryOperator::JsonPathText,
18✔
251
            right: Box::new(string_literal("{}")),
18✔
252
        },
18✔
253
        1700 => Expr::Cast {
7✔
254
            // numeric as text
7✔
255
            expr: Box::new(expr),
7✔
256
            target_type: super::type_name_to_sql_type("text"),
7✔
257
        },
7✔
258
        1016 => Expr::Cast {
1✔
259
            // bigint arrays as array of text
1✔
260
            expr: Box::new(expr),
1✔
261
            target_type: super::type_name_to_sql_type("text[]"),
1✔
262
        },
1✔
263
        199 | 3807 => Expr::Cast {
2✔
264
            // json/b array as array of text
2✔
265
            expr: Box::new(expr),
2✔
266
            target_type: super::type_name_to_sql_type("text[]"),
2✔
267
        },
2✔
268
        1231 => Expr::Cast {
1✔
269
            // numeric array as array of text
1✔
270
            expr: Box::new(expr),
1✔
271
            target_type: super::type_name_to_sql_type("text[]"),
1✔
272
        },
1✔
273
        _ => expr,
945✔
274
    }
275
}
994✔
276

277
/// Build expression for nodeId (base64 encoded JSON array)
278
fn build_node_id_expr(node_id_builder: &NodeIdBuilder, block_name: &str) -> Expr {
17✔
279
    // Build: translate(encode(convert_to(jsonb_build_array(schema, table, pk_vals...)::text, 'utf-8'), 'base64'), E'\n', '')
280
    let pk_exprs: Vec<Expr> = node_id_builder
17✔
281
        .columns
17✔
282
        .iter()
17✔
283
        .map(|c| func_call("to_jsonb", vec![column_ref(block_name, &c.name)]))
20✔
284
        .collect();
17✔
285

286
    let mut array_args = vec![
17✔
287
        string_literal(&node_id_builder.schema_name),
17✔
288
        string_literal(&node_id_builder.table_name),
17✔
289
    ];
290
    array_args.extend(pk_exprs);
17✔
291

292
    let jsonb_array = func_call("jsonb_build_array", array_args);
17✔
293
    let as_text = Expr::Cast {
17✔
294
        expr: Box::new(jsonb_array),
17✔
295
        target_type: super::type_name_to_sql_type("text"),
17✔
296
    };
17✔
297
    let converted = func_call("convert_to", vec![as_text, string_literal("utf-8")]);
17✔
298
    let encoded = func_call("encode", vec![converted, string_literal("base64")]);
17✔
299

300
    func_call(
17✔
301
        "translate",
17✔
302
        vec![encoded, string_literal("\n"), string_literal("")],
17✔
303
    )
304
}
17✔
305

306
/// Build expression for a function call
307
fn build_function_expr(
13✔
308
    func_builder: &crate::builder::FunctionBuilder,
13✔
309
    block_name: &str,
13✔
310
    params: &mut ParamCollector,
13✔
311
) -> GraphQLResult<Expr> {
13✔
312
    // The function takes the row as a typed argument: schema.function(block_name::schema.table)
313
    // Build the row argument with type cast
314
    let row_arg = Expr::Cast {
13✔
315
        expr: Box::new(Expr::Column(super::ColumnRef::new(block_name))),
13✔
316
        target_type: super::SqlType::with_schema(
13✔
317
            func_builder.table.schema.clone(),
13✔
318
            func_builder.table.name.clone(),
13✔
319
        ),
13✔
320
    };
13✔
321

322
    let args = vec![FunctionArg::unnamed(row_arg)];
13✔
323

324
    let func_call = super::FunctionCall::with_schema(
13✔
325
        func_builder.function.schema_name.clone(),
13✔
326
        func_builder.function.name.clone(),
13✔
327
        args,
13✔
328
    );
329

330
    match &func_builder.selection {
13✔
331
        FunctionSelection::ScalarSelf | FunctionSelection::Array => {
332
            // For scalar/array selections, the result is the function call with type cast
333
            let func_expr = Expr::FunctionCall(func_call);
9✔
334
            Ok(apply_type_cast(func_expr, func_builder.function.type_oid))
9✔
335
        }
336
        FunctionSelection::Node(node_builder) => {
3✔
337
            // For node selection (function returning a single row), wrap in a subquery:
338
            // (SELECT node_object FROM schema.func(block_name::schema.table) AS func_block WHERE NOT (func_block IS NULL))
339
            let func_block_name = AstBuildContext::new().block_name;
3✔
340

341
            // Build the node object expression
342
            let object_expr =
3✔
343
                build_node_object_expr(&node_builder.selections, &func_block_name, params)?;
3✔
344

345
            // Build: NOT (func_block IS NULL)
346
            let not_null_check = Expr::UnaryOp {
3✔
347
                op: super::UnaryOperator::Not,
3✔
348
                expr: Box::new(Expr::IsNull {
3✔
349
                    expr: Box::new(Expr::Column(super::ColumnRef::new(func_block_name.as_str()))),
3✔
350
                    negated: false,
3✔
351
                }),
3✔
352
            };
3✔
353

354
            // Build the subquery
355
            let subquery = SelectStmt {
3✔
356
                ctes: vec![],
3✔
357
                columns: vec![SelectColumn::expr(object_expr)],
3✔
358
                from: Some(FromClause::Function {
3✔
359
                    call: func_call,
3✔
360
                    alias: Ident::new(&func_block_name),
3✔
361
                }),
3✔
362
                where_clause: Some(not_null_check),
3✔
363
                group_by: vec![],
3✔
364
                having: None,
3✔
365
                order_by: vec![],
3✔
366
                limit: None,
3✔
367
                offset: None,
3✔
368
            };
3✔
369

370
            Ok(Expr::Subquery(Box::new(subquery)))
3✔
371
        }
372
        FunctionSelection::Connection(conn_builder) => {
1✔
373
            // For connection selection (function returning setof), build a connection subquery
374
            // that uses the function as its FROM clause instead of a table
375
            build_function_connection_subquery(func_builder, conn_builder, block_name, params)
1✔
376
        }
377
    }
378
}
13✔
379

380
/// Build a connection subquery for a function that returns setof <type>
381
///
382
/// This is used when a function returns a set of rows (setof) and we want to
383
/// expose that as a connection. The key difference from a regular connection
384
/// is that the FROM clause uses the function call instead of a table.
385
fn build_function_connection_subquery(
1✔
386
    func_builder: &crate::builder::FunctionBuilder,
1✔
387
    conn_builder: &crate::builder::ConnectionBuilder,
1✔
388
    parent_block_name: &str,
1✔
389
    params: &mut ParamCollector,
1✔
390
) -> GraphQLResult<Expr> {
1✔
391
    // Build the function call argument: parent_block_name::schema.table
392
    let row_arg = Expr::Cast {
1✔
393
        expr: Box::new(Expr::Column(super::ColumnRef::new(parent_block_name))),
1✔
394
        target_type: super::SqlType::with_schema(
1✔
395
            func_builder.table.schema.clone(),
1✔
396
            func_builder.table.name.clone(),
1✔
397
        ),
1✔
398
    };
1✔
399

400
    let func_call = super::FunctionCall::with_schema(
1✔
401
        func_builder.function.schema_name.clone(),
1✔
402
        func_builder.function.name.clone(),
1✔
403
        vec![FunctionArg::unnamed(row_arg)],
1✔
404
    );
405

406
    // Build the connection subquery using the function as the FROM source
407
    super::build_function_connection_subquery_full(conn_builder, func_call, params)
1✔
408
}
1✔
409

410
/// Build a subquery expression for a connection selection
411
fn build_connection_subquery_expr(
18✔
412
    conn_builder: &crate::builder::ConnectionBuilder,
18✔
413
    parent_block_name: &str,
18✔
414
    params: &mut ParamCollector,
18✔
415
) -> GraphQLResult<Expr> {
18✔
416
    // Use the full connection subquery implementation from transpile_connection
417
    super::build_connection_subquery(conn_builder, parent_block_name, params)
18✔
418
}
18✔
419

420
/// Build a subquery expression for a nested node relation
421
pub fn build_relation_subquery_expr(
32✔
422
    nested_node: &NodeBuilder,
32✔
423
    parent_block_name: &str,
32✔
424
    params: &mut ParamCollector,
32✔
425
) -> GraphQLResult<Expr> {
32✔
426
    let ctx = AstBuildContext::new();
32✔
427
    let block_name = ctx.block_name.clone();
32✔
428

429
    // Build the object clause from nested node's selections
430
    let object_expr = build_node_object_expr(&nested_node.selections, &block_name, params)?;
32✔
431

432
    // Get the foreign key and direction
433
    let fkey = nested_node
32✔
434
        .fkey
32✔
435
        .as_ref()
32✔
436
        .ok_or("Internal Error: relation key")?;
32✔
437
    let reverse_reference = nested_node
32✔
438
        .reverse_reference
32✔
439
        .ok_or("Internal Error: relation reverse reference")?;
32✔
440

441
    // Build the join condition
442
    let join_condition = build_join_condition(
32✔
443
        fkey,
32✔
444
        reverse_reference,
32✔
445
        &block_name,
32✔
446
        parent_block_name,
32✔
447
        &nested_node.table,
32✔
NEW
448
    )?;
×
449

450
    // Build the subquery
451
    let subquery = SelectStmt {
32✔
452
        ctes: vec![],
32✔
453
        columns: vec![SelectColumn::expr(object_expr)],
32✔
454
        from: Some(FromClause::Table {
32✔
455
            schema: Some(Ident::new(nested_node.table.schema.clone())),
32✔
456
            name: Ident::new(nested_node.table.name.clone()),
32✔
457
            alias: Some(Ident::new(block_name)),
32✔
458
        }),
32✔
459
        where_clause: Some(join_condition),
32✔
460
        group_by: vec![],
32✔
461
        having: None,
32✔
462
        order_by: vec![],
32✔
463
        limit: None,
32✔
464
        offset: None,
32✔
465
    };
32✔
466

467
    Ok(Expr::Subquery(Box::new(subquery)))
32✔
468
}
32✔
469

470
/// Build a join condition for a foreign key relationship
471
fn build_join_condition(
32✔
472
    fkey: &crate::sql_types::ForeignKey,
32✔
473
    reverse_reference: bool,
32✔
474
    child_block_name: &str,
32✔
475
    parent_block_name: &str,
32✔
476
    _table: &Table,
32✔
477
) -> GraphQLResult<Expr> {
32✔
478
    let mut conditions = Vec::new();
32✔
479

480
    // ForeignKey has local_table_meta and referenced_table_meta, each with column_names
481
    // Depending on direction, pair up the columns
482
    let pairs: Vec<(&String, &String)> = if reverse_reference {
32✔
483
        // Parent has the referenced columns, child has the local columns
484
        fkey.local_table_meta
2✔
485
            .column_names
2✔
486
            .iter()
2✔
487
            .zip(fkey.referenced_table_meta.column_names.iter())
2✔
488
            .collect()
2✔
489
    } else {
490
        // Parent has the local columns, child has the referenced columns
491
        fkey.referenced_table_meta
30✔
492
            .column_names
30✔
493
            .iter()
30✔
494
            .zip(fkey.local_table_meta.column_names.iter())
30✔
495
            .collect()
30✔
496
    };
497

498
    for (child_col, parent_col) in pairs {
64✔
499
        let child_expr = column_ref(child_block_name, child_col);
32✔
500
        let parent_expr = column_ref(parent_block_name, parent_col);
32✔
501

32✔
502
        conditions.push(Expr::BinaryOp {
32✔
503
            left: Box::new(child_expr),
32✔
504
            op: BinaryOperator::Eq,
32✔
505
            right: Box::new(parent_expr),
32✔
506
        });
32✔
507
    }
32✔
508

509
    // Combine with AND
510
    if conditions.len() == 1 {
32✔
511
        Ok(conditions.remove(0))
32✔
512
    } else {
NEW
513
        let mut combined = conditions.remove(0);
×
NEW
514
        for cond in conditions {
×
NEW
515
            combined = Expr::BinaryOp {
×
NEW
516
                left: Box::new(combined),
×
NEW
517
                op: BinaryOperator::And,
×
NEW
518
                right: Box::new(cond),
×
NEW
519
            };
×
NEW
520
        }
×
NEW
521
        Ok(combined)
×
522
    }
523
}
32✔
524

525
#[cfg(test)]
526
mod tests {
527
    use super::*;
528
    use crate::ast::ColumnRef;
529

530
    #[test]
531
    fn test_type_cast_bigint() {
532
        let expr = Expr::Column(ColumnRef::new("test"));
533
        let casted = apply_type_cast(expr, 20);
534
        match casted {
535
            Expr::Cast { target_type, .. } => {
536
                assert_eq!(target_type.name, "text");
537
            }
538
            _ => panic!("Expected Cast expression"),
539
        }
540
    }
541

542
    #[test]
543
    fn test_type_cast_no_change() {
544
        let expr = Expr::Column(ColumnRef::new("test"));
545
        let result = apply_type_cast(expr.clone(), 25); // text OID, no cast needed
546
        match result {
547
            Expr::Column(_) => {}
548
            _ => panic!("Expected no cast for text type"),
549
        }
550
    }
551
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc