From 345372a141915d68c836fe6ae0c1a471b0d30c40 Mon Sep 17 00:00:00 2001 From: Joshua Jones Date: Sat, 21 Feb 2026 23:08:41 -0500 Subject: [PATCH] add batch builder --- AGENTS.md | 15 +- batch.go | 154 ++++++++++++++++++++- batch_test.go | 369 ++++++++++++++++++++++++++++++++++++++++++++++++++ doc.go | 15 ++ segment.go | 20 ++- 5 files changed, 562 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 85ae910..5b2d869 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ Single-package Go library (`package hl7`) for parsing HL7 version 2.x messages i - **Full serialization or Marshal API.** The library does not provide a general `Marshal` function. Message construction is handled by `MessageBuilder` (from-scratch) and `Transform` (modify existing), both of which produce `*Message` via `ParseMessage`. There are no per-field setters on parsed messages. - **Built-in schema definitions.** The library does not ship with HL7v2 segment, data type, or table definitions. Users provide their own `Schema` struct to `msg.Validate()`. This keeps the library general-purpose and avoids bundling version-specific definitions. +- **No field location constants.** HL7 field positions (e.g., `PID-5.1`) are not stable across HL7 v2 versions or vendor implementations. The library does not provide named constants for terser-style location strings; callers define their own. ## Building and Testing @@ -39,14 +40,14 @@ hl7/ escape.go # Unescape() with zero-alloc fast path component.go # Component and Subcomponent value types field.go # Field and Repetition value types - segment.go # Segment type with MSH special-case handling + segment.go # Segment type with MSH/BHS/FHS special-case handling message.go # Message type, ParseMessage(), segment iterators accessor.go # Terser-style location parser, Location type, Value type, Get() charset.go # ValueDecoder type; DecodeString on Field/Repetition/Component/Subcomponent/Value reader.go # io.Reader wrapper with MLLP and raw mode support writer.go # io.Writer wrapper with MLLP and raw mode support ack.go # ACK message generation (AckCode, AckOption, Message.Ack, WithErrors) - batch.go # Batch (BHS/BTS) and File (FHS/FTS) parsing + batch.go # Batch (BHS/BTS) and File (FHS/FTS) parsing; BatchBuilder for constructing batches transform.go # Transform engine, Change types, workBuf, delimiter re-encoding builder.go # MessageBuilder for from-scratch message construction schema.go # Schema types for validation (MessageDef, SegmentDef, etc.) @@ -148,6 +149,8 @@ Schema types are defined in `schema.go`: **MessageBuilder** wraps `workBuf` seeded with a minimal MSH skeleton (instead of a source message). `Set(location, value)` calls the same `applyValueAtLocation` path used by `Transform`. `Build()` calls `ParseMessage` on the buffer to produce an immutable `*Message`. +**BatchBuilder** constructs BHS/BTS-wrapped batch files. `NewBatchBuilder(opts...)` seeds the builder with `DefaultDelimiters()`. `SetHeader(fieldNum, value)` stores plain-text header field values (escaped at `Build` time). `Add(msg)` appends messages. `Build()` pre-calculates total output size, allocates once, and writes BHS + messages + BTS. BTS-1 is set to the message count. `Reset()` clears messages while preserving header fields. BHS-7 defaults to `time.Now()` at build time unless set via `SetHeader(7, ...)`. BHS and FHS segments use the same MSH-style field numbering (field 1 = separator, field 2 = encoding chars, field 3+ = normal fields) via `isHeaderSeg` in `segment.go`. + **Change types** — `Replace`, `Null`, `Omit`, `Move`, `Copy` — are sealed interface implementations (`Change` has an unexported method). `applyOneChange` dispatches on the concrete type. **Delimiter re-encoding** — `reencodeData` performs a single-pass conversion of all bytes from source to destination delimiters, resolving escape sequences to their literal source values and re-escaping if they collide with destination delimiters. @@ -166,18 +169,18 @@ Schema types are defined in `schema.go`: Delimiters are extracted per-message from MSH-1 (field separator) and MSH-2 positions 1-4 (component, repetition, escape, subcomponent). The standard set `|^~\&` is never assumed — any valid delimiter set is accepted. Validation rejects zero bytes, CR/LF, and duplicate characters. -### MSH Field Numbering +### MSH/BHS/FHS Field Numbering -The MSH segment has unique field numbering because MSH-1 IS the field separator character (it does not appear between delimiters like normal fields): +MSH, BHS, and FHS segments share the same unique field numbering because field 1 IS the field separator character (it does not appear between delimiters like normal fields): | Index | Content | Notes | |-------|---------|-------| -| `Field(0)` | `"MSH"` | Segment type (same as all segments) | +| `Field(0)` | `"MSH"` / `"BHS"` / `"FHS"` | Segment type (same as all segments) | | `Field(1)` | `"\|"` | The field separator character itself | | `Field(2)` | `"^~\\&"` | Encoding characters (literal, not parsed further) | | `Field(3+)` | Normal fields | Parsed normally from bytes after encoding chars | -This is implemented in `segment.go` via `mshField()` which handles these three special cases before falling through to `nthSlice` for fields 3+. +This is implemented in `segment.go` via `isHeaderSeg()` (true for MSH, BHS, FHS) and `mshField()`, which handles the three special cases before falling through to `nthSlice` for fields 3+. ### ADD Segment (Continuation) diff --git a/batch.go b/batch.go index 295e3f8..6f1db7d 100644 --- a/batch.go +++ b/batch.go @@ -14,7 +14,11 @@ package hl7 -import "bytes" +import ( + "bytes" + "strconv" + "time" +) // Batch represents an HL7 batch (BHS...BTS) containing one or more messages. type Batch struct { @@ -229,6 +233,154 @@ func parseStandaloneSegment(line []byte) Segment { return Segment{raw: line, delims: delims} } +// BatchBuilder constructs a BHS/BTS-wrapped HL7 batch from individual messages. +// +// Example: +// +// bb := hl7.NewBatchBuilder() +// bb.SetHeader(3, "MYAPP").SetHeader(4, "MYFAC") +// bb.Add(msg1).Add(msg2) +// data, err := bb.Build() +type BatchBuilder struct { + delims Delimiters + fields map[int]string // fieldNum (1-based) -> plain-text value + messages []*Message +} + +// BatchBuilderOption configures a BatchBuilder. +type BatchBuilderOption func(*BatchBuilder) + +// WithBatchDelimiters sets the delimiter set used in the BHS header. +func WithBatchDelimiters(d Delimiters) BatchBuilderOption { + return func(b *BatchBuilder) { + b.delims = d + } +} + +// NewBatchBuilder returns a BatchBuilder with a default BHS header using +// DefaultDelimiters(). BHS-7 (date/time) is set to time.Now() at Build time +// unless overridden via SetHeader(7, ...). +func NewBatchBuilder(opts ...BatchBuilderOption) *BatchBuilder { + b := &BatchBuilder{delims: DefaultDelimiters()} + for _, opt := range opts { + opt(b) + } + return b +} + +// SetHeader sets BHS field fieldNum (1-based) to the given plain-text value. +// The value is escaped at Build time. Common fields: +// BHS-3 (sending application), BHS-4 (sending facility), BHS-11 (batch control ID). +// Returns the receiver for chaining. +func (b *BatchBuilder) SetHeader(fieldNum int, value string) *BatchBuilder { + if b.fields == nil { + b.fields = make(map[int]string) + } + b.fields[fieldNum] = value + return b +} + +// Add appends msg to the batch. msg.Raw() bytes are written verbatim at Build time. +// Returns the receiver for chaining. +func (b *BatchBuilder) Add(msg *Message) *BatchBuilder { + b.messages = append(b.messages, msg) + return b +} + +// Reset clears accumulated messages. Header field values set via SetHeader are preserved. +func (b *BatchBuilder) Reset() { + b.messages = nil +} + +// Build finalizes the batch and returns BHS + messages + BTS as a byte slice. +// BTS-1 (message count) is set to the number of messages added via Add. +// BatchBuilder is reusable after Build; call Reset to clear messages while +// keeping header field values. +// +// Returns an error if the delimiter set is invalid. +func (b *BatchBuilder) Build() ([]byte, error) { + if err := b.delims.validate(); err != nil { + return nil, err + } + + delims := b.delims + now := time.Now() + + // Determine the maximum field number written to the BHS segment. + // BHS-7 (datetime) is always included as the minimum. + maxField := 7 + for k := range b.fields { + if k > maxField { + maxField = k + } + } + + // Collect escaped field values for BHS fields 3..maxField. + fieldVals := make([][]byte, maxField-2) // index 0 = field 3 + for i := 3; i <= maxField; i++ { + idx := i - 3 + if v, ok := b.fields[i]; ok { + fieldVals[idx] = Escape([]byte(v), delims) + } else if i == 7 { + // Default BHS-7 to current date/time. + fieldVals[idx] = []byte(now.Format("20060102150405")) + } + // All other unset fields remain nil (written as empty between separators). + } + + // Pre-calculate BHS segment size: + // "BHS" (3) + fieldSep (1) + 4 encoding chars + (maxField-2) separators + field bytes + bhsSize := 7 + (maxField - 2) + for _, v := range fieldVals { + bhsSize += len(v) + } + + // Pre-calculate BTS segment size: "BTS" (3) + fieldSep (1) + count digits + countStr := strconv.Itoa(len(b.messages)) + btsSize := 3 + 1 + len(countStr) + + // Pre-calculate total output size. + totalSize := bhsSize + 1 // BHS + \r + for _, msg := range b.messages { + raw := msg.Raw() + totalSize += len(raw) + if len(raw) == 0 || raw[len(raw)-1] != '\r' { + totalSize++ // ensure \r terminator after each message + } + } + totalSize += btsSize + 1 // BTS + \r + + // Allocate once and build output. + out := make([]byte, 0, totalSize) + + // BHS segment: BHS + fieldSep + encoding chars + fields 3..maxField + out = append(out, 'B', 'H', 'S') + out = append(out, delims.Field) + out = append(out, delims.Component, delims.Repetition, delims.Escape, delims.SubComponent) + for _, v := range fieldVals { + out = append(out, delims.Field) + out = append(out, v...) + } + out = append(out, '\r') + + // Messages: write each message's raw bytes, ensuring \r termination. + for _, msg := range b.messages { + raw := msg.Raw() + out = append(out, raw...) + if len(raw) == 0 || raw[len(raw)-1] != '\r' { + out = append(out, '\r') + } + } + + // BTS segment: BTS + fieldSep + count + out = append(out, 'B', 'T', 'S') + out = append(out, delims.Field) + out = append(out, countStr...) + out = append(out, '\r') + + return out, nil +} + // extractHeaderDelimiters extracts delimiters from batch/file header segments // (BHS, FHS) which share the same encoding character layout as MSH. func extractHeaderDelimiters(data []byte) (Delimiters, error) { diff --git a/batch_test.go b/batch_test.go index 3854134..278069d 100644 --- a/batch_test.go +++ b/batch_test.go @@ -17,7 +17,9 @@ package hl7 import ( "os" "path/filepath" + "strings" "testing" + "time" ) func TestParseBatch(t *testing.T) { @@ -382,3 +384,370 @@ func TestParseBatchCustomDelimiters(t *testing.T) { t.Fatalf("expected 1 message, got %d", len(batch.Messages)) } } + +// --- BatchBuilder tests --- + +func TestBatchBuilderEmpty(t *testing.T) { + bb := NewBatchBuilder() + data, err := bb.Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + batch, err := ParseBatch(data) + if err != nil { + t.Fatalf("ParseBatch: %v", err) + } + if batch.Header == nil { + t.Error("expected BHS header") + } + if batch.Trailer == nil { + t.Error("expected BTS trailer") + } + if len(batch.Messages) != 0 { + t.Errorf("expected 0 messages, got %d", len(batch.Messages)) + } + // BTS-1 should be "0" + if got := batch.Trailer.Field(1).String(); got != "0" { + t.Errorf("BTS-1 = %q, want 0", got) + } +} + +func TestBatchBuilderSingleMessage(t *testing.T) { + msg, err := ParseMessage([]byte("MSH|^~\\&|APP|FAC|||20240101||ADT^A01|001|P|2.5.1\rPID|1||123")) + if err != nil { + t.Fatalf("ParseMessage: %v", err) + } + bb := NewBatchBuilder() + bb.Add(msg) + data, err := bb.Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + batch, err := ParseBatch(data) + if err != nil { + t.Fatalf("ParseBatch: %v", err) + } + if len(batch.Messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(batch.Messages)) + } + if got := batch.Messages[0].Get("MSH-10").String(); got != "001" { + t.Errorf("MSH-10 = %q, want 001", got) + } + if got := batch.Trailer.Field(1).String(); got != "1" { + t.Errorf("BTS-1 = %q, want 1", got) + } +} + +func TestBatchBuilderMultipleMessages(t *testing.T) { + msg1, _ := ParseMessage([]byte("MSH|^~\\&|APP|FAC|||20240101||ADT^A01|001|P|2.5.1")) + msg2, _ := ParseMessage([]byte("MSH|^~\\&|APP|FAC|||20240102||ADT^A08|002|P|2.5.1")) + msg3, _ := ParseMessage([]byte("MSH|^~\\&|APP|FAC|||20240103||ADT^A03|003|P|2.5.1")) + + bb := NewBatchBuilder() + bb.Add(msg1).Add(msg2).Add(msg3) + data, err := bb.Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + batch, err := ParseBatch(data) + if err != nil { + t.Fatalf("ParseBatch: %v", err) + } + if len(batch.Messages) != 3 { + t.Fatalf("expected 3 messages, got %d", len(batch.Messages)) + } + if got := batch.Trailer.Field(1).String(); got != "3" { + t.Errorf("BTS-1 = %q, want 3", got) + } + // Verify message identity + for i, want := range []string{"001", "002", "003"} { + if got := batch.Messages[i].Get("MSH-10").String(); got != want { + t.Errorf("msg%d MSH-10 = %q, want %q", i+1, got, want) + } + } +} + +func TestBatchBuilderCustomHeaderFields(t *testing.T) { + bb := NewBatchBuilder() + bb.SetHeader(3, "SENDAPP") + bb.SetHeader(4, "SENDFAC") + bb.SetHeader(5, "RECVAPP") + bb.SetHeader(6, "RECVFAC") + data, err := bb.Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + batch, err := ParseBatch(data) + if err != nil { + t.Fatalf("ParseBatch: %v", err) + } + if batch.Header == nil { + t.Fatal("expected BHS header") + } + // BHS uses MSH-style numbering: Field(3) = BHS-3 + if got := batch.Header.Field(3).String(); got != "SENDAPP" { + t.Errorf("BHS-3 = %q, want SENDAPP", got) + } + if got := batch.Header.Field(4).String(); got != "SENDFAC" { + t.Errorf("BHS-4 = %q, want SENDFAC", got) + } + if got := batch.Header.Field(5).String(); got != "RECVAPP" { + t.Errorf("BHS-5 = %q, want RECVAPP", got) + } + if got := batch.Header.Field(6).String(); got != "RECVFAC" { + t.Errorf("BHS-6 = %q, want RECVFAC", got) + } +} + +func TestBatchBuilderResetAndRebuild(t *testing.T) { + msg1, _ := ParseMessage([]byte("MSH|^~\\&|APP|FAC|||20240101||ADT^A01|001|P|2.5.1")) + msg2, _ := ParseMessage([]byte("MSH|^~\\&|APP|FAC|||20240102||ADT^A08|002|P|2.5.1")) + + bb := NewBatchBuilder() + bb.SetHeader(3, "MYAPP") + bb.Add(msg1).Add(msg2) + + data1, err := bb.Build() + if err != nil { + t.Fatalf("Build (first): %v", err) + } + batch1, err := ParseBatch(data1) + if err != nil { + t.Fatalf("ParseBatch (first): %v", err) + } + if len(batch1.Messages) != 2 { + t.Fatalf("first build: expected 2 messages, got %d", len(batch1.Messages)) + } + + // Reset clears messages but keeps header fields + bb.Reset() + bb.Add(msg2) + + data2, err := bb.Build() + if err != nil { + t.Fatalf("Build (second): %v", err) + } + batch2, err := ParseBatch(data2) + if err != nil { + t.Fatalf("ParseBatch (second): %v", err) + } + if len(batch2.Messages) != 1 { + t.Fatalf("second build: expected 1 message, got %d", len(batch2.Messages)) + } + if got := batch2.Messages[0].Get("MSH-10").String(); got != "002" { + t.Errorf("second build: MSH-10 = %q, want 002", got) + } + // Header field should be preserved + if batch2.Header == nil { + t.Fatal("second build: expected BHS header") + } + if got := batch2.Header.Field(3).String(); got != "MYAPP" { + t.Errorf("second build: BHS-3 = %q, want MYAPP (header not preserved)", got) + } +} + +func TestBatchBuilderRoundTrip(t *testing.T) { + msg1, _ := ParseMessage([]byte("MSH|^~\\&|APP1|FAC1|REC1|RFAC1|20240115160001||ADT^A08|MSG001|P|2.5.1\rPID|1||111^^^MRN||Smith^John")) + msg2, _ := ParseMessage([]byte("MSH|^~\\&|APP1|FAC1|REC1|RFAC1|20240115160002||ADT^A08|MSG002|P|2.5.1\rPID|1||222^^^MRN||Doe^Jane")) + + bb := NewBatchBuilder() + bb.SetHeader(3, "APP1") + bb.SetHeader(4, "FAC1") + bb.SetHeader(7, "20240115160000") + bb.Add(msg1).Add(msg2) + + data, err := bb.Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + + batch, err := ParseBatch(data) + if err != nil { + t.Fatalf("ParseBatch round-trip: %v", err) + } + if batch.Header == nil { + t.Fatal("expected BHS header") + } + if batch.Trailer == nil { + t.Fatal("expected BTS trailer") + } + if len(batch.Messages) != 2 { + t.Fatalf("expected 2 messages, got %d", len(batch.Messages)) + } + if got := batch.Header.Field(3).String(); got != "APP1" { + t.Errorf("BHS-3 = %q, want APP1", got) + } + if got := batch.Header.Field(7).String(); got != "20240115160000" { + t.Errorf("BHS-7 = %q, want 20240115160000", got) + } + if got := batch.Messages[0].Get("PID-5.1").String(); got != "Smith" { + t.Errorf("msg1 PID-5.1 = %q, want Smith", got) + } + if got := batch.Messages[1].Get("PID-5.1").String(); got != "Doe" { + t.Errorf("msg2 PID-5.1 = %q, want Doe", got) + } + if got := batch.Trailer.Field(1).String(); got != "2" { + t.Errorf("BTS-1 = %q, want 2", got) + } +} + +func TestBatchBuilderWithBatchDelimiters(t *testing.T) { + d := Delimiters{Field: '#', Component: '@', Repetition: '!', Escape: '$', SubComponent: '%'} + bb := NewBatchBuilder(WithBatchDelimiters(d)) + bb.SetHeader(3, "APP") + // Build with a standard-delimiter message + msg, _ := ParseMessage([]byte("MSH|^~\\&|APP|FAC|||20240101||ADT^A01|001|P|2.5.1")) + bb.Add(msg) + + data, err := bb.Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + // Verify BHS uses custom delimiters + if !strings.HasPrefix(string(data), "BHS#@!$%") { + t.Errorf("BHS does not start with expected delimiter encoding: %q", string(data[:12])) + } +} + +func TestBatchBuilderBHS7DefaultDatetime(t *testing.T) { + before := time.Now().Format("20060102150405") + bb := NewBatchBuilder() + data, err := bb.Build() + after := time.Now().Format("20060102150405") + if err != nil { + t.Fatalf("Build: %v", err) + } + batch, err := ParseBatch(data) + if err != nil { + t.Fatalf("ParseBatch: %v", err) + } + if batch.Header == nil { + t.Fatal("expected BHS header") + } + got := batch.Header.Field(7).String() + if got < before || got > after { + t.Errorf("BHS-7 = %q, want between %q and %q", got, before, after) + } +} + +func TestBatchBuilderBHS7Explicit(t *testing.T) { + bb := NewBatchBuilder() + bb.SetHeader(7, "20240601120000") + data, err := bb.Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + batch, err := ParseBatch(data) + if err != nil { + t.Fatalf("ParseBatch: %v", err) + } + if got := batch.Header.Field(7).String(); got != "20240601120000" { + t.Errorf("BHS-7 = %q, want 20240601120000", got) + } +} + +func TestBatchBuilderSetHeaderEscaping(t *testing.T) { + // Value containing field separator should be escaped + bb := NewBatchBuilder() + bb.SetHeader(3, "APP|NAME") // | is the field separator + data, err := bb.Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + batch, err := ParseBatch(data) + if err != nil { + t.Fatalf("ParseBatch: %v", err) + } + // Parsing should recover the original value via unescape + if got := batch.Header.Field(3).String(); got != "APP|NAME" { + t.Errorf("BHS-3 = %q, want APP|NAME", got) + } +} + +func TestBatchBuilderSetHeaderHighField(t *testing.T) { + // Set a field beyond BHS-7 to test variable-length header + bb := NewBatchBuilder() + bb.SetHeader(11, "BATCHCTRL001") + data, err := bb.Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + batch, err := ParseBatch(data) + if err != nil { + t.Fatalf("ParseBatch: %v", err) + } + if batch.Header == nil { + t.Fatal("expected BHS header") + } + if got := batch.Header.Field(11).String(); got != "BATCHCTRL001" { + t.Errorf("BHS-11 = %q, want BATCHCTRL001", got) + } +} + +func TestBatchBuilderInvalidDelimiters(t *testing.T) { + // Duplicate delimiters should cause Build to return an error + d := Delimiters{Field: '|', Component: '|', Repetition: '~', Escape: '\\', SubComponent: '&'} + bb := NewBatchBuilder(WithBatchDelimiters(d)) + _, err := bb.Build() + if err == nil { + t.Error("expected error for invalid delimiters, got nil") + } +} + +func TestBatchBuilderBuildIsReusable(t *testing.T) { + msg, _ := ParseMessage([]byte("MSH|^~\\&|APP|FAC|||20240101||ADT^A01|001|P|2.5.1")) + bb := NewBatchBuilder() + bb.Add(msg) + + // Build twice without Reset — should produce identical results + data1, err := bb.Build() + if err != nil { + t.Fatalf("first Build: %v", err) + } + data2, err := bb.Build() + if err != nil { + t.Fatalf("second Build: %v", err) + } + // BTS-1 should still be 1 in both + batch1, _ := ParseBatch(data1) + batch2, _ := ParseBatch(data2) + if got := batch1.Trailer.Field(1).String(); got != "1" { + t.Errorf("first build BTS-1 = %q, want 1", got) + } + if got := batch2.Trailer.Field(1).String(); got != "1" { + t.Errorf("second build BTS-1 = %q, want 1", got) + } +} + +func BenchmarkBatchBuilder1Message(b *testing.B) { + msg, _ := ParseMessage([]byte("MSH|^~\\&|APP|FAC|REC|RFAC|20240101||ADT^A01|001|P|2.5.1\rPID|1||123^^^MRN||Smith^John|||M")) + bb := NewBatchBuilder() + bb.SetHeader(3, "APP") + bb.SetHeader(4, "FAC") + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + bb.Reset() + bb.Add(msg) + _, _ = bb.Build() + } +} + +func BenchmarkBatchBuilder10Messages(b *testing.B) { + msgs := make([]*Message, 10) + for i := range msgs { + msgs[i], _ = ParseMessage([]byte("MSH|^~\\&|APP|FAC|REC|RFAC|20240101||ADT^A01|001|P|2.5.1\rPID|1||123^^^MRN||Smith^John|||M")) + } + bb := NewBatchBuilder() + bb.SetHeader(3, "APP") + bb.SetHeader(4, "FAC") + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + bb.Reset() + for _, msg := range msgs { + bb.Add(msg) + } + _, _ = bb.Build() + } +} diff --git a/doc.go b/doc.go index 709ddb2..bd92fa7 100644 --- a/doc.go +++ b/doc.go @@ -252,6 +252,21 @@ // // Both are tolerant: header and trailer segments are optional. // +// # Batch Building +// +// BatchBuilder constructs BHS/BTS-wrapped batch files from individual messages: +// +// bb := hl7.NewBatchBuilder() +// bb.SetHeader(3, "MYAPP").SetHeader(4, "MYFAC") +// bb.Add(msg1).Add(msg2) +// data, err := bb.Build() +// +// BHS-7 (date/time) defaults to time.Now() at Build time unless set via +// SetHeader(7, ...). BTS-1 (message count) is set automatically. Call +// Reset to clear accumulated messages while keeping header field values, +// then call Build again to produce a new batch with the same header. +// Use WithBatchDelimiters to override the default delimiter set. +// // # Escape Sequences // // Escape replaces delimiter characters in data with HL7 escape sequences. diff --git a/segment.go b/segment.go index 4de495c..06dd981 100644 --- a/segment.go +++ b/segment.go @@ -52,12 +52,26 @@ func (s *Segment) Field(index int) Field { return Field{Value: Value{raw: s.raw[0:3], delims: s.delims}} } - if s.raw[0] == 'M' && s.raw[1] == 'S' && s.raw[2] == 'H' { + if isHeaderSeg(s.raw) { return s.mshField(index) } return s.normalField(index) } +// isHeaderSeg reports whether raw is an MSH, BHS, or FHS segment. +// All three segment types share the same encoding-character layout: +// position 3 is the field separator and positions 4-7 are the four +// encoding characters. Field numbering follows HL7 convention: +// field 1 = the separator itself, field 2 = encoding chars, field 3+ = normal fields. +func isHeaderSeg(raw []byte) bool { + if len(raw) < 3 { + return false + } + return (raw[0] == 'M' && raw[1] == 'S' && raw[2] == 'H') || + (raw[0] == 'B' && raw[1] == 'H' && raw[2] == 'S') || + (raw[0] == 'F' && raw[1] == 'H' && raw[2] == 'S') +} + // mshField handles the MSH segment's special field structure. func (s *Segment) mshField(index int) Field { raw := s.raw @@ -117,9 +131,7 @@ func (s *Segment) FieldCount() int { return 0 } - isMSH := raw[0] == 'M' && raw[1] == 'S' && raw[2] == 'H' - - if isMSH { + if isHeaderSeg(raw) { return s.mshFieldCount() } return s.normalFieldCount()