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

torand / FasterSQL / 15071216480

16 May 2025 02:49PM UTC coverage: 69.877% (+2.4%) from 67.475%
15071216480

push

github

web-flow
Merge pull request #30 from torand/access-support

Access support

229 of 414 branches covered (55.31%)

Branch coverage included in aggregate %.

105 of 152 new or added lines in 26 files covered. (69.08%)

3 existing lines in 3 files now uncovered.

1193 of 1621 relevant lines covered (73.6%)

3.92 hits per line

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

77.66
/src/main/java/io/github/torand/fastersql/statement/SelectStatement.java
1
/*
2
 * Copyright (c) 2024-2025 Tore Eide Andersen
3
 *
4
 * Licensed under the Apache License, Version 2.0 (the "License");
5
 * you may not use this file except in compliance with the License.
6
 * You may obtain a copy of the License at
7
 *
8
 *      http://www.apache.org/licenses/LICENSE-2.0
9
 *
10
 * Unless required by applicable law or agreed to in writing, software
11
 * distributed under the License is distributed on an "AS IS" BASIS,
12
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
 * See the License for the specific language governing permissions and
14
 * limitations under the License.
15
 */
16
package io.github.torand.fastersql.statement;
17

18
import io.github.torand.fastersql.Column;
19
import io.github.torand.fastersql.Context;
20
import io.github.torand.fastersql.Join;
21
import io.github.torand.fastersql.Table;
22
import io.github.torand.fastersql.alias.Alias;
23
import io.github.torand.fastersql.alias.ColumnAlias;
24
import io.github.torand.fastersql.dialect.AnsiIsoDialect;
25
import io.github.torand.fastersql.expression.Expression;
26
import io.github.torand.fastersql.function.aggregate.AggregateFunction;
27
import io.github.torand.fastersql.order.Order;
28
import io.github.torand.fastersql.predicate.OptionalPredicate;
29
import io.github.torand.fastersql.predicate.Predicate;
30
import io.github.torand.fastersql.projection.Projection;
31
import io.github.torand.fastersql.relation.Relation;
32
import io.github.torand.fastersql.subquery.Subquery;
33

34
import java.util.HashSet;
35
import java.util.LinkedList;
36
import java.util.List;
37
import java.util.Optional;
38
import java.util.Set;
39
import java.util.function.Function;
40
import java.util.function.Supplier;
41
import java.util.stream.Stream;
42

43
import static io.github.torand.fastersql.Command.SELECT;
44
import static io.github.torand.fastersql.dialect.Capability.LIMIT_OFFSET;
45
import static io.github.torand.fastersql.dialect.Capability.SELECT_FOR_UPDATE;
46
import static io.github.torand.fastersql.statement.Helpers.unwrapSuppliers;
47
import static io.github.torand.fastersql.util.collection.CollectionHelper.asList;
48
import static io.github.torand.fastersql.util.collection.CollectionHelper.concat;
49
import static io.github.torand.fastersql.util.collection.CollectionHelper.isEmpty;
50
import static io.github.torand.fastersql.util.collection.CollectionHelper.nonEmpty;
51
import static io.github.torand.fastersql.util.collection.CollectionHelper.streamSafely;
52
import static io.github.torand.fastersql.util.contract.Requires.require;
53
import static io.github.torand.fastersql.util.contract.Requires.requireNonEmpty;
54
import static io.github.torand.fastersql.util.functional.Functions.castTo;
55
import static io.github.torand.fastersql.util.functional.Optionals.mapIfNonNull;
56
import static io.github.torand.fastersql.util.functional.Predicates.instanceOf;
57
import static java.util.Objects.nonNull;
58
import static java.util.Objects.requireNonNull;
59
import static java.util.function.Predicate.not;
60
import static java.util.stream.Collectors.joining;
61
import static java.util.stream.Collectors.toSet;
62

63
/**
64
 * Implements a SELECT statement.
65
 */
