diff --git a/src/AlternateAllele.php b/src/AlternateAllele.php new file mode 100644 index 0000000..61da4a8 --- /dev/null +++ b/src/AlternateAllele.php @@ -0,0 +1,27 @@ +validatedSequence = self::MISSING; + + return; + } + + parent::__construct($sequence); + } + + public function isMissing(): bool + { + return $this->validatedSequence === self::MISSING; + } +} diff --git a/src/DnaSequence.php b/src/DnaSequence.php index 2d1e063..968f184 100644 --- a/src/DnaSequence.php +++ b/src/DnaSequence.php @@ -9,7 +9,7 @@ class DnaSequence public function __construct(string $sequence) { $sequence = trim(strtoupper($sequence)); - if (\Safe\preg_match('/^[ATGC]*$/', $sequence) === 0) { + if (\Safe\preg_match('/^[ATGCN]*$/', $sequence) === 0) { throw new \InvalidArgumentException('Invalid DNA sequence'); } $this->validatedSequence = $sequence; diff --git a/src/VariantIdentifier.php b/src/VariantIdentifier.php index 5136b49..48f8503 100644 --- a/src/VariantIdentifier.php +++ b/src/VariantIdentifier.php @@ -49,7 +49,7 @@ public function toString(VariantIdentifierFormat $format, NamingConvention $nami private static function parseCanonical(string $value): self { - if (preg_match('/^(.+)-(\d+)-([ATGC]+)\/([ATGC]+)$/i', $value, $matches) === 0) { + if (preg_match('/^(.+)-(\d+)-([ATGCN]+)\/([ATGCN]+|\.)$/i', $value, $matches) === 0) { throw new \InvalidArgumentException("Invalid canonical variant identifier format: {$value}."); } @@ -58,13 +58,13 @@ private static function parseCanonical(string $value): self return new self( new GenomicPosition(new Chromosome($matches[1]), NucleotidePosition::fromOneBased(SafeCast::toInt($matches[2]))), new DnaSequence($matches[3]), - new DnaSequence($matches[4]) + new AlternateAllele($matches[4]) ); } private static function parseDash(string $value): self { - if (preg_match('/^(.+)-(\d+)-([ATGC]+)-([ATGC]+)$/i', $value, $matches) === 0) { + if (preg_match('/^(.+)-(\d+)-([ATGCN]+)-([ATGCN]+|\.)$/i', $value, $matches) === 0) { throw new \InvalidArgumentException("Invalid VCF variant identifier format: {$value}."); } @@ -73,7 +73,7 @@ private static function parseDash(string $value): self return new self( new GenomicPosition(new Chromosome($matches[1]), NucleotidePosition::fromOneBased(SafeCast::toInt($matches[2]))), new DnaSequence($matches[3]), - new DnaSequence($matches[4]) + new AlternateAllele($matches[4]) ); } } diff --git a/tests/AlternateAlleleTest.php b/tests/AlternateAlleleTest.php new file mode 100644 index 0000000..946ff6c --- /dev/null +++ b/tests/AlternateAlleleTest.php @@ -0,0 +1,29 @@ +isMissing()); + self::assertSame('.', $alternateAllele->toString()); + } + + public function testBases(): void + { + $alternateAllele = new AlternateAllele('acn'); + self::assertFalse($alternateAllele->isMissing()); + self::assertSame('ACN', $alternateAllele->toString()); + } + + public function testInvalidAlleleThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + new AlternateAllele('..'); + } +} diff --git a/tests/DnaSequenceTest.php b/tests/DnaSequenceTest.php index f145f85..a8e42a2 100644 --- a/tests/DnaSequenceTest.php +++ b/tests/DnaSequenceTest.php @@ -56,4 +56,18 @@ public function testLength(): void $dnaSequence = new DnaSequence(' GCAT '); self::assertSame(4, $dnaSequence->length()); } + + public function testUnknownBase(): void + { + $dnaSequence = new DnaSequence('ANGT'); + self::assertSame('ANGT', $dnaSequence->toString()); + self::assertSame('TNCA', $dnaSequence->complement()); + self::assertSame('ACNT', $dnaSequence->reverseComplement()); + } + + public function testMissingAlleleIsNoDnaSequence(): void + { + $this->expectException(\InvalidArgumentException::class); + new DnaSequence('.'); + } } diff --git a/tests/VariantIdentifierTest.php b/tests/VariantIdentifierTest.php index c6e2236..11f2e60 100644 --- a/tests/VariantIdentifierTest.php +++ b/tests/VariantIdentifierTest.php @@ -81,6 +81,8 @@ public static function parseVCFProvider(): iterable yield 'insertion' => ['chrX-99999-A-ATCG', 'X', 99999, 'A', 'ATCG']; yield 'chromosome 22' => ['chr22-100-G-C', '22', 100, 'G', 'C']; yield 'mitochondrial' => ['chrM-8000-T-C', 'M', 8000, 'T', 'C']; + yield 'unknown base' => ['chr17-42288064-TCAGGC-TGGN', '17', 42288064, 'TCAGGC', 'TGGN']; + yield 'missing alternate' => ['chr11-5304332-A-.', '11', 5304332, 'A', '.']; } /** @dataProvider parseCanonicalProvider */ @@ -101,6 +103,8 @@ public static function parseCanonicalProvider(): iterable yield 'SNV with chr prefix' => ['chr1-12345-A/T', '1', 12345, 'A', 'T']; yield 'multi-base alternate' => ['chr1-12345-A/ATT', '1', 12345, 'A', 'ATT']; yield 'without chr prefix' => ['12-500-G/CA', '12', 500, 'G', 'CA']; + yield 'unknown base' => ['chr19-13054588-AAGACAAGAA/ANT', '19', 13054588, 'AAGACAAGAA', 'ANT']; + yield 'missing alternate' => ['chr11-5304332-A/.', '11', 5304332, 'A', '.']; } public function testParseInvalidVCFThrows(): void @@ -109,6 +113,12 @@ public function testParseInvalidVCFThrows(): void VariantIdentifier::parse('invalid'); } + public function testParseMissingReferenceThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + VariantIdentifier::parse('chr11-5304332-.-A'); + } + public function testParseInvalidCanonicalThrows(): void { $this->expectException(\InvalidArgumentException::class);