package sqlite import ( "database/sql" "git.vakhrushev.me/av/transcriber/internal/entity" ) // Отображение аудиозаписи в строку базы и обратно живёт одним местом. // // Работает оно **по имени колонки**: именованные параметры запроса и место // назначения, найденное по имени. Причина в самой сущности — у аудиозаписи поля // одного типа идут длинным непрерывным рядом, и ссылки на файл, на структуру // реплик, на два вида текста и на попытку распознавания стоят в нём подряд. // Позиционный список дал бы сдвиг на одно поле, который компилируется молча и // кладёт идентификатор файла в колонку текста. По имени такого сдвига не // существует вовсе: лишнее имя или недостающее — отказ запроса, а не тихая // подмена значения. // // Инвариант проекта о колонках записи эта форма не снимает: колонку по-прежнему // можно забыть в отображении или в шаге схемы, и сверку держат правила // `internal/archrules`. // writeOwnedByPipeline — колонки, которыми распоряжается конвейер. // // Разрез нужен потому, что шаг держит запись снимком с момента захвата и до // своего сохранения, а это часы. Всё, что владелец правил за это время, // безусловная запись снимка стёрла бы молча: ни строки в журнале, ни отказа // тому, кто правил. Владелец, заголовок, краткое описание, имя файла // отправителя, длительность, размер и темы не трогаются вовсе. func writeOwnedByPipeline(r *entity.AudioRecord) map[string]any { return map[string]any{ "state": r.State, "state_entered_at": formatTime(r.StateEnteredAt), "halted_at": timeValue(r.HaltedAt), "halt_reason": stringValue(r.HaltReason), "error_text": stringValue(r.ErrorText), "acquisition_id": stringValue(r.AcquisitionID), "acquire_expires_at": timeValue(r.AcquireExpiresAt), "delay_time": timeValue(r.DelayTime), "attempts": r.Attempts, "original_file_id": stringValue(r.OriginalFileID), "normalized_file_id": stringValue(r.NormalizedFileID), "transcript_text_id": stringValue(r.TranscriptTextID), "literary_text_id": stringValue(r.LiteraryTextID), "structure_id": stringValue(r.StructureID), "recognition_id": stringValue(r.RecognitionID), "updated_at": formatTime(r.UpdatedAt), } } // writeRecord — запись целиком: это заведение, и спорить за поля здесь не с кем. // // Колонки заведения перечислены той же формой, что и колонки конвейера, — картой // «имя колонки в значение»: сверка колонок в `internal/archrules` читает именно // её, и колонка, положенная присваиванием мимо карты, выпала бы из-под правила // молча. func writeRecord(r *entity.AudioRecord) map[string]any { values := writeOwnedByPipeline(r) own := map[string]any{ "id": r.Id, // Владелец кладётся только здесь, при заведении. В перечне конвейера его // нет намеренно: конвейер владельца не назначает и не меняет, а снимок // шага, записанный поверх, стёр бы его молча. "owner_id": r.OwnerID, "title": stringValue(r.Title), "brief": stringValue(r.Brief), // Имя файла отправителя, длительность и размер кладёт приём и только он: // это снимок принятого, и конвейер его не пересчитывает. "original_filename": stringValue(r.OriginalFilename), "duration_ms": numberValue(r.DurationMs), "size_bytes": numberValue(r.SizeBytes), "created_at": formatTime(r.CreatedAt), } for name, value := range own { values[name] = value } return values } // recordRow — сырые значения одной строки аудиозаписи. type recordRow struct { id string ownerID string title sql.NullString brief sql.NullString originalFilename sql.NullString durationMs int64 sizeBytes int64 state string stateEnteredAt string haltedAt sql.NullString haltReason sql.NullString errorText sql.NullString acquisitionID sql.NullString acquireExpiresAt sql.NullString delayTime sql.NullString attempts int originalFileID sql.NullString normalizedFileID sql.NullString transcriptTextID sql.NullString literaryTextID sql.NullString structureID sql.NullString recognitionID sql.NullString createdAt string updatedAt string } // readRecordColumns — куда кладётся каждая колонка при чтении. // // Перечень колонок выборки собирается из этой же карты, поэтому расхождению // между тем, что спрошено, и тем, куда оно ляжет, взяться неоткуда. func readRecordColumns(row *recordRow) map[string]any { return map[string]any{ "id": &row.id, "owner_id": &row.ownerID, "title": &row.title, "brief": &row.brief, "original_filename": &row.originalFilename, "duration_ms": &row.durationMs, "size_bytes": &row.sizeBytes, "state": &row.state, "state_entered_at": &row.stateEnteredAt, "halted_at": &row.haltedAt, "halt_reason": &row.haltReason, "error_text": &row.errorText, "acquisition_id": &row.acquisitionID, "acquire_expires_at": &row.acquireExpiresAt, "delay_time": &row.delayTime, "attempts": &row.attempts, "original_file_id": &row.originalFileID, "normalized_file_id": &row.normalizedFileID, "transcript_text_id": &row.transcriptTextID, "literary_text_id": &row.literaryTextID, "structure_id": &row.structureID, "recognition_id": &row.recognitionID, "created_at": &row.createdAt, "updated_at": &row.updatedAt, } } // rowToAudioRecord собирает доменную запись из прочитанной строки. func rowToAudioRecord(row *recordRow) *entity.AudioRecord { return &entity.AudioRecord{ Id: row.id, OwnerID: row.ownerID, Title: stringOf(row.title), Brief: stringOf(row.brief), OriginalFilename: stringOf(row.originalFilename), DurationMs: numberOf(row.durationMs), SizeBytes: numberOf(row.sizeBytes), State: row.state, StateEnteredAt: requiredTimeOf(row.stateEnteredAt), HaltedAt: timeOf(row.haltedAt), HaltReason: stringOf(row.haltReason), ErrorText: stringOf(row.errorText), AcquisitionID: stringOf(row.acquisitionID), AcquireExpiresAt: timeOf(row.acquireExpiresAt), DelayTime: timeOf(row.delayTime), Attempts: row.attempts, OriginalFileID: stringOf(row.originalFileID), NormalizedFileID: stringOf(row.normalizedFileID), TranscriptTextID: stringOf(row.transcriptTextID), LiteraryTextID: stringOf(row.literaryTextID), StructureID: stringOf(row.structureID), RecognitionID: stringOf(row.recognitionID), TopicIDs: []string{}, CreatedAt: requiredTimeOf(row.createdAt), UpdatedAt: requiredTimeOf(row.updatedAt), } }