66
public class SelectStatement implements PreparableStatement {
67
    private final List<Projection> projections;
68
    private final List<Relation> relations;
69
    private final List<Join> joins;
70
    private final List<Predicate> wherePredicates;
71
    private final List<Column> groups;
72
    private final List<Predicate> havingPredicates;
73
    private final List<Order> orders;
74
    private final boolean distinct;
75
    private final Long limit;
76
    private final Long offset;
77
    private final boolean forUpdate;
78

79
    SelectStatement(List<Projection> projections, List<Relation> relations, List<Join> joins, List<Predicate> wherePredicates, List<Column> groups, List<Predicate> havingPredicates, List<Order> orders, boolean distinct, Long limit, Long offset, boolean forUpdate) {
2✔
80
        this.projections = asList(projections);
4✔
81
        this.relations = asList(relations);
4✔
82
        this.joins = asList(joins);
4✔
83
        this.wherePredicates = asList(wherePredicates);
4✔
84
        this.groups = asList(groups);
4✔
85
        this.havingPredicates = asList(havingPredicates);
4✔
86
        this.orders = asList(orders);
4✔
87
        this.distinct = distinct;
3✔
88
        this.limit = limit;
3✔
89
        this.offset = offset;
3✔
90
        this.forUpdate = forUpdate;
3✔
91
    }
1✔
92

93
    /**
94
     * Adds one or more JOIN clauses.
95
     * @param joins the JOIN clauses.
96
     * @return the modified statement.
97
     */
98
    public SelectStatement join(Join... joins) {
99
        requireNonEmpty(joins, "No joins specified");
6✔
100
        require(() -> streamSafely(relations).noneMatch(instanceOf(Subquery.class)), "Can't combine a subquery FROM clause with joins");
13✔
101

102
        List<Join> concatenated = concat(this.joins, joins);
5✔
103
        return new SelectStatement(projections, relations, concatenated, wherePredicates, groups, havingPredicates, orders, distinct, limit, offset, forUpdate);
25✔
104
    }
105

106
    /**
107
     * Adds a LEFT OUTER JOIN clause.
108
     * @param join the JOIN clause.
109
     * @return the modified statement.
110
     */
111
    public SelectStatement leftOuterJoin(Join join) {
112
        requireNonNull(join, "No join specified");
×
113
        return join(join.leftOuter());
×
114
    }
115

116
    /**
117
     * Adds a RIGHT OUTER JOIN clause.
118
     * @param join the JOIN clause.
119
     * @return the modified statement.
120
     */
121
    public SelectStatement rightOuterJoin(Join join) {
122
        requireNonNull(join, "No join specified");
×
123
        return join(join.rightOuter());
×
124
    }
125

126
    /**
127
     * Adds one or more JOIN clauses, if the condition is true.
128
     * @param condition the condition.
129
     * @param joinSuppliers the suppliers of JOIN clauses.
130
     * @return the modified statement.
131
     */
132
    @SafeVarargs
133
    public final SelectStatement joinIf(boolean condition, Supplier<Join>... joinSuppliers) {
134
        requireNonEmpty(joinSuppliers, "No join suppliers specified");
×
135
        require(() -> streamSafely(relations).noneMatch(instanceOf(Subquery.class)), "Can't combine a subquery FROM clause with joins");
×
136
        if (condition) {
×
137
            List<Join> concatenated = concat(this.joins, unwrapSuppliers(joinSuppliers));
×
138
            return new SelectStatement(projections, relations, concatenated, wherePredicates, groups, havingPredicates, orders, distinct, limit, offset, forUpdate);
×
139
        } else {
140
            return this;
×
141
        }
142
    }
143

144
    /**
145
     * Adds one or more predicates to the WHERE clause.
146
     * @param predicates the predicates.
147
     * @return the modified statement.
148
     */
149
    public SelectStatement where(Predicate... predicates) {
150
        requireNonEmpty(predicates, "No WHERE predicates specified");
6✔
151

152
        List<Predicate> concatenated = concat(this.wherePredicates, predicates);
5✔
153
        return new SelectStatement(projections, relations, joins, concatenated, groups, havingPredicates, orders, distinct, limit, offset, forUpdate);
25✔
154
    }
155

156
    /**
157
     * Adds optional predicates to the WHERE clause if the wrapped predicates are present.
158
     * @param maybePredicates the optional predicates.
159
     * @return the modified statement.
160
     */
161
    @SafeVarargs
162
    public final SelectStatement where(OptionalPredicate... maybePredicates) {
163
        requireNonEmpty(maybePredicates, "No optional WHERE predicates specified");
6✔
164

165
        List<Predicate> concatenated = concat(this.wherePredicates, OptionalPredicate.unwrap(maybePredicates));
6✔
166
        return new SelectStatement(projections, relations, joins, concatenated, groups, havingPredicates, orders, distinct, limit, offset, forUpdate);
25✔
167
    }
168

169
    /**
170
     * Adds supplied predicates to the WHERE clause, if the condition is true.
171
     * @param condition the condition.
172
     * @param predicateSuppliers the suppliers providing predicates
173
     * @return the modified statement.
174
     */
175
    @SafeVarargs
176
    public final SelectStatement whereIf(boolean condition, Supplier<Predicate>... predicateSuppliers) {
177
        requireNonEmpty(predicateSuppliers, "No WHERE predicate suppliers specified");
×
178
        if (condition) {
×
179
            List<Predicate> concatenated = concat(this.wherePredicates, unwrapSuppliers(predicateSuppliers));
×
180
            return new SelectStatement(projections, relations, joins, concatenated, groups, havingPredicates, orders, distinct, limit, offset, forUpdate);
×
181
        } else {
182
            return this;
×
183
        }
184
    }
185

186
    /**
187
     * Adds one or columns as groups to the GROUP BY clause.
188
     * @param groups the groups
189
     * @return the modified statement.
190
     */
191
    public SelectStatement groupBy(Column... groups) {
192
        requireNonEmpty(groups, "No groups specified");
6✔
193

194
        List<Column> concatenated = concat(this.groups, groups);
5✔
195
        return new SelectStatement(projections, relations, joins, wherePredicates, concatenated, havingPredicates, orders, distinct, limit, offset, forUpdate);
25✔
196
    }
197

198
    /**
199
     * Adds one or more predicates to the HAVING clause.
200
     * @param predicates the predicates.
201
     * @return the modified statement.
202
     */
203
    public SelectStatement having(Predicate... predicates) {
204
        requireNonEmpty(predicates, "No HAVING predicates specified");
6✔
205

206
        List<Predicate> concatenated = concat(this.havingPredicates, predicates);
5✔
207
        return new SelectStatement(projections, relations, joins, wherePredicates, groups, concatenated, orders, distinct, limit, offset, forUpdate);
25✔
208
    }
209

210
    /**
211
     * Adds optional predicates to the HAVING clause if the wrapped predicates are present.
212
     * @param maybePredicates the optional predicates.
213
     * @return the modified statement.
214
     */
215
    @SafeVarargs
216
    public final SelectStatement having(OptionalPredicate... maybePredicates) {
217
        requireNonEmpty(maybePredicates, "No optional HAVING predicates specified");
×
218

219
        List<Predicate> concatenated = concat(this.havingPredicates, OptionalPredicate.unwrap(maybePredicates));
×
220
        return new SelectStatement(projections, relations, joins, wherePredicates, groups, concatenated, orders, distinct, limit, offset, forUpdate);
×
221
    }
222

223
    /**
224
     * Adds one or more predicates to the HAVING clause, if the condition is true.
225
     * @param condition the condition.
226
     * @param predicateSuppliers the suppliers providing predicates.
227
     * @return the modified statement.
228
     */
229
    @SafeVarargs
230
    public final SelectStatement havingIf(boolean condition, Supplier<Predicate>... predicateSuppliers) {
231
        requireNonEmpty(predicateSuppliers, "No HAVING predicate suppliers specified");
×
232
        if (condition) {
×
233
            List<Predicate> concatenated = concat(this.havingPredicates, unwrapSuppliers(predicateSuppliers));
×
234
            return new SelectStatement(projections, relations, joins, wherePredicates, groups, concatenated, orders, distinct, limit, offset, forUpdate);
×
235
        } else {
236
            return this;
×
237
        }
238
    }
239

240
    /**
241
     * Adds one or more ORDER clauses.
242
     * @param orders the ORDER clauses.
243
     * @return the modified statement.
244
     */
245
    public SelectStatement orderBy(Order... orders) {
246
        requireNonEmpty(orders, "No orders specified");
6✔
247

248
        List<Order> concatenated = concat(this.orders, orders);
5✔
249
        return new SelectStatement(projections, relations, joins, wherePredicates, groups, havingPredicates, concatenated, distinct, limit, offset, forUpdate);
25✔
250
    }
251

252
    /**
253
     * Adds a LIMIT clause.
254
     * @param limit the limit.
255
     * @return the modified statement.
256
     */
257
    public SelectStatement limit(long limit) {
258
        return new SelectStatement(projections, relations, joins, wherePredicates, groups, havingPredicates, orders, distinct, limit, offset, forUpdate);
26✔
259
    }
260

261
    /**
262
     * Adds a OFFSET clause.
263
     * @param offset the offset.
264
     * @return the modified statement.
265
     */
266
    public SelectStatement offset(long offset) {
267
        return new SelectStatement(projections, relations, joins, wherePredicates, groups, havingPredicates, orders, distinct, limit, offset, forUpdate);
26✔
268
    }
269

270
    /**
271
     * Adds a FOR UPDATE clause.
272
     * @return the modified statement.
273
     */
274
    public SelectStatement forUpdate() {
275
        return new SelectStatement(projections, relations, joins, wherePredicates, groups, havingPredicates, orders, distinct, limit, offset, true);
25✔
276
    }
277

278
    @Override
279
    public String sql(Context context) {
280
        final Context localContext = context
2✔
281
            .withCommand(SELECT)
2✔
282
            .withOuterStatement(this);
2✔
283

284
        validate(context);
3✔
285

286
        StringBuilder sb = new StringBuilder();
4✔
287
        sb.append("select ");
4✔
288
        if (distinct) {
3✔
289
            sb.append("distinct ");
4✔
290
        }
291

292
        sb.append(streamSafely(projections)
8✔
293
            .map(p -> p.sql(localContext) + (p.alias().isEmpty() ? "" : " " + p.alias().map(a -> a.sql(localContext)).get()))
25✔
294
            .collect(joining(", ")));
3✔
295

296
        sb.append(" from ");
4✔
297

298
        // Tables that are joined with should not be specified in the FROM clause
299
        Set<Table<?>> joinedTables = streamSafely(joins).map(Join::joined).collect(toSet());
9✔
300

301
        sb.append(streamSafely(relations)
8✔
302
            .filter(not(joinedTables::contains))
7✔
303
            .map(t -> t.sql(localContext))
6✔
304
            .collect(joining(", ")));
3✔
305

306
        if (nonEmpty(joins)) {
4✔
307
            sb.append(" ");
4✔
308
            sb.append(streamSafely(joins)
8✔
309
                .map(j -> j.sql(localContext))
6✔
310
                .collect(joining(" ")));
3✔
311
        }
312

313
        if (nonEmpty(wherePredicates)) {
4✔
314
            sb.append(" where ");
4✔
315
            sb.append(streamSafely(wherePredicates)
8✔
316
                .map(p -> p.sql(localContext))
6✔
317
                .collect(joining(" and ")));
3✔
318
        }
319

320
        if (nonEmpty(groups)) {
4✔
321
            sb.append(" group by ");
4✔
322
            sb.append(streamSafely(groups)
8✔
323
                .map(g -> g.sql(localContext))
6✔
324
                .collect(joining(", ")));
3✔
325
        }
326

327
        if (nonEmpty(havingPredicates)) {
4✔
328
            sb.append(" having ");
4✔
329
            sb.append(streamSafely(havingPredicates)
8✔
330
                .map(p -> p.sql(localContext))
6✔
331
                .collect(joining(" and ")));
3✔
332
        }
333

334
        if (nonEmpty(orders)) {
4✔
335
            sb.append(" order by ");
4✔
336
            sb.append(streamSafely(orders)
8✔
337
                .map(o -> o.sql(localContext))
6✔
338
                .collect(joining(", ")));
3✔
339
        }
340

341
        if (nonNull(offset) || nonNull(limit)) {
8!
342
            if (context.getDialect().supports(LIMIT_OFFSET)) {
5✔
343
                String offsetClause = nonNull(offset) ? " " + localContext.getDialect().formatRowOffsetClause().orElseThrow(() -> new RuntimeException("Dialect " + localContext.getDialect().getProductName() + " has no row offset clause")) : "";
14!
344
                String limitClause = nonNull(limit) ? " " + localContext.getDialect().formatRowLimitClause().orElseThrow(() -> new RuntimeException("Dialect " + localContext.getDialect().getProductName() + " has no row limit clause")) : "";
14!
345

346
                if (localContext.getDialect().offsetBeforeLimit()) {
4✔
347
                    sb.append(offsetClause).append(limitClause);
7✔
348
                } else {
349
                    sb.append(limitClause).append(offsetClause);
6✔
350
                }
351
            } else {
1✔
352
                sb = addLimitOffsetFallback(localContext, sb, rowFrom(), rowTo());
9✔
353
            }
354
        }
355

356
        if (forUpdate) {
3✔
357
            sb.append(" for update");
4✔
358
        }
359

360
        return sb.toString();
3✔
361
    }
362

363
    private Long rowFrom() {
364
        return mapIfNonNull(offset, o -> o + 1);
12✔
365
    }
366

367
    private Long rowTo() {
368
        return mapIfNonNull(limit, l -> (nonNull(offset) ? offset : 0) + l);
20!
369
    }
370

371
    private StringBuilder addLimitOffsetFallback(Context context, StringBuilder innerSql, Long rowFrom, Long rowTo) {
372
        String rowNum = context.getDialect().formatRowNumLiteral()
5✔
373
            .orElseThrow(() -> new RuntimeException("Dialect " + context.getDialect().getProductName() + " has no row number literal"));
3✔
374

375
        if (nonNull(rowFrom) && nonNull(rowTo)) {
6!
376
            String limitSql = "select ORIGINAL.*, {ROWNUM} ROW_NO from ( " + innerSql.toString() + " ) ORIGINAL where {ROWNUM} <= ?";
4✔
377
            String offsetSql = "select * from ( " + limitSql + " ) where ROW_NO >= ?";
3✔
378
            return new StringBuilder(offsetSql.replace("{ROWNUM}", rowNum));
8✔
379
        } else if (nonNull(rowFrom)) {
×
380
            String offsetSql = "select * from ( " + innerSql.toString() + " ) where {ROWNUM} >= ?";
×
381
            return new StringBuilder(offsetSql.replace("{ROWNUM}", rowNum));
×
382
        } else if (nonNull(rowTo)) {
×
383
            String limitSql = "select * from ( " + innerSql.toString() + " ) where {ROWNUM} <= ?";
×
384
            return new StringBuilder(limitSql.replace("{ROWNUM}", rowNum));
×
385
        }
386

387
        return innerSql;
×
388
    }
389

390
    @Override
391
    public Stream<Object> params(Context context) {
392
        List<Object> params = new LinkedList<>();
4✔
393

394
        streamSafely(projections).flatMap(p -> p.params(context)).forEach(params::add);
16✔
395

396
        streamSafely(relations).flatMap(r -> r.params(context)).forEach(params::add);
16✔
397

398
        streamSafely(wherePredicates).flatMap(p -> p.params(context)).forEach(params::add);
16✔
399

400
        streamSafely(havingPredicates).flatMap(p -> p.params(context)).forEach(params::add);
16✔
401

402
        if (context.getDialect().supports(LIMIT_OFFSET)) {
5✔
403
            if (context.getDialect().offsetBeforeLimit()) {
4✔
404
                if (nonNull(offset)) {
4✔
405
                    params.add(offset);
5✔
406
                }
407
                if (nonNull(limit)) {
4✔
408
                    params.add(limit);
6✔
409
                }
410
            } else {
411
                if (nonNull(limit)) {
4✔
412
                    params.add(limit);
5✔
413
                }
414
                if (nonNull(offset)) {
4✔
415
                    params.add(offset);
6✔
416
                }
417
            }
418
        } else {
419
            if (nonNull(limit)) {
4!
420
                params.add(rowTo());
5✔
421
            }
422
            if (nonNull(offset)) {
4!
423
                params.add(rowFrom());
5✔
424
            }
425
        }
426

427
        return params.stream();
3✔
428
    }
429

430
    private void validate(Context context) {
431
        if (isEmpty(relations)) {
4!
432
            throw new IllegalStateException("No FROM clause specified");
×
433
        }
434

435
        Stream<Column> projectedColumns = streamSafely(projections)
4✔
436
            .filter(instanceOf(Expression.class))
3✔
437
            .map(castTo(Expression.class))
3✔
438
            .flatMap(Expression::columnRefs);
2✔
439
        validateColumnTableRelations(context, projectedColumns);
4✔
440

441
        if (nonNull(joins)) {
4!
442
            validateColumnTableRelations(context, streamSafely(joins).flatMap(Join::columnRefs));
8✔
443
        }
444

445
        if (nonNull(orders)) {
4!
446
            Set<String> orderableAliases = streamSafely(projections)
4✔
447
                .map(Projection::alias)
2✔
448
                .flatMap(Optional::stream)
2✔
449
                .map(Alias::name)
1✔
450
                .collect(toSet());
4✔
451

452
            streamSafely(orders)
4✔
453
                .flatMap(o -> o.aliasRefs())
5✔
454
                .map(ColumnAlias::name)
3✔
455
                .filter(a -> !orderableAliases.contains(a))
7!
456
                .findFirst()
2✔
457
                .ifPresent(a -> {
1✔
458
                    throw new IllegalStateException("ORDER BY column alias " + a + " is not specified in the SELECT clause");
×
459
                });
460
        }
461

462
        validateColumnTableRelations(context, streamSafely(wherePredicates).flatMap(Predicate::columnRefs));
8✔
463
        validateColumnTableRelations(context, streamSafely(groups));
6✔
464
        validateColumnTableRelations(context, streamSafely(havingPredicates).flatMap(Predicate::columnRefs));
8✔
465
        validateColumnTableRelations(context, streamSafely(orders).flatMap(Order::columnRefs));
8✔
466

467
        if (forUpdate) {
3✔
468
            if (!context.getDialect().supports(SELECT_FOR_UPDATE)) {
5!
469
                throw new UnsupportedOperationException("%s does not support the SELECT ... FOR UPDATE clause".formatted(context.getDialect().getProductName()));
×
470
            }
471

472
            if (distinct || nonEmpty(groups) || streamSafely(projections).anyMatch(instanceOf(AggregateFunction.class))) {
14!
473
                throw new IllegalStateException("SELECT ... FOR UPDATE can't be used with DISTINCT, GROUP BY or aggregates");
×
474
            }
475

476
            List<String> projectedTables = streamSafely(projections)
4✔
477
                .filter(instanceOf(Expression.class))
3✔
478
                .map(castTo(Expression.class))
3✔
479
                .flatMap(Expression::columnRefs)
2✔
480
                .map(cr -> cr.table().name())
5✔
481
                .distinct()
1✔
482
                .toList();
2✔
483

484
            if (projectedTables.size() != 1) {
4!
485
                throw new IllegalStateException("SELECT ... FOR UPDATE can be used for a single projected table only. Projected tables in this statement are: %s".formatted(projectedTables));
×
486
            }
487
        }
488
    }
1✔
489

490
    private void validateColumnTableRelations(Context context, Stream<Column> columns) {
491
        Function<Relation, Stream<Table>> filterTables = r -> r instanceof Table ? Stream.of((Table)r) : Stream.empty();
11✔
492

493
        Set<String> outerTableNames = new HashSet<>();
4✔
494
        if (nonEmpty(context.getOuterStatements())) {
4✔
495
            context.getOuterStatements().forEach(os -> streamSafely(os.relations).flatMap(filterTables).map(Table::name).forEach(outerTableNames::add));
20✔
496
            context.getOuterStatements().forEach(os -> streamSafely(os.joins).map(Join::joined).map(Table::name).forEach(outerTableNames::add));
19✔
497
        }
498

499
        Set<String> tableNames = Stream.concat(streamSafely(relations).flatMap(filterTables), streamSafely(joins).map(Join::joined))
12✔
500
            .map(Table::name)
1✔
501
            .collect(toSet());
4✔
502

503
        columns
4✔
504
            .filter(c -> !outerTableNames.contains(c.table().name()) && !tableNames.contains(c.table().name()))
15!
505
            .findFirst()
2✔
506
            .ifPresent(c -> {
1✔
507
                throw new IllegalStateException("Column " + c.name() + " belongs to table " + c.table().name() + ", but is not specified in a FROM or JOIN clause");
×
508
            });
509
    }
1✔
510

511
    @Override
512
    public String toString() {
NEW
513
        return toString(new AnsiIsoDialect());
×
514
    }
515
}
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