2
3
4
6#include "penelope/inbox.hpp"
8#include "arachne/contracts.hpp"
10#include <nlohmann/json.hpp>
11#include <rapidfuzz/fuzz.hpp>
37#include <unordered_map>
38#include <unordered_set>
45namespace fs = std::filesystem;
57 sqlite3*
const value,
const std::string_view operation
59 return std::string(operation) +
": "
60 + (value ==
nullptr ?
"SQLite error" : sqlite3_errmsg(value));
63class statement
final {
65 statement(sqlite3*
const database,
const std::string_view sql)
67 if (sqlite3_prepare_v2(
68 database, sql.data(),
static_cast<
int>(sql.size()), &
value_,
72 throw database_error(sqlite_message(database,
"prepare statement"));
77 statement&
operator=(
const statement&) =
delete;
87 void bind(
const int index,
const std::string_view value) {
88 if (sqlite3_bind_text(
89 value_, index, value.data(),
static_cast<
int>(value.size()),
93 throw database_error(sqlite_message(database_,
"bind text"));
97 void bind(
const int index,
const std::int64_t value) {
98 if (sqlite3_bind_int64(
value_, index, value) != SQLITE_OK) {
99 throw database_error(sqlite_message(database_,
"bind integer"));
103 void bind(
const int index,
const double value) {
104 if (sqlite3_bind_double(
value_, index, value) != SQLITE_OK) {
105 throw database_error(sqlite_message(database_,
"bind real"));
110 if (sqlite3_bind_null(
value_, index) != SQLITE_OK) {
111 throw database_error(sqlite_message(database_,
"bind null"));
116 if (value.is_null()) {
118 }
else if (value.is_string()) {
119 bind(index, value.get_ref<
const std::string&>());
120 }
else if (value.is_boolean()) {
121 bind(index
, static_cast<std::int64_t>(value.get<
bool>() ? 1 : 0)
);
122 }
else if (value.is_number_integer()) {
123 bind(index, value.get<std::int64_t>());
124 }
else if (value.is_number_unsigned()) {
125 const auto number = value.get<std::uint64_t>();
127 >
static_cast<std::uint64_t>(
128 std::numeric_limits<std::int64_t>::max()
130 throw database_error(
"integer is outside SQLite's range");
132 bind(index
, static_cast<std::int64_t>(number)
);
133 }
else if (value.is_number_float()) {
134 bind(index, value.get<
double>());
136 bind(index, value.dump());
141 const int status = sqlite3_step(
value_);
142 if (status == SQLITE_ROW) {
145 if (status == SQLITE_DONE) {
148 throw database_error(sqlite_message(database_,
"execute statement"));
153 throw database_error(
"statement unexpectedly returned a row");
158 return sqlite3_column_int64(
value_, column);
161 [[nodiscard]]
double real(
const int column)
const {
162 return sqlite3_column_double(
value_, column);
165 [[nodiscard]] std::string
text(
const int column)
const {
166 const auto* bytes = sqlite3_column_text(
value_, column);
167 if (bytes ==
nullptr) {
170 return reinterpret_cast<
const char*>(bytes);
173 [[nodiscard]]
bool is_null(
const int column)
const {
174 return sqlite3_column_type(
value_, column) == SQLITE_NULL;
185 const std::set<std::string, std::less<>> expected_tables {
188 "concept_relation_evidence",
199 "merge_hint_block_members",
203 "parent_guide_assertions",
204 "parent_guide_evidence",
206 "work_concept_evidence",
210 std::set<std::string, std::less<>> actual_tables;
213 "SELECT name FROM sqlite_schema WHERE type='table' "
214 "AND name NOT LIKE 'sqlite_%'"
217 actual_tables.emplace(tables
.text(0
));
219 if (actual_tables != expected_tables) {
220 throw database_error(
221 "product database table set does not match schema version 5"
225 const std::set<std::string, std::less<>> expected_indexes {
226 "concept_relations_object_idx",
228 "credits_logical_unique",
230 "evidence_logical_unique",
231 "external_ids_entity_idx",
232 "financial_facts_logical_unique",
233 "ingest_issues_status_idx",
234 "measurements_logical_unique",
235 "merge_hint_block_members_peer_idx",
236 "merge_hints_left_idx",
237 "merge_hints_right_idx",
238 "merge_hints_status_score_idx",
240 "names_logical_unique",
241 "sources_bibliography_fallback_unique",
242 "sources_doi_unique",
243 "sources_isbn_unique",
244 "sources_url_unique",
245 "work_concepts_concept_idx",
247 std::set<std::string, std::less<>> actual_indexes;
250 "SELECT name FROM sqlite_schema WHERE type='index' "
251 "AND name NOT LIKE 'sqlite_autoindex_%'"
254 actual_indexes.emplace(indexes
.text(0
));
256 if (actual_indexes != expected_indexes) {
257 throw database_error(
258 "product database index set does not match schema version 5"
262 const std::set<std::string, std::less<>> expected_triggers {
263 "agents_entity_type",
264 "agents_entity_type_update",
265 "concept_relation_last_evidence_delete",
266 "concepts_entity_type",
267 "entities_agent_type_update",
268 "entities_subtype_update_guard",
269 "manifestations_entity_type",
270 "merge_hint_block_members_entity_family_insert",
271 "merge_hint_block_members_entity_family_update",
272 "merge_hint_block_members_remove_orphan",
273 "merge_hint_blocks_identity_update_guard",
274 "merge_hints_entity_family_insert",
275 "merge_hints_entity_family_update",
276 "parent_guide_last_evidence_delete",
277 "work_concept_last_evidence_delete",
280 std::set<std::string, std::less<>> actual_triggers;
282 sql,
"SELECT name FROM sqlite_schema WHERE type='trigger'"
285 actual_triggers.emplace(triggers
.text(0
));
287 if (actual_triggers != expected_triggers) {
288 throw database_error(
289 "product database trigger set does not match schema version 5"
292 const std::vector<std::string> expected_block_columns {
293 "id",
"entity_type",
"block_type",
"block_key"
295 std::vector<std::string> actual_block_columns;
296 statement columns(sql,
"PRAGMA table_info(merge_hint_blocks)");
298 actual_block_columns.push_back(columns
.text(1
));
300 if (actual_block_columns != expected_block_columns) {
301 throw database_error(
302 "merge_hint_blocks columns do not match schema version 5"
305 const std::vector<std::string> expected_member_columns {
306 "id",
"block_id",
"entity_id"
308 std::vector<std::string> actual_member_columns;
309 statement member_columns(
310 sql,
"PRAGMA table_info(merge_hint_block_members)"
312 while (member_columns
.step()) {
313 actual_member_columns.push_back(member_columns
.text(1
));
315 if (actual_member_columns != expected_member_columns) {
316 throw database_error(
317 "merge_hint_block_members columns do not match schema version 5"
320 statement foreign_keys(
321 sql,
"PRAGMA foreign_key_list(merge_hint_block_members)"
323 bool entity_cascade =
false;
324 bool block_cascade =
false;
326 entity_cascade = entity_cascade
328 foreign_keys
.text(2
) ==
"entities"
329 && foreign_keys
.text(3
) ==
"entity_id"
330 && foreign_keys
.text(4
) ==
"id"
331 && foreign_keys
.text(6
) ==
"CASCADE"
333 block_cascade = block_cascade
335 foreign_keys
.text(2
) ==
"merge_hint_blocks"
336 && foreign_keys
.text(3
) ==
"block_id"
337 && foreign_keys
.text(4
) ==
"id"
338 && foreign_keys
.text(6
) ==
"CASCADE"
341 if (!entity_cascade || !block_cascade) {
342 throw database_error(
343 "merge-hint block memberships must cascade with blocks and entities"
348class database
final {
350 database(
const fs::path& path,
const bool writable) {
351 const std::string native = path.string();
352 const int flags = writable
353 ? SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX
354 : SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX;
355 if (sqlite3_open_v2(native.c_str(), &
value_, flags,
nullptr)
357 const std::string message = sqlite_message(value_,
"open database");
362 throw database_error(message);
364 sqlite3_extended_result_codes(
value_, 1);
365 sqlite3_busy_timeout(
value_, 10'000);
366 execute(
"PRAGMA foreign_keys = ON");
367 statement version(
value_,
"PRAGMA user_version");
369 throw database_error(
370 "product database must use schema version 5"
388 char* error =
nullptr;
389 const int status = sqlite3_exec(
390 value_, std::string(sql).c_str(),
nullptr,
nullptr, &error
392 if (status != SQLITE_OK) {
393 std::string message = error ==
nullptr
394 ? sqlite_message(value_,
"execute SQL")
395 : std::string(error);
397 throw database_error(message);
407class transaction
final {
415 transaction&
operator=(
const transaction&) =
delete;
436struct file_identity
final {
443struct file_snapshot
final {
450 const file_identity& left,
const file_identity& right
461 .
inode = state.st_ino,
462 .
size = state.st_size,
463#if defined(__APPLE__)
464 .modified = state.st_mtimespec,
471class file_descriptor
final {
476 file_descriptor&
operator=(
const file_descriptor&) =
delete;
482 [[nodiscard]]
int get()
const noexcept {
return value_; }
489 struct stat link_state {};
490 if (::lstat(path.c_str(), &link_state) != 0) {
492 "cannot inspect inbox file " + path.string() +
": "
493 + std::strerror(errno)
496 if (S_ISLNK(link_state.st_mode) || !S_ISREG(link_state.st_mode)) {
498 "inbox entry is not a real regular file: " + path.string()
501 const int raw = ::open(path.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
504 "cannot open inbox file " + path.string() +
": "
505 + std::strerror(errno)
508 file_descriptor descriptor
(raw
);
509 struct stat before {};
510 if (::fstat(descriptor
.get(), &before) != 0 || !S_ISREG(before.st_mode)) {
511 throw inbox_error(
"cannot snapshot inbox file " + path.string());
513 if (before.st_size < 0
515 throw inbox_error(
"inbox batch exceeds its byte limit: " + path.string());
517 std::string bytes(
static_cast<std::size_t>(before.st_size),
'\0');
518 std::size_t offset = 0;
519 while (offset < bytes.size()) {
520 const ssize_t count = ::read(
521 descriptor
.get(), bytes.data() + offset, bytes.size() - offset
523 if (count < 0 && errno == EINTR) {
527 throw inbox_error(
"cannot read complete inbox file " + path.string());
529 offset +=
static_cast<std::size_t>(count);
532 ssize_t trailing_count;
534 trailing_count = ::read(descriptor
.get(), &trailing, 1U);
535 }
while (trailing_count < 0 && errno == EINTR);
536 struct stat after {};
537 if (trailing_count != 0 || ::fstat(descriptor
.get(), &after) != 0
539 throw inbox_error(
"inbox file changed while it was read: " + path.string());
543 .bytes = std::move(bytes),
549 struct stat state {};
550 return ::lstat(snapshot.path.c_str(), &state) == 0
551 && S_ISREG(state.st_mode) && !S_ISLNK(state.st_mode)
557 result.reserve(value.size());
558 for (
const char character : value) {
559 if (character ==
'~') {
561 }
else if (character ==
'/') {
564 result.push_back(character);
570struct parsed_batch
final {
588 parsed_batch& batch,
const std::string_view code,
589 const std::string_view path,
const std::string_view message,
590 const json*
const value =
nullptr
592 batch.issues.push_back(
595 .code = std::string(code),
596 .json_path = path.empty() ?
"/" : std::string(path),
597 .message = std::string(message),
598 .value_json = value ==
nullptr ? std::string() : value->dump(),
604 if (value.empty() || value.size() > 128U) {
607 return std::ranges::all_of(value, [](
const unsigned char character) {
608 return std::isalnum(character) != 0 || character ==
'-'
609 || character ==
'_' || character ==
'.' || character ==
':';
614 if (value.empty() || value.size() > 128U
615 || std::isalnum(
static_cast<
unsigned char>(value.front())) == 0) {
618 return std::ranges::all_of(value, [](
const unsigned char character) {
619 return std::isalnum(character) != 0 || character ==
'.'
620 || character ==
'_' || character ==
':' || character ==
'-';
625 const std::string_view value,
const std::string_view family
627 std::string_view prefix;
628 if (family ==
"agent") {
630 }
else if (family ==
"work") {
632 }
else if (family ==
"concept") {
634 }
else if (family ==
"manifestation") {
635 prefix =
"manifestation-";
639 if (!value.starts_with(prefix) || value.size() < prefix.size() + 6U) {
642 return std::ranges::all_of(
643 value.substr(prefix.size()),
644 [](
const unsigned char character) {
return std::isdigit(character) != 0; }
649 const std::string_view value
651 return valid_canonical_id(value,
"agent")
652 || valid_canonical_id(value,
"work")
653 || valid_canonical_id(value,
"concept")
654 || valid_canonical_id(value,
"manifestation");
658 if (value.empty() || value.front() ==
'-' || value.back() ==
'-') {
661 bool previous_dash =
false;
662 for (
const char raw_character : value) {
663 const auto character =
static_cast<
unsigned char>(raw_character);
664 if (character ==
'-') {
668 previous_dash =
true;
670 if (!(std::isdigit(character) != 0
671 || (character >=
'a' && character <=
'z'))) {
674 previous_dash =
false;
681 parsed_batch& batch,
const json& object,
const std::string& path,
682 const std::set<std::string, std::less<>>& allowed,
683 const std::set<std::string, std::less<>>& required = {}
685 if (!object.is_object()) {
687 batch,
"type_mismatch", path,
"value must be an object", &object
691 for (
const auto& [key, value] : object.items()) {
692 if (!allowed.contains(key)) {
694 batch,
"unknown_field",
695 path +
"/" + pointer_escape(key),
"field is not allowed", &value
699 for (
const auto& key : required) {
700 if (!object.contains(key)) {
702 batch,
"required_field", path +
"/" + pointer_escape(key),
703 "required field is missing"
721 return value.is_string();
723 return value.is_number_integer() || value.is_number_unsigned();
725 return value.is_number();
727 return value.is_boolean();
729 return value.is_object();
731 return value.is_array();
737 parsed_batch& batch,
const json& object,
const std::string& key,
738 const std::string& path,
const value_kind kind
740 const auto found = object.find(key);
741 if (found == object.end()) {
744 if (!is_kind(*found, kind)) {
746 batch,
"type_mismatch", path +
"/" + pointer_escape(key),
747 "field has the wrong JSON type", &*found
753 parsed_batch& batch,
const json& object,
const std::string& key,
754 const std::string& path
757 const auto found = object.find(key);
758 if (found != object.end() && found->is_string()
759 && found->get_ref<
const std::string&>().empty()) {
761 batch,
"empty_string", path +
"/" + pointer_escape(key),
762 "field must not be empty", &*found
768 parsed_batch& batch,
const json& object,
const std::string& key,
769 const std::string& path,
770 const std::set<std::string, std::less<>>& allowed
773 const auto found = object.find(key);
774 if (found != object.end() && found->is_string()
775 && !allowed.contains(found->get<std::string>())) {
777 batch,
"unknown_enum", path +
"/" + pointer_escape(key),
778 "field contains an unknown enum value", &*found
784 parsed_batch& batch,
const json& object,
const std::string& key,
785 const std::string& path,
const std::int64_t minimum,
786 const std::int64_t maximum
789 const auto found = object.find(key);
790 if (found == object.end()
791 || !(found->is_number_integer() || found->is_number_unsigned())) {
795 const auto value = found->get<std::int64_t>();
796 if (value < minimum || value > maximum) {
798 batch,
"number_out_of_range",
799 path +
"/" + pointer_escape(key),
"integer is outside its range",
803 }
catch (
const json::exception&) {
805 batch,
"number_out_of_range", path +
"/" + pointer_escape(key),
806 "integer is outside SQLite's range", &*found
812 parsed_batch& batch,
const json& object,
const std::string& key,
813 const std::string& path,
const double minimum,
const double maximum
816 const auto found = object.find(key);
817 if (found == object.end() || !found->is_number()) {
820 const double value = found->get<
double>();
821 if (!std::isfinite(value) || value < minimum || value > maximum) {
823 batch,
"number_out_of_range", path +
"/" + pointer_escape(key),
824 "number is outside its range", &*found
830 "person",
"organization",
"group"
833 "film",
"short_film",
"television",
"novel",
"novella",
"short_story",
834 "poetry",
"play",
"essay",
"album",
"single",
"composition",
"painting",
835 "print",
"engraving",
"drawing",
"sculpture",
"installation",
836 "photography",
"mixed_media"
839 "year",
"decade",
"approximate",
"range",
"exact"
842 "genre",
"style",
"theme",
"keyword",
"motif",
"trope",
"phobia",
843 "taboo",
"technique",
"movement",
"setting",
"mood",
"content_warning"
846 "edition",
"translation",
"release",
"pressing",
"cut",
"restoration",
850 "original",
"english",
"transliteration",
"translation",
"alias",
854 "book",
"article",
"catalogue",
"web_page",
"interview",
"database",
855 "video",
"audio",
"PDF"
858 "supports",
"contradicts",
"contextualizes"
861 "author",
"director",
"screenwriter",
"producer",
"actor",
"composer",
862 "performer",
"artist",
"engraver",
"sculptor",
"photographer",
"editor",
863 "cinematographer",
"production_company",
"publisher",
"record_label",
867 "primary",
"key",
"supporting"
870 "duration",
"height",
"width",
"depth",
"pages"
873 "seconds",
"millimetres",
"pages"
876 "broader_than",
"narrower_than",
"derived_from",
"precursor_of",
877 "hybrid_of",
"revival_of",
"regional_variant_of",
"influenced_by",
878 "opposes",
"alias_of"
881 "exemplifies",
"contains",
"anticipates",
"influenced_by",
"influences",
882 "revives",
"parodies",
"deconstructs",
"associated_with"
885 "formative",
"canonical",
"transitional",
"hybrid",
"revival",
886 "late_derivative",
"peripheral",
"precursor"
889 "violence",
"sex_nudity",
"language",
"drugs",
"frightening",
"self_harm",
890 "discrimination",
"abuse",
"taboo"
893 "none",
"mild",
"major"
897 const std::string_view parent,
const std::size_t index
899 return std::string(parent) +
"/" + std::to_string(index);
903 parsed_batch& batch,
const std::string_view path,
const json& value
905 batch.application_path = path.empty() ?
"/" : std::string(path);
910 parsed_batch& batch,
const json& record,
const std::string& path,
911 std::unordered_map<std::string, std::string>& locals,
912 const std::string_view family
914 require_nonempty_string(batch, record,
"local_id", path);
915 const auto found = record.find(
"local_id");
916 if (found == record.end() || !found->is_string()
917 || found->get_ref<
const std::string&>().empty()) {
920 const std::string& value = found->get_ref<
const std::string&>();
921 if (!valid_local_id(value)) {
923 batch,
"invalid_local_id", path +
"/local_id",
924 "local_id must be 1-128 identifier characters", &*found
928 batch,
"reserved_local_id", path +
"/local_id",
929 "local_id must not use a canonical entity ID pattern", &*found
932 const auto [position, inserted] = locals.emplace(value, family);
935 batch,
"duplicate_local_id", path +
"/local_id",
936 "local_id is already used by " + position->second, &*found
942 parsed_batch& batch,
const json& record,
const std::string& key,
943 const std::string& path
945 require_nonempty_string(batch, record, key, path);
949 parsed_batch& batch,
const json& record,
const std::string& key,
950 const std::string& path
952 const auto found = record.find(key);
953 if (found == record.end()) {
956 if (found->is_string()) {
957 if (found->get_ref<
const std::string&>().empty()) {
959 batch,
"empty_string", path +
"/" + pointer_escape(key),
960 "local reference must not be empty", &*found
963 }
else if (!(found->is_number_integer() || found->is_number_unsigned())) {
965 batch,
"type_mismatch", path +
"/" + pointer_escape(key),
966 "reference must be a positive database integer or local_id", &*found
970 if (found->get<std::int64_t>() <= 0) {
972 batch,
"number_out_of_range",
973 path +
"/" + pointer_escape(key),
974 "database row reference must be positive", &*found
977 }
catch (
const json::exception&) {
979 batch,
"number_out_of_range",
980 path +
"/" + pointer_escape(key),
981 "database row reference is outside SQLite's range", &*found
988 parsed_batch& batch,
const json& value,
const std::string& path,
989 std::unordered_map<std::string, std::string>& locals
993 {
"local_id",
"agent_type",
"birth_year",
"death_year" },
994 {
"local_id",
"agent_type" }
996 if (!value.is_object()) {
999 validate_local_id(batch, value, path, locals,
"agent");
1000 require_enum(batch, value,
"agent_type", path, agent_types);
1001 require_integer_range(batch, value,
"birth_year", path, -9999, 9999);
1002 require_integer_range(batch, value,
"death_year", path, -9999, 9999);
1006 parsed_batch& batch,
const json& value,
const std::string& path,
1007 std::unordered_map<std::string, std::string>& locals
1011 {
"local_id",
"medium",
"year_start",
"year_end",
"date_precision",
1012 "date_start_text",
"date_end_text",
"date_qualifier",
1013 "language_code",
"country_code",
"production_info_json" },
1014 {
"local_id",
"medium" }
1016 if (!value.is_object()) {
1019 validate_local_id(batch, value, path, locals,
"work");
1020 require_enum(batch, value,
"medium", path, media);
1021 require_integer_range(batch, value,
"year_start", path, -9999, 9999);
1022 require_integer_range(batch, value,
"year_end", path, -9999, 9999);
1023 if (value.contains(
"year_start") && value[
"year_start"].is_number_integer()
1024 && value.contains(
"year_end") && value[
"year_end"].is_number_integer()
1025 && value[
"year_end"].get<std::int64_t>()
1026 < value[
"year_start"].get<std::int64_t>()) {
1028 batch,
"invalid_range", path +
"/year_end",
1029 "year_end must not be earlier than year_start", &value[
"year_end"]
1032 if (value.contains(
"date_precision")) {
1033 require_enum(batch, value,
"date_precision", path, date_precisions);
1035 for (
const auto& key : {
1036 "date_start_text",
"date_end_text",
"date_qualifier",
1037 "language_code",
"country_code" }) {
1038 if (value.contains(key)) {
1039 require_nonempty_string(batch, value, key, path);
1042 if (value.contains(
"production_info_json")) {
1043 require_nonempty_string(
1044 batch, value,
"production_info_json", path
1046 if (value[
"production_info_json"].is_string()) {
1048 const json parsed = json::parse(
1049 value[
"production_info_json"].get<std::string>()
1051 static_cast<
void>(parsed);
1052 }
catch (
const json::exception&) {
1054 batch,
"invalid_json_text",
1055 path +
"/production_info_json",
1056 "production_info_json must contain valid JSON",
1057 &value[
"production_info_json"]
1065 parsed_batch& batch,
const json& value,
const std::string& path,
1066 std::unordered_map<std::string, std::string>& locals
1069 batch, value, path, {
"local_id",
"concept_type",
"slug" },
1070 {
"local_id",
"concept_type",
"slug" }
1072 if (!value.is_object()) {
1075 validate_local_id(batch, value, path, locals,
"concept");
1076 require_enum(batch, value,
"concept_type", path, concept_types);
1077 require_nonempty_string(batch, value,
"slug", path);
1078 if (value.contains(
"slug") && value[
"slug"].is_string()
1079 && !valid_slug(value[
"slug"].get_ref<
const std::string&>())) {
1081 batch,
"invalid_slug", path +
"/slug",
1082 "slug must contain lowercase alphanumeric dash-separated tokens",
1089 parsed_batch& batch,
const json& value,
const std::string& path,
1090 std::unordered_map<std::string, std::string>& locals
1094 {
"local_id",
"work_id",
"manifestation_type",
"release_year",
1095 "region_code",
"language_code",
"label" },
1096 {
"local_id",
"work_id",
"manifestation_type",
"label" }
1098 if (!value.is_object()) {
1101 validate_local_id(batch, value, path, locals,
"manifestation");
1102 validate_entity_reference_shape(batch, value,
"work_id", path);
1104 batch, value,
"manifestation_type", path, manifestation_types
1106 require_nonempty_string(batch, value,
"label", path);
1107 require_integer_range(batch, value,
"release_year", path, -9999, 9999);
1108 for (
const auto& key : {
"region_code",
"language_code" }) {
1109 if (value.contains(key)) {
1110 require_nonempty_string(batch, value, key, path);
1116 parsed_batch& batch,
const json& value,
const std::string& path
1120 {
"entity_id",
"name_type",
"language_code",
"script_code",
"value",
1122 {
"entity_id",
"name_type",
"value",
"is_preferred" }
1124 if (!value.is_object()) {
1127 validate_entity_reference_shape(batch, value,
"entity_id", path);
1128 require_enum(batch, value,
"name_type", path, name_types);
1129 require_nonempty_string(batch, value,
"value", path);
1131 for (
const auto& key : {
"language_code",
"script_code" }) {
1132 if (value.contains(key)) {
1133 require_nonempty_string(batch, value, key, path);
1139 parsed_batch& batch,
const json& value,
const std::string& path
1143 {
"entity_id",
"scheme",
"value",
"canonical_url" },
1144 {
"entity_id",
"scheme",
"value" }
1146 if (!value.is_object()) {
1149 validate_entity_reference_shape(batch, value,
"entity_id", path);
1150 require_nonempty_string(batch, value,
"scheme", path);
1151 require_nonempty_string(batch, value,
"value", path);
1152 if (value.contains(
"canonical_url")) {
1153 require_nonempty_string(batch, value,
"canonical_url", path);
1158 parsed_batch& batch,
const json& value,
const std::string& path,
1159 std::unordered_map<std::string, std::string>& locals
1163 {
"local_id",
"source_type",
"title",
"bibliography_text",
1164 "author_text",
"publisher",
"publication_date",
"url",
"doi",
1165 "isbn",
"language_code" },
1166 {
"local_id",
"source_type" }
1168 if (!value.is_object()) {
1171 validate_local_id(batch, value, path, locals,
"source");
1172 require_enum(batch, value,
"source_type", path, source_types);
1173 for (
const auto& key : {
1174 "title",
"bibliography_text",
"author_text",
"publisher",
1175 "publication_date",
"url",
"doi",
"isbn",
"language_code" }) {
1176 if (value.contains(key)) {
1177 require_nonempty_string(batch, value, key, path);
1180 bool identified =
false;
1181 for (
const auto& key : {
"doi",
"isbn",
"url",
"bibliography_text" }) {
1182 identified = identified || value.contains(key);
1186 batch,
"source_identity_required", path,
1187 "source needs doi, isbn, url, or bibliography_text", &value
1193 parsed_batch& batch,
const json& value,
const std::string& path,
1194 std::unordered_map<std::string, std::string>& locals
1198 {
"local_id",
"source_id",
"exact_quote",
"quote_language",
1199 "quote_translation",
"locator_json",
"stance" },
1200 {
"local_id",
"source_id",
"exact_quote",
"stance" }
1202 if (!value.is_object()) {
1205 validate_local_id(batch, value, path, locals,
"evidence");
1206 validate_integer_or_local_reference_shape(
1207 batch, value,
"source_id", path
1209 require_nonempty_string(batch, value,
"exact_quote", path);
1210 require_enum(batch, value,
"stance", path, stances);
1211 for (
const auto& key : {
"quote_language",
"quote_translation" }) {
1212 if (value.contains(key)) {
1213 require_nonempty_string(batch, value, key, path);
1216 if (value.contains(
"locator_json")) {
1217 require_nonempty_string(batch, value,
"locator_json", path);
1218 if (value[
"locator_json"].is_string()) {
1220 const json parsed = json::parse(
1221 value[
"locator_json"].get<std::string>()
1223 static_cast<
void>(parsed);
1224 }
catch (
const json::exception&) {
1226 batch,
"invalid_json_text", path +
"/locator_json",
1227 "locator_json must contain valid JSON",
1228 &value[
"locator_json"]
1236 parsed_batch& batch,
const json& value,
const std::string& path
1240 {
"work_id",
"agent_id",
"role",
"credit_order",
"importance",
1242 {
"work_id",
"agent_id",
"role",
"importance" }
1244 if (!value.is_object()) {
1247 validate_entity_reference_shape(batch, value,
"work_id", path);
1248 validate_entity_reference_shape(batch, value,
"agent_id", path);
1249 require_enum(batch, value,
"role", path, credit_roles);
1250 require_enum(batch, value,
"importance", path, importance_values);
1251 require_integer_range(
1252 batch, value,
"credit_order", path, 0,
1253 std::numeric_limits<std::int64_t>::max()
1255 if (value.contains(
"credited_as")) {
1256 require_nonempty_string(batch, value,
"credited_as", path);
1261 parsed_batch& batch,
const json& value,
const std::string& path
1265 {
"entity_id",
"measurement_type",
"value",
"unit",
"qualifier" },
1266 {
"entity_id",
"measurement_type",
"value",
"unit" }
1268 if (!value.is_object()) {
1271 validate_entity_reference_shape(batch, value,
"entity_id", path);
1273 batch, value,
"measurement_type", path, measurement_types
1275 require_number_range(
1276 batch, value,
"value", path, 0.0,
1277 std::numeric_limits<
double>::max()
1279 require_enum(batch, value,
"unit", path, measurement_units);
1280 if (value.contains(
"qualifier")) {
1281 require_nonempty_string(batch, value,
"qualifier", path);
1286 parsed_batch& batch,
const json& value,
const std::string& path
1290 {
"work_id",
"fact_type",
"amount_min",
"amount_max",
1291 "currency_code",
"value_year",
"is_estimate",
"confidence" },
1292 {
"work_id",
"fact_type",
"amount_min",
"currency_code",
1295 if (!value.is_object()) {
1298 validate_entity_reference_shape(batch, value,
"work_id", path);
1300 batch, value,
"fact_type", path,
1301 std::set<std::string, std::less<>> {
"budget" }
1303 require_integer_range(
1304 batch, value,
"amount_min", path, 0,
1305 std::numeric_limits<std::int64_t>::max()
1307 require_integer_range(
1308 batch, value,
"amount_max", path, 0,
1309 std::numeric_limits<std::int64_t>::max()
1311 if (value.contains(
"amount_min") && value[
"amount_min"].is_number_integer()
1312 && value.contains(
"amount_max")
1313 && value[
"amount_max"].is_number_integer()
1314 && value[
"amount_max"].get<std::int64_t>()
1315 < value[
"amount_min"].get<std::int64_t>()) {
1317 batch,
"invalid_range", path +
"/amount_max",
1318 "amount_max must not be less than amount_min",
1319 &value[
"amount_max"]
1322 require_nonempty_string(batch, value,
"currency_code", path);
1323 if (value.contains(
"currency_code") && value[
"currency_code"].is_string()) {
1324 const std::string& currency
1325 = value[
"currency_code"].get_ref<
const std::string&>();
1326 if (currency.size() != 3U
1327 || !std::ranges::all_of(currency, [](
const char character) {
1328 return character >=
'A' && character <=
'Z';
1331 batch,
"invalid_currency_code", path +
"/currency_code",
1332 "currency_code must contain exactly three uppercase letters",
1333 &value[
"currency_code"]
1337 require_integer_range(
1338 batch, value,
"value_year", path, -9999, 9999
1341 require_number_range(batch, value,
"confidence", path, 0.0, 1.0);
1345 parsed_batch& batch,
const json& value,
const std::string& path
1347 const auto found = value.find(
"evidence");
1348 if (found == value.end()) {
1351 if (!found->is_array()) {
1353 batch,
"type_mismatch", path +
"/evidence",
1354 "evidence must be an array", &*found
1358 if (found->empty()) {
1360 batch,
"evidence_required", path +
"/evidence",
1361 "semantic assertion needs at least one evidence reference", &*found
1364 std::set<std::string, std::less<>> seen;
1365 for (std::size_t index = 0; index < found->size(); ++index) {
1366 const auto& reference = (*found)[index];
1367 const std::string identity = reference.dump();
1368 if (!seen.emplace(identity).second) {
1370 batch,
"duplicate_evidence_reference",
1371 indexed_path(path +
"/evidence", index),
1372 "evidence reference occurs more than once", &reference
1375 if (reference.is_string()) {
1376 if (reference.get_ref<
const std::string&>().empty()) {
1378 batch,
"empty_string",
1379 indexed_path(path +
"/evidence", index),
1380 "local evidence reference must not be empty", &reference
1383 }
else if (!(reference.is_number_integer()
1384 || reference.is_number_unsigned())) {
1386 batch,
"type_mismatch",
1387 indexed_path(path +
"/evidence", index),
1388 "evidence reference must be a positive integer or local_id",
1393 if (reference.get<std::int64_t>() <= 0) {
1395 batch,
"number_out_of_range",
1396 indexed_path(path +
"/evidence", index),
1397 "evidence row reference must be positive", &reference
1400 }
catch (
const json::exception&) {
1402 batch,
"number_out_of_range",
1403 indexed_path(path +
"/evidence", index),
1404 "evidence row reference is outside SQLite's range",
1413 parsed_batch& batch,
const json& value,
const std::string& path,
1414 std::unordered_map<std::string, std::string>& locals
1418 {
"local_id",
"work_id",
"concept_id",
"relation_type",
"centrality",
1419 "historical_role",
"confidence",
"evidence" },
1420 {
"local_id",
"work_id",
"concept_id",
"relation_type",
"centrality",
1423 if (!value.is_object()) {
1426 validate_local_id(batch, value, path, locals,
"work_concept");
1427 validate_entity_reference_shape(batch, value,
"work_id", path);
1428 validate_entity_reference_shape(batch, value,
"concept_id", path);
1430 batch, value,
"relation_type", path, work_concept_types
1432 require_integer_range(batch, value,
"centrality", path, 1, 100);
1433 if (value.contains(
"historical_role")) {
1435 batch, value,
"historical_role", path, historical_roles
1438 require_number_range(batch, value,
"confidence", path, 0.0, 1.0);
1439 validate_evidence_list(batch, value, path);
1443 parsed_batch& batch,
const json& value,
const std::string& path,
1444 std::unordered_map<std::string, std::string>& locals
1448 {
"local_id",
"subject_concept_id",
"relation_type",
1449 "object_concept_id",
"strength",
1450 "from_year",
"to_year",
"region_code",
"confidence",
"evidence" },
1451 {
"local_id",
"subject_concept_id",
"relation_type",
1452 "object_concept_id",
"evidence" }
1454 if (!value.is_object()) {
1457 validate_local_id(batch, value, path, locals,
"concept_relation");
1458 validate_entity_reference_shape(
1459 batch, value,
"subject_concept_id", path
1461 validate_entity_reference_shape(
1462 batch, value,
"object_concept_id", path
1465 batch, value,
"relation_type", path, concept_relation_types
1467 require_integer_range(batch, value,
"strength", path, 1, 100);
1468 require_integer_range(batch, value,
"from_year", path, -9999, 9999);
1469 require_integer_range(batch, value,
"to_year", path, -9999, 9999);
1470 if (value.contains(
"from_year") && value[
"from_year"].is_number_integer()
1471 && value.contains(
"to_year") && value[
"to_year"].is_number_integer()
1472 && value[
"to_year"].get<std::int64_t>()
1473 < value[
"from_year"].get<std::int64_t>()) {
1475 batch,
"invalid_range", path +
"/to_year",
1476 "to_year must not be earlier than from_year", &value[
"to_year"]
1479 if (value.contains(
"region_code")) {
1480 require_nonempty_string(batch, value,
"region_code", path);
1482 require_number_range(batch, value,
"confidence", path, 0.0, 1.0);
1483 validate_evidence_list(batch, value, path);
1487 parsed_batch& batch,
const json& value,
const std::string& path,
1488 std::unordered_map<std::string, std::string>& locals
1492 {
"local_id",
"work_id",
"concept_id",
"category",
"intensity",
1493 "explicitness",
"frequency",
"centrality",
"realism",
1494 "spoiler_level",
"confidence",
"evidence" },
1495 {
"local_id",
"work_id",
"concept_id",
"category",
"intensity",
1496 "explicitness",
"frequency",
"centrality",
"realism",
1497 "spoiler_level",
"evidence" }
1499 if (!value.is_object()) {
1502 validate_local_id(batch, value, path, locals,
"parent_guide");
1503 validate_entity_reference_shape(batch, value,
"work_id", path);
1504 validate_entity_reference_shape(batch, value,
"concept_id", path);
1505 require_enum(batch, value,
"category", path, guide_categories);
1506 for (
const auto& key : {
1507 "intensity",
"explicitness",
"frequency",
"centrality",
1509 require_integer_range(batch, value, key, path, 1, 5);
1511 require_enum(batch, value,
"spoiler_level", path, spoiler_levels);
1512 require_number_range(batch, value,
"confidence", path, 0.0, 1.0);
1513 validate_evidence_list(batch, value, path);
1517 parsed_batch&,
const json&,
const std::string&
1520template <
typename Validator>
1522 parsed_batch& batch,
const json& parent,
const std::string& key,
1523 const std::string& parent_path, Validator&& validator
1525 const auto found = parent.find(key);
1526 if (found == parent.end()) {
1529 const std::string path = parent_path +
"/" + pointer_escape(key);
1530 if (!found->is_array()) {
1532 batch,
"type_mismatch", path,
"operation collection must be an array",
1537 for (std::size_t index = 0; index < found->size(); ++index) {
1538 validator(batch, (*found)[index], indexed_path(path, index));
1543 parsed_batch& batch,
const json& value,
const std::string& path,
1544 const std::string_view family,
1545 const std::map<std::string, value_kind, std::less<>>& mutable_fields,
1546 const std::set<std::string, std::less<>>& required_fields,
1552 batch, value, path, {
"id",
"set",
"unset" },
1553 {
"id",
"set",
"unset" }
1555 if (!value.is_object()) {
1559 require_integer_range(
1560 batch, value,
"id", path, 1,
1561 std::numeric_limits<std::int64_t>::max()
1564 require_nonempty_string(batch, value,
"id", path);
1565 const auto id = value.find(
"id");
1566 if (id != value.end() && id->is_string()
1567 && !valid_canonical_id(id->get_ref<
const std::string&>(), family)) {
1569 batch,
"invalid_canonical_id", path +
"/id",
1570 "id does not identify the required entity family", &*id
1576 std::set<std::string, std::less<>> set_names;
1577 const auto set = value.find(
"set");
1578 if (set != value.end() && set->is_object()) {
1579 for (
const auto& [field, field_value] : set->items()) {
1580 const auto expected = mutable_fields.find(field);
1581 const std::string field_path
1582 = path +
"/set/" + pointer_escape(field);
1583 if (expected == mutable_fields.end()) {
1585 batch,
"unknown_field", field_path,
1586 "field is not mutable for this entity family", &field_value
1590 set_names.emplace(field);
1591 if (field_value.is_null()) {
1593 batch,
"ambiguous_null", field_path,
1594 "use unset to remove a field", &field_value
1596 }
else if (!is_kind(field_value, expected->second)) {
1598 batch,
"type_mismatch", field_path,
1599 "field has the wrong JSON type", &field_value
1602 if (field_value.is_string()
1603 && field_value.get_ref<
const std::string&>().empty()) {
1605 batch,
"empty_string", field_path,
1606 "set value must not be empty", &field_value
1609 if (field ==
"slug" && field_value.is_string()
1611 field_value.get_ref<
const std::string&>()
1614 batch,
"invalid_slug", field_path,
1615 "slug must contain lowercase alphanumeric dash-separated tokens",
1619 if (field ==
"work_id" && field_value.is_string()
1620 && !valid_canonical_id(
1621 field_value.get_ref<
const std::string&>(),
"work"
1624 batch,
"invalid_canonical_id", field_path,
1625 "updated work_id must be an existing canonical work ID",
1629 if (field ==
"production_info_json" && field_value.is_string()) {
1631 const json parsed = json::parse(
1632 field_value.get<std::string>()
1634 static_cast<
void>(parsed);
1635 }
catch (
const json::exception&) {
1637 batch,
"invalid_json_text", field_path,
1638 "production_info_json must contain valid JSON",
1643 const auto enum_rule = enum_fields.find(field);
1644 if (enum_rule != enum_fields.end() && field_value.is_string()
1645 && !enum_rule->second.contains(field_value.get<std::string>())) {
1647 batch,
"unknown_enum", field_path,
1648 "field contains an unknown enum value", &field_value
1653 const auto unset = value.find(
"unset");
1654 std::set<std::string, std::less<>> unset_names;
1655 if (unset != value.end() && unset->is_array()) {
1656 for (std::size_t index = 0; index < unset->size(); ++index) {
1657 const auto& field = (*unset)[index];
1658 const std::string field_path
1659 = indexed_path(path +
"/unset", index);
1660 if (!field.is_string()) {
1662 batch,
"type_mismatch", field_path,
1663 "unset entry must be a mutable field name", &field
1667 const std::string name = field.get<std::string>();
1668 if (!mutable_fields.contains(name)) {
1670 batch,
"unknown_field", field_path,
1671 "field is not mutable for this entity family", &field
1673 }
else if (required_fields.contains(name)) {
1675 batch,
"required_field_unset", field_path,
1676 "required field cannot be unset", &field
1679 if (!unset_names.emplace(name).second) {
1681 batch,
"duplicate_unset", field_path,
1682 "field occurs more than once in unset", &field
1685 if (set_names.contains(name)) {
1687 batch,
"set_unset_conflict", field_path,
1688 "field cannot be set and unset in the same operation", &field
1693 if (!allow_empty && set_names.empty() && unset_names.empty()) {
1695 batch,
"empty_update", path,
1696 "update must set or unset at least one field", &value
1760 {
"medium",
media },
1780 parsed_batch& batch,
const json& update,
const std::string& path
1782 const auto found = update.find(
"delete");
1783 if (found == update.end()) {
1786 const std::set<std::string, std::less<>> tables {
1787 "names",
"external_ids",
"credits",
"measurements",
1788 "financial_facts",
"evidence",
"work_concepts",
"concept_relations",
1789 "parent_guide_assertions",
"ingest_issues"
1791 check_keys(batch, *found, path +
"/delete", tables);
1792 if (!found->is_object()) {
1795 for (
const auto& table : tables) {
1796 const auto rows = found->find(table);
1797 if (rows == found->end()) {
1800 const std::string rows_path
1801 = path +
"/delete/" + pointer_escape(table);
1802 if (!rows->is_array()) {
1804 batch,
"type_mismatch", rows_path,
1805 "delete collection must be an array", &*rows
1809 if (table ==
"ingest_issues") {
1810 std::set<std::string, std::less<>> seen;
1811 for (std::size_t index = 0; index < rows->size(); ++index) {
1812 const auto& value = (*rows)[index];
1813 const std::string value_path = indexed_path(rows_path, index);
1815 batch, value, value_path,
1816 {
"batch_id",
"code",
"json_path" },
1817 {
"batch_id",
"code",
"json_path" }
1819 if (!value.is_object()) {
1822 require_nonempty_string(
1823 batch, value,
"batch_id", value_path
1825 require_nonempty_string(batch, value,
"code", value_path);
1826 require_nonempty_string(
1827 batch, value,
"json_path", value_path
1829 if (
const auto batch_id = value.find(
"batch_id");
1830 batch_id != value.end() && batch_id->is_string()
1831 && !valid_batch_id(batch_id->get_ref<
const std::string&>())) {
1833 batch,
"invalid_batch_id",
1834 value_path +
"/batch_id",
1835 "issue batch_id must be a stable identifier", &*batch_id
1838 if (
const auto json_path = value.find(
"json_path");
1839 json_path != value.end() && json_path->is_string()
1840 && !json_path->get_ref<
const std::string&>().starts_with(
1844 batch,
"invalid_json_pointer",
1845 value_path +
"/json_path",
1846 "JSON Pointer must start with '/'", &*json_path
1849 if (!seen.emplace(value.dump()).second) {
1851 batch,
"duplicate_delete", value_path,
1852 "ingest issue key occurs more than once", &value
1858 std::set<std::int64_t> seen;
1859 for (std::size_t index = 0; index < rows->size(); ++index) {
1860 const auto& value = (*rows)[index];
1861 const std::string value_path = indexed_path(rows_path, index);
1862 if (!(value.is_number_integer() || value.is_number_unsigned())) {
1864 batch,
"type_mismatch", value_path,
1865 "row ID must be a positive integer", &value
1870 const std::int64_t id = value.get<std::int64_t>();
1873 batch,
"number_out_of_range", value_path,
1874 "row ID must be positive", &value
1876 }
else if (!seen.emplace(id).second) {
1878 batch,
"duplicate_delete", value_path,
1879 "row ID occurs more than once", &value
1882 }
catch (
const json::exception&) {
1884 batch,
"number_out_of_range", value_path,
1885 "row ID is outside SQLite's range", &value
1893 parsed_batch& batch,
const json& value,
const std::string& path,
1894 const std::string_view family,
1895 const std::map<std::string, value_kind, std::less<>>& mutable_fields,
1896 const std::set<std::string, std::less<>>& required_fields,
1902 batch, value, path, {
"target",
"members",
"set",
"unset" },
1903 {
"target",
"members",
"set",
"unset" }
1905 if (!value.is_object()) {
1908 require_nonempty_string(batch, value,
"target", path);
1912 const auto target = value.find(
"target");
1913 std::string target_id;
1914 if (target != value.end() && target->is_string()) {
1915 target_id = target->get<std::string>();
1916 if (!valid_canonical_id(target_id, family)) {
1918 batch,
"invalid_canonical_id", path +
"/target",
1919 "target does not identify the required entity family", &*target
1922 const auto [position, inserted] = merged_entities.emplace(
1927 batch,
"conflicting_merge", path +
"/target",
1928 "entity already participates in merge " + position->second,
1933 const auto members = value.find(
"members");
1934 std::set<std::string, std::less<>> local_members;
1935 if (members != value.end() && members->is_array()) {
1936 if (members->empty()) {
1938 batch,
"members_required", path +
"/members",
1939 "merge needs at least one member", &*members
1942 for (std::size_t index = 0; index < members->size(); ++index) {
1943 const auto& member = (*members)[index];
1944 const std::string member_path
1945 = indexed_path(path +
"/members", index);
1946 if (!member.is_string()
1947 || !valid_canonical_id(
1948 member.is_string() ? member.get_ref<
const std::string&>()
1949 : std::string_view(),
1953 batch,
"invalid_canonical_id", member_path,
1954 "member does not identify the required entity family",
1959 const std::string member_id = member.get<std::string>();
1960 if (member_id == target_id) {
1962 batch,
"target_is_member", member_path,
1963 "merge target cannot also be a member", &member
1966 if (!local_members.emplace(member_id).second) {
1968 batch,
"duplicate_merge_member", member_path,
1969 "merge member occurs more than once", &member
1972 const auto [position, inserted] = merged_entities.emplace(
1977 batch,
"conflicting_merge", member_path,
1978 "entity already participates in merge " + position->second,
1985 {
"id", target_id },
1986 {
"set", value.value(
"set", json::object()) },
1987 {
"unset", value.value(
"unset", json::array()) },
1989 validate_update_record(
1990 batch, update, path, family, mutable_fields, required_fields,
1996 if (!batch.document.is_object()) {
1998 batch,
"root_type",
"/",
"batch root must be an object",
2004 batch, batch.document,
"",
2005 {
"format",
"batch_id",
"create",
"update",
"merge" },
2006 {
"format",
"batch_id",
"create",
"update",
"merge" }
2011 if (batch.document.contains(
"format")
2012 && batch.document[
"format"].is_string()
2013 && batch.document[
"format"] !=
"arachne_batch_v2") {
2015 batch,
"unsupported_format",
"/format",
2016 "format must be arachne_batch_v2", &batch.document[
"format"]
2022 if (batch.document.contains(
"batch_id")
2023 && batch.document[
"batch_id"].is_string()) {
2024 batch
.batch_id = batch.document[
"batch_id"].get<std::string>();
2025 for (
auto& issue : batch.issues) {
2026 issue.batch_id = batch.batch_id;
2028 if (!valid_batch_id(batch.batch_id)) {
2030 batch,
"invalid_batch_id",
"/batch_id",
2031 "batch_id must contain 1-128 letters, digits, dot, dash, or underscore",
2032 &batch.document[
"batch_id"]
2036 for (
const auto& section : {
"create",
"update",
"merge" }) {
2038 batch, batch.document, section,
"", value_kind::object
2041 if (!(batch.document.contains(
"create")
2042 && batch.document[
"create"].is_object()
2043 && batch.document.contains(
"update")
2044 && batch.document[
"update"].is_object()
2045 && batch.document.contains(
"merge")
2046 && batch.document[
"merge"].is_object())) {
2050 auto& create = batch.document[
"create"];
2051 const std::set<std::string, std::less<>> create_keys {
2052 "agents",
"works",
"concepts",
"manifestations",
"names",
2053 "external_ids",
"sources",
"evidence",
"credits",
"measurements",
2054 "financial_facts",
"work_concepts",
"concept_relations",
2055 "parent_guide_assertions"
2057 check_keys(batch, create,
"/create", create_keys);
2058 auto& locals = batch.entity_local_ids;
2060 batch, create,
"agents",
"/create",
2062 parsed_batch& target,
const json& value,
const std::string& path
2063 ) { validate_create_agent(target, value, path, locals); }
2066 batch, create,
"works",
"/create",
2068 parsed_batch& target,
const json& value,
const std::string& path
2069 ) { validate_create_work(target, value, path, locals); }
2072 batch, create,
"concepts",
"/create",
2074 parsed_batch& target,
const json& value,
const std::string& path
2075 ) { validate_create_concept(target, value, path, locals); }
2078 batch, create,
"manifestations",
"/create",
2080 parsed_batch& target,
const json& value,
const std::string& path
2081 ) { validate_create_manifestation(target, value, path, locals); }
2083 validate_array(batch, create,
"names",
"/create", validate_create_name);
2085 batch, create,
"external_ids",
"/create",
2086 validate_create_external_id
2089 batch, create,
"sources",
"/create",
2091 parsed_batch& target,
const json& value,
const std::string& path
2092 ) { validate_create_source(target, value, path, locals); }
2095 batch, create,
"evidence",
"/create",
2097 parsed_batch& target,
const json& value,
const std::string& path
2098 ) { validate_create_evidence(target, value, path, locals); }
2101 batch, create,
"credits",
"/create", validate_create_credit
2104 batch, create,
"measurements",
"/create",
2105 validate_create_measurement
2108 batch, create,
"financial_facts",
"/create",
2109 validate_create_financial
2112 batch, create,
"work_concepts",
"/create",
2114 parsed_batch& target,
const json& value,
const std::string& path
2115 ) { validate_create_work_concept(target, value, path, locals); }
2118 batch, create,
"concept_relations",
"/create",
2120 parsed_batch& target,
const json& value,
const std::string& path
2121 ) { validate_create_concept_relation(target, value, path, locals); }
2124 batch, create,
"parent_guide_assertions",
"/create",
2126 parsed_batch& target,
const json& value,
const std::string& path
2127 ) { validate_create_parent_guide(target, value, path, locals); }
2130 auto& update = batch.document[
"update"];
2131 const std::set<std::string, std::less<>> update_keys {
2132 "agents",
"works",
"concepts",
"manifestations",
"sources",
"delete"
2134 check_keys(batch, update,
"/update", update_keys);
2136 batch, update,
"agents",
"/update",
2137 [](parsed_batch& target,
const json& value,
const std::string& path) {
2138 validate_update_record(
2139 target, value, path,
"agent", agent_mutable, {},
2145 batch, update,
"works",
"/update",
2146 [](parsed_batch& target,
const json& value,
const std::string& path) {
2147 validate_update_record(
2148 target, value, path,
"work", work_mutable, {
"medium" },
2154 batch, update,
"concepts",
"/update",
2155 [](parsed_batch& target,
const json& value,
const std::string& path) {
2156 validate_update_record(
2157 target, value, path,
"concept", concept_mutable,
2158 {
"concept_type",
"slug" }, concept_update_enums
2163 batch, update,
"manifestations",
"/update",
2164 [](parsed_batch& target,
const json& value,
const std::string& path) {
2165 validate_update_record(
2166 target, value, path,
"manifestation",
2167 manifestation_mutable,
2168 {
"work_id",
"manifestation_type",
"label" },
2169 manifestation_update_enums
2174 batch, update,
"sources",
"/update",
2175 [](parsed_batch& target,
const json& value,
const std::string& path) {
2176 validate_update_record(
2177 target, value, path,
"source", source_mutable,
2178 {
"source_type" }, source_update_enums,
false,
true
2182 validate_delete_object(batch, update,
"/update");
2183 for (
const auto collection : {
2184 "agents",
"works",
"concepts",
"manifestations",
"sources" }) {
2185 const auto rows = update.find(collection);
2186 if (rows == update.end() || !rows->is_array()) {
2189 std::set<std::string, std::less<>> targets;
2190 for (std::size_t index = 0; index < rows->size(); ++index) {
2191 const auto& row = (*rows)[index];
2192 const auto id = row.is_object() ? row.find(
"id") : row.end();
2194 || (!id->is_string() && !id->is_number_integer()
2195 && !id->is_number_unsigned())) {
2198 const std::string identity = id->dump();
2199 if (!targets.emplace(identity).second) {
2201 batch,
"duplicate_update_target",
2203 "/update/" + std::string(collection), index
2206 "a canonical record may be updated at most once per batch",
2213 auto& merge = batch.document[
"merge"];
2214 check_keys(batch, merge,
"/merge", {
"agents",
"works",
"concepts" });
2215 std::map<std::string, std::string, std::less<>> merged_entities;
2217 batch, merge,
"agents",
"/merge",
2219 parsed_batch& target,
const json& value,
const std::string& path
2221 validate_merge_record(
2222 target, value, path,
"agent", agent_merge_mutable,
2223 {
"agent_type" }, agent_merge_enums, merged_entities
2228 batch, merge,
"works",
"/merge",
2230 parsed_batch& target,
const json& value,
const std::string& path
2232 validate_merge_record(
2233 target, value, path,
"work", work_mutable, {
"medium" },
2234 work_update_enums, merged_entities
2239 batch, merge,
"concepts",
"/merge",
2241 parsed_batch& target,
const json& value,
const std::string& path
2243 validate_merge_record(
2244 target, value, path,
"concept", concept_mutable,
2245 {
"concept_type",
"slug" }, concept_update_enums,
2251 for (
const auto& family : {
2252 std::pair {
"agents", std::string_view(
"agent") },
2253 std::pair {
"works", std::string_view(
"work") },
2254 std::pair {
"concepts", std::string_view(
"concept") },
2255 std::pair {
"manifestations", std::string_view(
"manifestation") } }) {
2256 const auto updates = update.find(family.first);
2257 if (updates == update.end() || !updates->is_array()) {
2260 for (std::size_t index = 0; index < updates->size(); ++index) {
2261 const auto& operation = (*updates)[index];
2262 if (!operation.is_object() || !operation.contains(
"id")
2263 || !operation[
"id"].is_string()) {
2266 const std::string id = operation[
"id"].get<std::string>();
2267 const auto conflict = merged_entities.find(id);
2268 if (conflict != merged_entities.end()) {
2270 batch,
"conflicting_merge_update",
2272 "/update/" + std::string(family.first), index
2275 "entity is also modified by " + conflict->second,
2284 if (bytes.size() >= 3U
2285 &&
static_cast<
unsigned char>(bytes[0]) == 0xEFU
2286 &&
static_cast<
unsigned char>(bytes[1]) == 0xBBU
2287 &&
static_cast<
unsigned char>(bytes[2]) == 0xBFU) {
2290 std::vector<std::set<std::string, std::less<>>> object_keys;
2291 json::parser_callback_t callback
2293 const int depth,
const json::parse_event_t event, json& parsed
2295 static_cast<
void>(depth);
2296 if (event == json::parse_event_t::object_start) {
2297 object_keys.emplace_back();
2298 }
else if (event == json::parse_event_t::key) {
2299 if (object_keys.empty()) {
2302 const std::string key = parsed.get<std::string>();
2303 if (!object_keys.back().emplace(key).second) {
2304 throw inbox_error(
"duplicate JSON object key: " + key);
2306 }
else if (event == json::parse_event_t::object_end) {
2307 if (object_keys.empty()) {
2310 object_keys.pop_back();
2315 return json::parse(bytes, callback,
true,
false);
2318 }
catch (
const json::exception& error) {
2319 throw inbox_error(std::string(
"invalid UTF-8 JSON: ") + error.what());
2324 sqlite3*
const database_value,
const std::string_view table,
2325 const std::string_view column,
const json& id
2327 const std::string sql =
"SELECT 1 FROM " + std::string(table) +
" WHERE "
2328 + std::string(column) +
" = ? LIMIT 1";
2329 statement query(database_value, sql);
2330 query.bind_json_value(1, id);
2335 sqlite3*
const database_value,
const std::string& id
2339 "SELECT entity_type FROM entities WHERE id = ?"
2345 const std::string type = query
.text(0
);
2346 if (type ==
"person" || type ==
"organization" || type ==
"group") {
2353 return record.at(key).get<std::string>();
2357 parsed_batch& batch, sqlite3*
const database_value,
2358 const json& record,
const std::string& key,
const std::string& path,
2359 const std::string_view expected_family = {}
2361 const auto found = record.find(key);
2362 if (found == record.end() || !found->is_string()) {
2365 const std::string& reference = found->get_ref<
const std::string&>();
2366 const auto local = batch.entity_local_ids.find(reference);
2367 std::string actual_family;
2368 if (local != batch.entity_local_ids.end()) {
2369 actual_family = local->second;
2370 if (actual_family ==
"source" || actual_family ==
"evidence"
2371 || actual_family ==
"work_concept"
2372 || actual_family ==
"concept_relation"
2373 || actual_family ==
"parent_guide") {
2375 batch,
"wrong_reference_family",
2376 path +
"/" + pointer_escape(key),
2377 "reference is not a canonical entity", &*found
2383 if (actual_family.empty()) {
2385 batch,
"unknown_reference", path +
"/" + pointer_escape(key),
2386 "canonical entity or local_id does not exist", &*found
2391 if (!expected_family.empty() && actual_family != expected_family) {
2393 batch,
"wrong_reference_family",
2394 path +
"/" + pointer_escape(key),
2395 "reference must identify a " + std::string(expected_family),
2402 parsed_batch& batch, sqlite3*
const database_value,
2403 const json& record,
const std::string& key,
const std::string& path,
2404 const std::string_view local_family,
const std::string_view table
2406 const auto found = record.find(key);
2407 if (found == record.end()) {
2410 if (found->is_string()) {
2411 const auto local = batch.entity_local_ids.find(found->get<std::string>());
2412 if (local == batch.entity_local_ids.end()) {
2414 batch,
"unknown_local_reference",
2415 path +
"/" + pointer_escape(key),
"local_id does not exist",
2418 }
else if (local->second != local_family) {
2420 batch,
"wrong_reference_family",
2421 path +
"/" + pointer_escape(key),
2422 "local_id has the wrong record family", &*found
2425 }
else if ((found->is_number_integer() || found->is_number_unsigned())
2426 && !row_exists(database_value, table,
"id", *found)) {
2428 batch,
"unknown_reference", path +
"/" + pointer_escape(key),
2429 "canonical database row does not exist", &*found
2436 const auto& create = batch.document.at(
"create");
2437 auto walk = [&batch, &create](
2438 const std::string& key,
const auto& callback
2440 const auto found = create.find(key);
2441 if (found == create.end() || !found->is_array()) {
2444 for (std::size_t index = 0; index < found->size(); ++index) {
2445 callback((*found)[index], indexed_path(
"/create/" + key, index));
2448 walk(
"manifestations", [&](
const json& row,
const std::string& path) {
2449 prevalidate_entity_reference(
2450 batch, sql, row,
"work_id", path,
"work"
2453 walk(
"names", [&](
const json& row,
const std::string& path) {
2454 prevalidate_entity_reference(batch, sql, row,
"entity_id", path);
2456 walk(
"external_ids", [&](
const json& row,
const std::string& path) {
2457 prevalidate_entity_reference(batch, sql, row,
"entity_id", path);
2459 walk(
"evidence", [&](
const json& row,
const std::string& path) {
2460 prevalidate_integer_or_local_reference(
2461 batch, sql, row,
"source_id", path,
"source",
"sources"
2464 walk(
"credits", [&](
const json& row,
const std::string& path) {
2465 prevalidate_entity_reference(
2466 batch, sql, row,
"work_id", path,
"work"
2468 prevalidate_entity_reference(
2469 batch, sql, row,
"agent_id", path,
"agent"
2472 walk(
"measurements", [&](
const json& row,
const std::string& path) {
2473 prevalidate_entity_reference(batch, sql, row,
"entity_id", path);
2475 walk(
"financial_facts", [&](
const json& row,
const std::string& path) {
2476 prevalidate_entity_reference(
2477 batch, sql, row,
"work_id", path,
"work"
2480 auto assertion_evidence = [&](
const json& row,
const std::string& path) {
2481 const auto values = row.find(
"evidence");
2482 if (values == row.end() || !values->is_array()) {
2485 for (std::size_t index = 0; index < values->size(); ++index) {
2486 const auto& reference = (*values)[index];
2487 json wrapper { {
"evidence_id", reference } };
2488 prevalidate_integer_or_local_reference(
2489 batch, sql, wrapper,
"evidence_id",
2490 indexed_path(path +
"/evidence", index),
"evidence",
"evidence"
2494 walk(
"work_concepts", [&](
const json& row,
const std::string& path) {
2495 prevalidate_entity_reference(
2496 batch, sql, row,
"work_id", path,
"work"
2498 prevalidate_entity_reference(
2499 batch, sql, row,
"concept_id", path,
"concept"
2501 assertion_evidence(row, path);
2503 walk(
"concept_relations", [&](
const json& row,
const std::string& path) {
2504 prevalidate_entity_reference(
2505 batch, sql, row,
"subject_concept_id", path,
"concept"
2507 prevalidate_entity_reference(
2508 batch, sql, row,
"object_concept_id", path,
"concept"
2510 assertion_evidence(row, path);
2513 "parent_guide_assertions",
2514 [&](
const json& row,
const std::string& path) {
2515 prevalidate_entity_reference(
2516 batch, sql, row,
"work_id", path,
"work"
2518 prevalidate_entity_reference(
2519 batch, sql, row,
"concept_id", path,
"concept"
2521 assertion_evidence(row, path);
2525 const auto& update = batch.document.at(
"update");
2526 for (
const auto& [collection, family] : {
2527 std::pair {
"agents",
"agent" },
2528 std::pair {
"works",
"work" },
2529 std::pair {
"concepts",
"concept" },
2530 std::pair {
"manifestations",
"manifestation" } }) {
2531 const auto rows = update.find(collection);
2532 if (rows == update.end() || !rows->is_array()) {
2535 for (std::size_t index = 0; index < rows->size(); ++index) {
2536 const auto& row = (*rows)[index];
2537 if (!row.is_object() || !row.contains(
"id")
2538 || !row[
"id"].is_string()) {
2541 const std::string id = row[
"id"].get<std::string>();
2542 if (entity_family(sql, id) != family) {
2544 batch,
"unknown_canonical_id",
2545 indexed_path(
"/update/" + std::string(collection), index)
2547 "canonical entity does not exist in the required family",
2551 if (family == std::string_view(
"manifestation")
2552 && row.contains(
"set") && row[
"set"].is_object()
2553 && row[
"set"].contains(
"work_id")) {
2554 prevalidate_entity_reference(
2555 batch, sql, row[
"set"],
"work_id",
2557 "/update/" + std::string(collection), index
2565 const auto source_updates = update.find(
"sources");
2566 if (source_updates != update.end() && source_updates->is_array()) {
2567 for (std::size_t index = 0; index < source_updates->size(); ++index) {
2568 const auto& row = (*source_updates)[index];
2569 if (row.is_object() && row.contains(
"id")
2570 && (row[
"id"].is_number_integer()
2571 || row[
"id"].is_number_unsigned())
2572 && !row_exists(sql,
"sources",
"id", row[
"id"])) {
2574 batch,
"unknown_canonical_id",
2575 indexed_path(
"/update/sources", index) +
"/id",
2576 "source does not exist", &row[
"id"]
2579 if (!row.is_object() || !row.contains(
"id")
2580 || !(row[
"id"].is_number_integer()
2581 || row[
"id"].is_number_unsigned())) {
2586 "SELECT bibliography_text,url,doi,isbn FROM sources WHERE id=?"
2588 identity.bind_json_value(1, row[
"id"]);
2592 std::map<std::string,
bool, std::less<>> present {
2593 {
"bibliography_text", !identity
.is_null(0
) },
2598 if (row.contains(
"set") && row[
"set"].is_object()) {
2599 for (
const auto& [field, value] : row[
"set"].items()) {
2600 if (present.contains(field)) {
2601 present[field] = !value.is_null();
2605 if (row.contains(
"unset") && row[
"unset"].is_array()) {
2606 for (
const auto& field : row[
"unset"]) {
2607 if (field.is_string()
2608 && present.contains(field.get<std::string>())) {
2609 present[field.get<std::string>()] =
false;
2613 if (std::ranges::none_of(
2615 [](
const auto& item) {
return item.second; }
2618 batch,
"source_identity_required",
2619 indexed_path(
"/update/sources", index),
2620 "source update would remove every strong or fallback identity",
2626 const auto deletes = update.find(
"delete");
2627 if (deletes != update.end() && deletes->is_object()) {
2628 for (
const auto& [table, values] : deletes->items()) {
2629 if (!values.is_array()) {
2632 if (table ==
"ingest_issues") {
2635 "SELECT status FROM ingest_issues "
2636 "WHERE batch_id=? AND code=? AND json_path=?"
2638 for (std::size_t index = 0; index < values.size(); ++index) {
2639 const auto& value = values[index];
2640 if (!value.is_object() || !value.contains(
"batch_id")
2641 || !value.contains(
"code")
2642 || !value.contains(
"json_path")) {
2645 sqlite3_reset(issue.native());
2646 sqlite3_clear_bindings(issue.native());
2647 issue.bind_json_value(1, value.at(
"batch_id"));
2648 issue.bind_json_value(2, value.at(
"code"));
2649 issue.bind_json_value(3, value.at(
"json_path"));
2650 const std::string value_path = indexed_path(
2651 "/update/delete/ingest_issues", index
2653 if (!issue.step()) {
2655 batch,
"unknown_delete_row", value_path,
2656 "ingest issue does not exist", &value
2658 }
else if (issue.text(0) ==
"open") {
2660 batch,
"open_issue_delete_forbidden", value_path,
2661 "only resolved or ignored ingest issues may be "
2669 for (std::size_t index = 0; index < values.size(); ++index) {
2670 if ((values[index].is_number_integer()
2671 || values[index].is_number_unsigned())
2672 && !row_exists(sql, table,
"id", values[index])) {
2674 batch,
"unknown_delete_row",
2675 indexed_path(
"/update/delete/" + table, index),
2676 "relationship row does not exist", &values[index]
2681 const auto evidence_rows = deletes->find(
"evidence");
2682 if (evidence_rows != deletes->end() && evidence_rows->is_array()
2683 && !evidence_rows->empty()) {
2684 std::set<std::int64_t> deleted_evidence;
2685 for (
const auto& value : *evidence_rows) {
2686 if (value.is_number_integer() || value.is_number_unsigned()) {
2687 deleted_evidence.emplace(value.get<std::int64_t>());
2690 for (
const auto collection : {
2691 "work_concepts",
"concept_relations",
2692 "parent_guide_assertions" }) {
2693 const auto created = create.find(collection);
2694 if (created == create.end() || !created->is_array()) {
2697 for (std::size_t index = 0; index < created->size(); ++index) {
2698 const auto& row = (*created)[index];
2699 const auto evidence_values = row.find(
"evidence");
2700 if (evidence_values == row.end()
2701 || !evidence_values->is_array()) {
2704 for (std::size_t evidence_index = 0;
2705 evidence_index < evidence_values->size();
2707 const auto& reference
2708 = (*evidence_values)[evidence_index];
2709 if ((reference.is_number_integer()
2710 || reference.is_number_unsigned())
2711 && deleted_evidence.contains(
2712 reference.get<std::int64_t>()
2715 batch,
"deleted_evidence_reference",
2719 + std::string(collection),
2725 "new assertion references evidence scheduled "
2726 "for deletion in the same batch",
2731 const bool has_remaining = std::ranges::any_of(
2733 [&deleted_evidence](
const json& reference) {
2734 return reference.is_string()
2735 || ((reference.is_number_integer()
2736 || reference.is_number_unsigned())
2737 && !deleted_evidence.contains(
2738 reference.get<std::int64_t>()
2742 if (!has_remaining) {
2744 batch,
"assertion_evidence_required",
2746 "/create/" + std::string(collection), index
2749 "evidence deletion would leave the newly created "
2750 "assertion without evidence",
2756 for (
const auto& [assertion_table, link_table] : {
2758 "work_concepts",
"work_concept_evidence" },
2760 "concept_relations",
"concept_relation_evidence" },
2762 "parent_guide_assertions",
"parent_guide_evidence" } }) {
2763 std::set<std::int64_t> deleted_assertions;
2764 if (
const auto values = deletes->find(assertion_table);
2765 values != deletes->end() && values->is_array()) {
2766 for (
const auto& value : *values) {
2767 if (value.is_number_integer()
2768 || value.is_number_unsigned()) {
2769 deleted_assertions.emplace(
2770 value.get<std::int64_t>()
2777 "SELECT DISTINCT assertion_id FROM "
2778 + std::string(link_table) +
" WHERE evidence_id=?"
2780 std::set<std::int64_t> assertions;
2781 for (
const auto evidence_id : deleted_evidence) {
2782 sqlite3_reset(affected.native());
2783 sqlite3_clear_bindings(affected.native());
2784 affected.bind(1, evidence_id);
2785 while (affected.step()) {
2786 assertions.emplace(affected.integer(0));
2789 for (
const auto assertion_id : assertions) {
2790 if (deleted_assertions.contains(assertion_id)) {
2793 statement remaining(
2795 "SELECT evidence_id FROM " + std::string(link_table)
2796 +
" WHERE assertion_id=?"
2798 remaining.bind(1, assertion_id);
2799 bool has_remaining =
false;
2800 while (remaining.step()) {
2801 if (!deleted_evidence.contains(remaining.integer(0))) {
2802 has_remaining =
true;
2806 if (!has_remaining) {
2808 batch,
"assertion_evidence_required",
2809 "/update/delete/evidence/assertion_impacts/"
2810 + std::string(assertion_table) +
"/"
2811 + std::to_string(assertion_id),
2812 "evidence deletion would leave "
2813 + std::string(assertion_table)
2814 +
" row " + std::to_string(assertion_id)
2815 +
" without evidence",
2824 const auto& merge = batch.document.at(
"merge");
2825 for (
const auto& [collection, family] : {
2826 std::pair {
"agents",
"agent" },
2827 std::pair {
"works",
"work" },
2828 std::pair {
"concepts",
"concept" } }) {
2829 const auto rows = merge.find(collection);
2830 if (rows == merge.end() || !rows->is_array()) {
2833 for (std::size_t index = 0; index < rows->size(); ++index) {
2834 const auto& row = (*rows)[index];
2835 if (!row.is_object()) {
2838 auto inspect = [&](
const json& id,
const std::string& path) {
2840 && entity_family(sql, id.get<std::string>()) != family) {
2842 batch,
"unknown_canonical_id", path,
2843 "merge entity does not exist in the required family",
2848 if (row.contains(
"target")) {
2851 indexed_path(
"/merge/" + std::string(collection), index)
2855 if (row.contains(
"members") && row[
"members"].is_array()) {
2856 for (std::size_t member = 0; member < row[
"members"].size();
2859 row[
"members"][member],
2862 "/merge/" + std::string(collection), index
2875 sqlite3*
const sql,
const std::string_view table
2878 sql,
"SELECT COALESCE(MAX(id), 0) FROM " + std::string(table)
2881 throw database_error(
"cannot read maximum row ID");
2887 sqlite3*
const sql,
const std::string_view prefix
2891 "SELECT COALESCE(MAX(CAST(substr(id, ?) AS INTEGER)), 0) "
2892 "FROM entities WHERE id GLOB ?"
2895 1
, static_cast<std::int64_t>(prefix.size() + 1U)
2897 query.bind(2, std::string(prefix) +
"[0-9]*");
2899 throw database_error(
"cannot read maximum canonical ID suffix");
2905 const std::string_view family,
const std::int64_t sequence
2908 std::size_t width = 6U;
2909 if (family ==
"agent") {
2911 }
else if (family ==
"work") {
2913 }
else if (family ==
"concept") {
2914 prefix =
"concept-";
2915 }
else if (family ==
"manifestation") {
2916 prefix =
"manifestation-";
2918 throw database_error(
"cannot allocate ID for non-entity family");
2920 std::string digits = std::to_string(sequence);
2921 if (digits.size() < width) {
2922 digits.insert(0U, width - digits.size(),
'0');
2924 return prefix + digits;
2927struct local_id_allocation_state
final {
2937 .entity_sequences = {
2938 {
"agent", maximum_entity_suffix(product.native(),
"agent-") },
2939 {
"work", maximum_entity_suffix(product.native(),
"work-") },
2940 {
"concept", maximum_entity_suffix(product.native(),
"concept-") },
2942 maximum_entity_suffix(product.native(),
"manifestation-") },
2944 .source_sequence = maximum_row_id(product.native(),
"sources"),
2945 .evidence_sequence = maximum_row_id(product.native(),
"evidence"),
2946 .assertion_sequences = {
2948 maximum_row_id(product.native(),
"work_concepts") },
2949 {
"concept_relations",
2950 maximum_row_id(product.native(),
"concept_relations") },
2951 {
"parent_guide_assertions",
2952 maximum_row_id(product.native(),
"parent_guide_assertions") },
2958 std::int64_t& sequence,
const std::string_view description
2960 if (sequence == std::numeric_limits<std::int64_t>::max()) {
2961 throw database_error(
2962 std::string(description) +
" ID sequence is exhausted"
2969 parsed_batch& batch, local_id_allocation_state& sequences
2971 const auto& create = batch.document.at(
"create");
2972 for (
const auto& [collection, family] : {
2973 std::pair {
"agents",
"agent" },
2974 std::pair {
"works",
"work" },
2975 std::pair {
"concepts",
"concept" },
2976 std::pair {
"manifestations",
"manifestation" } }) {
2977 const auto rows = create.find(collection);
2978 if (rows == create.end() || !rows->is_array()) {
2981 for (
const auto& row : *rows) {
2982 auto& sequence = sequences.entity_sequences.at(family);
2983 batch.resolved_entity_ids.emplace(
2984 row.at(
"local_id").get<std::string>(),
2985 formatted_entity_id(
2986 family, next_sequence(sequence,
"canonical entity")
2991 if (
const auto rows = create.find(
"sources");
2992 rows != create.end() && rows->is_array()) {
2993 for (
const auto& row : *rows) {
2994 if (row.is_object() && row.contains(
"local_id")
2995 && row[
"local_id"].is_string()) {
2996 batch.source_local_ids.emplace(
2997 row[
"local_id"].get<std::string>(),
2998 next_sequence(sequences.source_sequence,
"source")
3003 if (
const auto rows = create.find(
"evidence");
3004 rows != create.end() && rows->is_array()) {
3005 for (
const auto& row : *rows) {
3006 if (row.is_object() && row.contains(
"local_id")
3007 && row[
"local_id"].is_string()) {
3008 batch.evidence_local_ids.emplace(
3009 row[
"local_id"].get<std::string>(),
3010 next_sequence(sequences.evidence_sequence,
"evidence")
3015 for (
const auto& [collection, table] : {
3016 std::pair {
"work_concepts",
"work_concepts" },
3017 std::pair {
"concept_relations",
"concept_relations" },
3019 "parent_guide_assertions",
"parent_guide_assertions" } }) {
3020 const auto rows = create.find(collection);
3021 if (rows == create.end() || !rows->is_array()) {
3024 auto& sequence = sequences.assertion_sequences.at(table);
3025 for (
const auto& row : *rows) {
3026 if (row.is_object() && row.contains(
"local_id")
3027 && row[
"local_id"].is_string()) {
3028 batch.assertion_local_ids.emplace(
3029 row[
"local_id"].get<std::string>(),
3030 next_sequence(sequence,
"assertion")
3038 const parsed_batch& batch,
const json& value
3040 const std::string reference = value.get<std::string>();
3041 const auto local = batch.resolved_entity_ids.find(reference);
3042 return local == batch.resolved_entity_ids.end() ? reference : local->second;
3046 const std::unordered_map<std::string, std::int64_t>& locals,
3049 if (value.is_string()) {
3050 const auto found = locals.find(value.get<std::string>());
3051 if (found == locals.end()) {
3052 throw database_error(
"unresolved local row reference");
3054 return found->second;
3056 return value.get<std::int64_t>();
3060 statement& insert,
const int index,
const json& row,
const char* key,
3061 const bool serialize =
false
3063 const auto found = row.find(key);
3064 if (found == row.end()) {
3066 }
else if (serialize) {
3067 insert.bind(index, found->dump());
3069 insert.bind_json_value(index, *found);
3074 const auto& create = batch.document.at(
"create");
3075 auto insert_entity = [&](
const json& row,
const std::string_view type) {
3076 const std::string local = row.at(
"local_id").get<std::string>();
3077 const std::string id = batch.resolved_entity_ids.at(local);
3079 sql,
"INSERT INTO entities(id, entity_type) VALUES (?, ?)"
3082 entity.bind(2, type);
3084 batch.affected_entities.emplace(id);
3088 if (
const auto rows = create.find(
"agents");
3089 rows != create.end() && rows->is_array()) {
3090 for (std::size_t index = 0; index < rows->size(); ++index) {
3091 const auto& row = (*rows)[index];
3092 set_application_context(
3093 batch, indexed_path(
"/create/agents", index), row
3095 const std::string agent_type = row.at(
"agent_type");
3096 const std::string id = insert_entity(row, agent_type);
3099 "INSERT INTO agents(entity_id,agent_type,birth_year,death_year)"
3103 insert.bind(2, agent_type);
3104 bind_optional(insert, 3, row,
"birth_year");
3105 bind_optional(insert, 4, row,
"death_year");
3109 if (
const auto rows = create.find(
"works");
3110 rows != create.end() && rows->is_array()) {
3111 for (std::size_t index = 0; index < rows->size(); ++index) {
3112 const auto& row = (*rows)[index];
3113 set_application_context(
3114 batch, indexed_path(
"/create/works", index), row
3116 const std::string id = insert_entity(row,
"work");
3119 "INSERT INTO works("
3120 "entity_id,medium,year_start,year_end,date_precision,"
3121 "date_start_text,date_end_text,date_qualifier,language_code,"
3122 "country_code,production_info_json)"
3123 " VALUES(?,?,?,?,?,?,?,?,?,?,?)"
3126 insert.bind_json_value(2, row.at(
"medium"));
3127 bind_optional(insert, 3, row,
"year_start");
3128 bind_optional(insert, 4, row,
"year_end");
3129 bind_optional(insert, 5, row,
"date_precision");
3130 bind_optional(insert, 6, row,
"date_start_text");
3131 bind_optional(insert, 7, row,
"date_end_text");
3132 bind_optional(insert, 8, row,
"date_qualifier");
3133 bind_optional(insert, 9, row,
"language_code");
3134 bind_optional(insert, 10, row,
"country_code");
3135 bind_optional(insert, 11, row,
"production_info_json");
3139 if (
const auto rows = create.find(
"concepts");
3140 rows != create.end() && rows->is_array()) {
3141 for (std::size_t index = 0; index < rows->size(); ++index) {
3142 const auto& row = (*rows)[index];
3143 set_application_context(
3144 batch, indexed_path(
"/create/concepts", index), row
3146 const std::string id = insert_entity(row,
"concept");
3149 "INSERT INTO concepts(entity_id,concept_type,slug)"
3153 insert.bind_json_value(2, row.at(
"concept_type"));
3154 insert.bind_json_value(3, row.at(
"slug"));
3158 if (
const auto rows = create.find(
"manifestations");
3159 rows != create.end() && rows->is_array()) {
3160 for (std::size_t index = 0; index < rows->size(); ++index) {
3161 const auto& row = (*rows)[index];
3162 set_application_context(
3163 batch, indexed_path(
"/create/manifestations", index), row
3165 const std::string id = insert_entity(row,
"manifestation");
3168 "INSERT INTO manifestations("
3169 "entity_id,work_id,manifestation_type,release_year,"
3170 "region_code,language_code,label) VALUES(?,?,?,?,?,?,?)"
3173 insert.bind(2, resolve_entity(batch, row.at(
"work_id")));
3174 insert.bind_json_value(3, row.at(
"manifestation_type"));
3175 bind_optional(insert, 4, row,
"release_year");
3176 bind_optional(insert, 5, row,
"region_code");
3177 bind_optional(insert, 6, row,
"language_code");
3178 insert.bind_json_value(7, row.at(
"label"));
3185 parsed_batch& batch, sqlite3*
const sql
3187 const auto& create = batch.document.at(
"create");
3188 if (
const auto rows = create.find(
"names");
3189 rows != create.end() && rows->is_array()) {
3190 for (std::size_t index = 0; index < rows->size(); ++index) {
3191 const auto& row = (*rows)[index];
3192 set_application_context(
3193 batch, indexed_path(
"/create/names", index), row
3197 "INSERT INTO names("
3198 "entity_id,name_type,language_code,script_code,value,"
3199 "is_preferred) VALUES(?,?,?,?,?,?)"
3201 insert.bind(1, resolve_entity(batch, row.at(
"entity_id")));
3202 insert.bind_json_value(2, row.at(
"name_type"));
3203 bind_optional(insert, 3, row,
"language_code");
3204 bind_optional(insert, 4, row,
"script_code");
3205 insert.bind_json_value(5, row.at(
"value"));
3208 static_cast<std::int64_t>(
3209 row.at(
"is_preferred").get<
bool>() ? 1 : 0
3213 batch.affected_entities.emplace(
3214 resolve_entity(batch, row.at(
"entity_id"))
3218 if (
const auto rows = create.find(
"external_ids");
3219 rows != create.end() && rows->is_array()) {
3220 for (std::size_t index = 0; index < rows->size(); ++index) {
3221 const auto& row = (*rows)[index];
3222 set_application_context(
3223 batch, indexed_path(
"/create/external_ids", index), row
3227 "INSERT INTO external_ids("
3228 "entity_id,scheme,value,canonical_url) VALUES(?,?,?,?)"
3230 insert.bind(1, resolve_entity(batch, row.at(
"entity_id")));
3231 insert.bind_json_value(2, row.at(
"scheme"));
3232 insert.bind_json_value(3, row.at(
"value"));
3233 bind_optional(insert, 4, row,
"canonical_url");
3235 batch.affected_entities.emplace(
3236 resolve_entity(batch, row.at(
"entity_id"))
3243 parsed_batch& batch, sqlite3*
const sql
3245 const auto& create = batch.document.at(
"create");
3246 if (
const auto rows = create.find(
"sources");
3247 rows != create.end() && rows->is_array()) {
3248 for (std::size_t index = 0; index < rows->size(); ++index) {
3249 const auto& row = (*rows)[index];
3250 set_application_context(
3251 batch, indexed_path(
"/create/sources", index), row
3253 const std::int64_t id = batch.source_local_ids.at(
3254 row.at(
"local_id").get<std::string>()
3258 "INSERT INTO sources("
3259 "id,source_type,title,bibliography_text,author_text,publisher,"
3260 "publication_date,url,doi,isbn,language_code)"
3261 " VALUES(?,?,?,?,?,?,?,?,?,?,?)"
3264 insert.bind_json_value(2, row.at(
"source_type"));
3265 bind_optional(insert, 3, row,
"title");
3266 bind_optional(insert, 4, row,
"bibliography_text");
3267 bind_optional(insert, 5, row,
"author_text");
3268 bind_optional(insert, 6, row,
"publisher");
3269 bind_optional(insert, 7, row,
"publication_date");
3270 bind_optional(insert, 8, row,
"url");
3271 bind_optional(insert, 9, row,
"doi");
3272 bind_optional(insert, 10, row,
"isbn");
3273 bind_optional(insert, 11, row,
"language_code");
3277 if (
const auto rows = create.find(
"evidence");
3278 rows != create.end() && rows->is_array()) {
3279 for (std::size_t index = 0; index < rows->size(); ++index) {
3280 const auto& row = (*rows)[index];
3281 set_application_context(
3282 batch, indexed_path(
"/create/evidence", index), row
3284 const std::int64_t id = batch.evidence_local_ids.at(
3285 row.at(
"local_id").get<std::string>()
3289 "INSERT INTO evidence("
3290 "id,source_id,exact_quote,quote_language,quote_translation,"
3291 "locator_json,stance) VALUES(?,?,?,?,?,?,?)"
3296 resolve_row_reference(
3297 batch.source_local_ids, row.at(
"source_id")
3300 insert.bind_json_value(3, row.at(
"exact_quote"));
3301 bind_optional(insert, 4, row,
"quote_language");
3302 bind_optional(insert, 5, row,
"quote_translation");
3303 bind_optional(insert, 6, row,
"locator_json");
3304 insert.bind_json_value(7, row.at(
"stance"));
3311 const auto& create = batch.document.at(
"create");
3312 if (
const auto rows = create.find(
"credits");
3313 rows != create.end() && rows->is_array()) {
3314 for (std::size_t index = 0; index < rows->size(); ++index) {
3315 const auto& row = (*rows)[index];
3316 set_application_context(
3317 batch, indexed_path(
"/create/credits", index), row
3321 "INSERT INTO credits("
3322 "work_id,agent_id,role,credit_order,importance,credited_as)"
3323 " VALUES(?,?,?,?,?,?)"
3325 insert.bind(1, resolve_entity(batch, row.at(
"work_id")));
3326 insert.bind(2, resolve_entity(batch, row.at(
"agent_id")));
3327 insert.bind_json_value(3, row.at(
"role"));
3328 bind_optional(insert, 4, row,
"credit_order");
3329 insert.bind_json_value(5, row.at(
"importance"));
3330 bind_optional(insert, 6, row,
"credited_as");
3332 batch.affected_entities.emplace(
3333 resolve_entity(batch, row.at(
"work_id"))
3335 batch.affected_entities.emplace(
3336 resolve_entity(batch, row.at(
"agent_id"))
3340 if (
const auto rows = create.find(
"measurements");
3341 rows != create.end() && rows->is_array()) {
3342 for (std::size_t index = 0; index < rows->size(); ++index) {
3343 const auto& row = (*rows)[index];
3344 set_application_context(
3345 batch, indexed_path(
"/create/measurements", index), row
3349 "INSERT INTO measurements("
3350 "entity_id,measurement_type,value,unit,qualifier)"
3351 " VALUES(?,?,?,?,?)"
3353 insert.bind(1, resolve_entity(batch, row.at(
"entity_id")));
3354 insert.bind_json_value(2, row.at(
"measurement_type"));
3355 insert.bind_json_value(3, row.at(
"value"));
3356 insert.bind_json_value(4, row.at(
"unit"));
3357 bind_optional(insert, 5, row,
"qualifier");
3359 batch.affected_entities.emplace(
3360 resolve_entity(batch, row.at(
"entity_id"))
3364 if (
const auto rows = create.find(
"financial_facts");
3365 rows != create.end() && rows->is_array()) {
3366 for (std::size_t index = 0; index < rows->size(); ++index) {
3367 const auto& row = (*rows)[index];
3368 set_application_context(
3369 batch, indexed_path(
"/create/financial_facts", index), row
3373 "INSERT INTO financial_facts("
3374 "work_id,fact_type,amount_min,amount_max,currency_code,"
3375 "value_year,is_estimate,confidence) VALUES(?,?,?,?,?,?,?,?)"
3377 insert.bind(1, resolve_entity(batch, row.at(
"work_id")));
3378 insert.bind_json_value(2, row.at(
"fact_type"));
3379 insert.bind_json_value(3, row.at(
"amount_min"));
3380 bind_optional(insert, 4, row,
"amount_max");
3381 insert.bind_json_value(5, row.at(
"currency_code"));
3382 bind_optional(insert, 6, row,
"value_year");
3385 static_cast<std::int64_t>(
3386 row.at(
"is_estimate").get<
bool>() ? 1 : 0
3389 bind_optional(insert, 8, row,
"confidence");
3391 batch.affected_entities.emplace(
3392 resolve_entity(batch, row.at(
"work_id"))
3399 const parsed_batch& batch, sqlite3*
const sql,
3400 const std::string_view table,
const std::int64_t assertion_id,
3401 const json& evidence
3403 for (
const auto& reference : evidence) {
3406 "INSERT INTO " + std::string(table)
3407 +
"(assertion_id,evidence_id) VALUES(?,?)"
3409 insert.bind(1, assertion_id);
3412 resolve_row_reference(batch.evidence_local_ids, reference)
3419 const auto& create = batch.document.at(
"create");
3420 if (
const auto rows = create.find(
"work_concepts");
3421 rows != create.end() && rows->is_array()) {
3422 for (std::size_t index = 0; index < rows->size(); ++index) {
3423 const auto& row = (*rows)[index];
3424 set_application_context(
3425 batch, indexed_path(
"/create/work_concepts", index), row
3427 const std::int64_t id = batch.assertion_local_ids.at(
3428 row.at(
"local_id").get<std::string>()
3432 "INSERT INTO work_concepts("
3433 "id,work_id,concept_id,relation_type,centrality,"
3434 "historical_role,confidence) VALUES(?,?,?,?,?,?,?)"
3437 insert.bind(2, resolve_entity(batch, row.at(
"work_id")));
3438 insert.bind(3, resolve_entity(batch, row.at(
"concept_id")));
3439 insert.bind_json_value(4, row.at(
"relation_type"));
3440 insert.bind_json_value(5, row.at(
"centrality"));
3441 bind_optional(insert, 6, row,
"historical_role");
3442 bind_optional(insert, 7, row,
"confidence");
3445 batch, sql,
"work_concept_evidence", id, row.at(
"evidence")
3447 batch.affected_entities.emplace(
3448 resolve_entity(batch, row.at(
"work_id"))
3450 batch.affected_entities.emplace(
3451 resolve_entity(batch, row.at(
"concept_id"))
3455 if (
const auto rows = create.find(
"concept_relations");
3456 rows != create.end() && rows->is_array()) {
3457 for (std::size_t index = 0; index < rows->size(); ++index) {
3458 const auto& row = (*rows)[index];
3459 set_application_context(
3460 batch, indexed_path(
"/create/concept_relations", index), row
3462 const std::int64_t id = batch.assertion_local_ids.at(
3463 row.at(
"local_id").get<std::string>()
3467 "INSERT INTO concept_relations("
3468 "id,subject_concept_id,relation_type,object_concept_id,"
3469 "strength,from_year,to_year,region_code,confidence)"
3470 " VALUES(?,?,?,?,?,?,?,?,?)"
3474 2, resolve_entity(batch, row.at(
"subject_concept_id"))
3476 insert.bind_json_value(3, row.at(
"relation_type"));
3478 4, resolve_entity(batch, row.at(
"object_concept_id"))
3480 bind_optional(insert, 5, row,
"strength");
3481 bind_optional(insert, 6, row,
"from_year");
3482 bind_optional(insert, 7, row,
"to_year");
3483 bind_optional(insert, 8, row,
"region_code");
3484 bind_optional(insert, 9, row,
"confidence");
3487 batch, sql,
"concept_relation_evidence", id,
3490 batch.affected_entities.emplace(
3491 resolve_entity(batch, row.at(
"subject_concept_id"))
3493 batch.affected_entities.emplace(
3494 resolve_entity(batch, row.at(
"object_concept_id"))
3498 if (
const auto rows = create.find(
"parent_guide_assertions");
3499 rows != create.end() && rows->is_array()) {
3500 for (std::size_t index = 0; index < rows->size(); ++index) {
3501 const auto& row = (*rows)[index];
3502 set_application_context(
3504 indexed_path(
"/create/parent_guide_assertions", index), row
3506 const std::int64_t id = batch.assertion_local_ids.at(
3507 row.at(
"local_id").get<std::string>()
3511 "INSERT INTO parent_guide_assertions("
3512 "id,work_id,concept_id,category,intensity,explicitness,"
3513 "frequency,centrality,realism,spoiler_level,confidence)"
3514 " VALUES(?,?,?,?,?,?,?,?,?,?,?)"
3517 insert.bind(2, resolve_entity(batch, row.at(
"work_id")));
3518 insert.bind(3, resolve_entity(batch, row.at(
"concept_id")));
3519 insert.bind_json_value(4, row.at(
"category"));
3520 insert.bind_json_value(5, row.at(
"intensity"));
3521 insert.bind_json_value(6, row.at(
"explicitness"));
3522 insert.bind_json_value(7, row.at(
"frequency"));
3523 insert.bind_json_value(8, row.at(
"centrality"));
3524 insert.bind_json_value(9, row.at(
"realism"));
3525 insert.bind_json_value(10, row.at(
"spoiler_level"));
3526 bind_optional(insert, 11, row,
"confidence");
3529 batch, sql,
"parent_guide_evidence", id, row.at(
"evidence")
3531 batch.affected_entities.emplace(
3532 resolve_entity(batch, row.at(
"work_id"))
3534 batch.affected_entities.emplace(
3535 resolve_entity(batch, row.at(
"concept_id"))
3542 return std::string(field);
3546 sqlite3*
const sql,
const std::string_view table,
3547 const std::string_view id_column,
const json& operation
3549 std::vector<std::pair<std::string,
const json*>> assignments;
3550 for (
const auto& [field, value] : operation.at(
"set").items()) {
3551 assignments.emplace_back(field, &value);
3553 for (
const auto& field : operation.at(
"unset")) {
3554 assignments.emplace_back(field.get<std::string>(),
nullptr);
3556 if (assignments.empty()) {
3559 std::string sql_text =
"UPDATE " + std::string(table) +
" SET ";
3560 for (std::size_t index = 0; index < assignments.size(); ++index) {
3564 sql_text += sql_column(assignments[index].first) +
"=?";
3566 sql_text +=
" WHERE " + std::string(id_column) +
"=?";
3567 statement update(sql, sql_text);
3569 for (
const auto& [field, value] : assignments) {
3570 if (value ==
nullptr) {
3571 update.bind_null(binding);
3573 update.bind_json_value(binding, *value);
3577 update.bind_json_value(binding, operation.at(
"id"));
3579 if (sqlite3_changes(sql) != 1) {
3580 throw database_error(
"update target disappeared before mutation");
3585 const auto& update = batch.document.at(
"update");
3586 auto apply_entities = [&](
const char* collection,
const char* table) {
3587 const auto rows = update.find(collection);
3588 if (rows == update.end() || !rows->is_array()) {
3591 for (std::size_t index = 0; index < rows->size(); ++index) {
3592 const auto& row = (*rows)[index];
3593 set_application_context(
3596 "/update/" + std::string(collection), index
3600 update_row(sql, table,
"entity_id", row);
3601 const std::string id = row.at(
"id").get<std::string>();
3602 batch.affected_entities.emplace(id);
3603 if (std::string_view(table) ==
"agents"
3604 && row.at(
"set").contains(
"agent_type")) {
3606 sql,
"UPDATE entities SET entity_type=? WHERE id=?"
3608 entity.bind_json_value(1, row.at(
"set").at(
"agent_type"));
3614 apply_entities(
"agents",
"agents");
3615 apply_entities(
"works",
"works");
3616 apply_entities(
"concepts",
"concepts");
3617 apply_entities(
"manifestations",
"manifestations");
3618 if (
const auto rows = update.find(
"sources");
3619 rows != update.end() && rows->is_array()) {
3620 for (std::size_t index = 0; index < rows->size(); ++index) {
3621 const auto& row = (*rows)[index];
3622 set_application_context(
3623 batch, indexed_path(
"/update/sources", index), row
3625 update_row(sql,
"sources",
"id", row);
3631 const auto& update = batch.document.at(
"update");
3632 const auto deletes = update.find(
"delete");
3633 if (deletes == update.end() || !deletes->is_object()) {
3638 for (
const std::string_view table_name : {
3639 "names",
"external_ids",
"credits",
"measurements",
3640 "financial_facts",
"work_concepts",
"concept_relations",
3641 "parent_guide_assertions",
"evidence",
"ingest_issues" }) {
3642 const auto found = deletes->find(table_name);
3643 if (found == deletes->end()) {
3646 const std::string table(table_name);
3647 const auto& values = *found;
3648 if (!values.is_array()) {
3651 if (table ==
"ingest_issues") {
3654 "DELETE FROM ingest_issues "
3655 "WHERE batch_id=? AND code=? AND json_path=? "
3656 "AND status IN('resolved','ignored')"
3658 for (std::size_t index = 0; index < values.size(); ++index) {
3659 const auto& value = values[index];
3660 set_application_context(
3663 "/update/delete/" + pointer_escape(table), index
3667 sqlite3_reset(remove.native());
3668 sqlite3_clear_bindings(remove.native());
3669 remove.bind_json_value(1, value.at(
"batch_id"));
3670 remove.bind_json_value(2, value.at(
"code"));
3671 remove.bind_json_value(3, value.at(
"json_path"));
3673 if (sqlite3_changes(sql) != 1) {
3674 throw database_error(
3675 "ingest issue disappeared or reopened before deletion"
3682 sql,
"DELETE FROM " + table +
" WHERE id=?"
3684 for (std::size_t value_index = 0;
3685 value_index < values.size(); ++value_index) {
3686 const auto& value = values[value_index];
3687 set_application_context(
3690 "/update/delete/" + pointer_escape(table), value_index
3694 std::vector<std::string> entity_columns;
3695 if (table ==
"names" || table ==
"external_ids"
3696 || table ==
"measurements") {
3697 entity_columns = {
"entity_id" };
3698 }
else if (table ==
"credits") {
3699 entity_columns = {
"work_id",
"agent_id" };
3700 }
else if (table ==
"financial_facts") {
3701 entity_columns = {
"work_id" };
3702 }
else if (table ==
"work_concepts"
3703 || table ==
"parent_guide_assertions") {
3704 entity_columns = {
"work_id",
"concept_id" };
3705 }
else if (table ==
"concept_relations") {
3707 "subject_concept_id",
"object_concept_id"
3710 if (!entity_columns.empty()) {
3711 std::string query_text =
"SELECT ";
3712 for (std::size_t index = 0; index < entity_columns.size();
3717 query_text += entity_columns[index];
3719 query_text +=
" FROM " + table +
" WHERE id=?";
3720 statement affected(sql, query_text);
3721 affected.bind_json_value(1, value);
3722 if (affected.step()) {
3723 for (std::size_t index = 0;
3724 index < entity_columns.size(); ++index) {
3725 batch.affected_entities.emplace(
3726 affected.text(
static_cast<
int>(index))
3731 sqlite3_reset(remove.native());
3732 sqlite3_clear_bindings(remove.native());
3733 remove.bind_json_value(1, value);
3735 if (sqlite3_changes(sql) != 1) {
3736 throw database_error(
3737 "relationship row disappeared before deletion"
3747 std::set<std::string, std::less<>> result;
3748 for (
const auto& value : array) {
3749 result.emplace(value.get<std::string>());
3755 sqlite3*
const sql,
const std::string_view table,
3756 const std::string_view id_column,
const std::string& target,
3757 const std::vector<std::string>& members,
const json& operation,
3758 const std::vector<std::string>& columns,
3759 const std::set<std::string, std::less<>>& required
3761 const auto explicitly_set = [&operation](
const std::string& field) {
3762 return operation.at(
"set").contains(field);
3764 const auto unset = string_set(operation.at(
"unset"));
3765 json implicit_set = json::object();
3766 for (
const auto& field : columns) {
3767 if (explicitly_set(field) || unset.contains(field)) {
3770 std::set<std::string, std::less<>> values;
3771 std::optional<json> target_value;
3772 auto inspect = [&](
const std::string& id,
const bool is_target) {
3775 "SELECT " + sql_column(field) +
" FROM " + std::string(table)
3776 +
" WHERE " + std::string(id_column) +
"=?"
3779 if (!query.step()) {
3780 throw database_error(
"merge entity disappeared");
3782 if (!query.is_null(0)) {
3783 const int type = sqlite3_column_type(query.native(), 0);
3785 if (type == SQLITE_INTEGER) {
3786 value = query.integer(0);
3787 }
else if (type == SQLITE_FLOAT) {
3788 value = query.real(0);
3790 value = query.text(0);
3792 values.emplace(value.dump());
3794 target_value = value;
3798 inspect(target,
true);
3799 for (
const auto& member : members) {
3800 inspect(member,
false);
3802 if (values.size() > 1U) {
3803 throw database_error(
3804 "unresolved merge scalar conflict for field " + field
3807 if (required.contains(field) && values.empty()) {
3808 throw database_error(
3809 "merge would leave required field empty: " + field
3812 if (!target_value && !values.empty()) {
3813 implicit_set[field] = json::parse(*values.begin());
3816 json target_update {
3818 {
"set", implicit_set },
3819 {
"unset", operation.at(
"unset") },
3821 for (
const auto& [field, value] : operation.at(
"set").items()) {
3822 target_update[
"set"][field] = value;
3824 update_row(sql, table, id_column, target_update);
3828 sqlite3*
const sql,
const std::string& target,
3829 const std::vector<std::string>& members
3831 std::set<std::string, std::less<>> preferred;
3832 auto inspect = [&](
const std::string& id) {
3835 "SELECT value FROM names WHERE entity_id=? AND is_preferred=1"
3839 preferred.emplace(query
.text(0
));
3843 for (
const auto& member : members) {
3846 if (preferred.size() > 1U) {
3847 throw database_error(
"unresolved preferred name or title conflict");
3852 sqlite3*
const sql,
const std::string_view query_text,
3853 const std::string& target,
const std::string& member
3855 statement query(sql, query_text);
3856 query.bind(1, target);
3857 query.bind(2, member);
3862 sqlite3*
const sql,
const std::string& target,
const std::string& member
3866 "SELECT 1 FROM names t JOIN names m "
3867 "ON t.entity_id=? AND m.entity_id=? "
3868 "AND t.name_type=m.name_type "
3869 "AND t.language_code IS m.language_code "
3870 "AND t.script_code IS m.script_code AND t.value=m.value "
3871 "WHERE t.is_preferred<>m.is_preferred LIMIT 1",
3874 throw database_error(
"conflicting duplicate name preference");
3879 "DELETE FROM names AS m WHERE m.entity_id=? AND EXISTS("
3880 "SELECT 1 FROM names AS t WHERE t.entity_id=? "
3881 "AND t.name_type=m.name_type "
3882 "AND t.language_code IS m.language_code "
3883 "AND t.script_code IS m.script_code AND t.value=m.value)"
3885 remove.bind(1, member);
3886 remove.bind(2, target);
3889 sql,
"UPDATE names SET entity_id=? WHERE entity_id=?"
3891 rewrite.bind(1, target);
3892 rewrite.bind(2, member);
3897 sql,
"UPDATE external_ids SET entity_id=? WHERE entity_id=?"
3899 rewrite.bind(1, target);
3900 rewrite.bind(2, member);
3906 "DELETE FROM measurements AS m WHERE m.entity_id=? AND EXISTS("
3907 "SELECT 1 FROM measurements AS t WHERE t.entity_id=? "
3908 "AND t.measurement_type=m.measurement_type AND t.value=m.value "
3909 "AND t.unit=m.unit AND t.qualifier IS m.qualifier)"
3911 remove.bind(1, member);
3912 remove.bind(2, target);
3915 sql,
"UPDATE measurements SET entity_id=? WHERE entity_id=?"
3917 rewrite.bind(1, target);
3918 rewrite.bind(2, member);
3924 sqlite3*
const sql,
const std::string_view id_column,
3925 const std::string& target,
const std::string& member
3927 const std::string other_column
3928 = id_column ==
"work_id" ?
"agent_id" :
"work_id";
3929 const std::string conflict
3930 =
"SELECT 1 FROM credits t JOIN credits m ON t." + std::string(id_column)
3931 +
"=? AND m." + std::string(id_column) +
"=? AND t." + other_column
3932 +
"=m." + other_column
3933 +
" AND t.role=m.role AND t.credit_order IS m.credit_order "
3934 "AND t.credited_as IS m.credited_as "
3935 "WHERE t.importance<>m.importance LIMIT 1";
3937 throw database_error(
"conflicting duplicate credit");
3941 "DELETE FROM credits AS m WHERE m." + std::string(id_column)
3942 +
"=? AND EXISTS(SELECT 1 FROM credits AS t WHERE t."
3943 + std::string(id_column) +
"=? AND t." + other_column +
"=m."
3945 +
" AND t.role=m.role AND t.credit_order IS m.credit_order "
3946 "AND t.credited_as IS m.credited_as)"
3948 remove.bind(1, member);
3949 remove.bind(2, target);
3952 sql,
"UPDATE credits SET " + std::string(id_column) +
"=? WHERE "
3953 + std::string(id_column) +
"=?"
3955 rewrite.bind(1, target);
3956 rewrite.bind(2, member);
3961 sqlite3*
const sql,
const std::string_view evidence_table,
3962 const std::int64_t keeper,
const std::int64_t duplicate
3966 "INSERT OR IGNORE INTO " + std::string(evidence_table)
3967 +
"(assertion_id,evidence_id) SELECT ?,evidence_id FROM "
3968 + std::string(evidence_table) +
" WHERE assertion_id=?"
3976 sqlite3*
const sql,
const std::string_view id_column,
3977 const std::string& target,
const std::string& member
3979 const std::string other_column
3980 = id_column ==
"work_id" ?
"concept_id" :
"work_id";
3984 "(m.centrality=t.centrality "
3985 "AND m.historical_role IS t.historical_role "
3986 "AND m.confidence IS t.confidence) "
3987 "FROM work_concepts m JOIN work_concepts t "
3988 "ON m." + std::string(id_column) +
"=? AND t."
3989 + std::string(id_column) +
"=? AND m." + other_column +
"=t."
3990 + other_column +
" AND m.relation_type=t.relation_type"
3992 rows.bind(1, member);
3993 rows.bind(2, target);
3994 std::vector<std::pair<std::int64_t, std::int64_t>> duplicates;
3997 throw database_error(
"conflicting work-concept assertion");
4001 for (
const auto& [duplicate, keeper] : duplicates) {
4002 transfer_assertion_evidence(
4003 sql,
"work_concept_evidence", keeper, duplicate
4005 statement remove(sql,
"DELETE FROM work_concepts WHERE id=?");
4006 remove.bind(1, duplicate);
4010 sql,
"UPDATE work_concepts SET " + std::string(id_column)
4011 +
"=? WHERE " + std::string(id_column) +
"=?"
4013 rewrite.bind(1, target);
4014 rewrite.bind(2, member);
4019 sqlite3*
const sql,
const std::string_view id_column,
4020 const std::string& target,
const std::string& member
4022 const std::string other_column
4023 = id_column ==
"work_id" ?
"concept_id" :
"work_id";
4027 "(m.intensity=t.intensity AND m.explicitness=t.explicitness "
4028 "AND m.frequency=t.frequency AND m.centrality=t.centrality "
4029 "AND m.realism=t.realism AND m.spoiler_level=t.spoiler_level "
4030 "AND m.confidence IS t.confidence) "
4031 "FROM parent_guide_assertions m JOIN parent_guide_assertions t "
4032 "ON m." + std::string(id_column) +
"=? AND t."
4033 + std::string(id_column) +
"=? AND m." + other_column +
"=t."
4034 + other_column +
" AND m.category=t.category"
4036 rows.bind(1, member);
4037 rows.bind(2, target);
4038 std::vector<std::pair<std::int64_t, std::int64_t>> duplicates;
4041 throw database_error(
"conflicting parent-guide assertion");
4045 for (
const auto& [duplicate, keeper] : duplicates) {
4046 transfer_assertion_evidence(
4047 sql,
"parent_guide_evidence", keeper, duplicate
4050 sql,
"DELETE FROM parent_guide_assertions WHERE id=?"
4052 remove.bind(1, duplicate);
4056 sql,
"UPDATE parent_guide_assertions SET " + std::string(id_column)
4057 +
"=? WHERE " + std::string(id_column) +
"=?"
4059 rewrite.bind(1, target);
4060 rewrite.bind(2, member);
4065 sqlite3*
const sql,
const std::string& target,
const std::string& member
4069 "SELECT 1 FROM financial_facts t JOIN financial_facts m "
4070 "ON t.work_id=? AND m.work_id=? AND t.fact_type=m.fact_type "
4071 "AND t.amount_min=m.amount_min AND t.amount_max IS m.amount_max "
4072 "AND t.currency_code=m.currency_code "
4073 "AND t.value_year IS m.value_year "
4074 "WHERE t.is_estimate<>m.is_estimate "
4075 "OR t.confidence IS NOT m.confidence LIMIT 1",
4078 throw database_error(
"conflicting duplicate financial fact");
4082 "DELETE FROM financial_facts AS m WHERE m.work_id=? AND EXISTS("
4083 "SELECT 1 FROM financial_facts AS t WHERE t.work_id=? "
4084 "AND t.fact_type=m.fact_type AND t.amount_min=m.amount_min "
4085 "AND t.amount_max IS m.amount_max "
4086 "AND t.currency_code=m.currency_code "
4087 "AND t.value_year IS m.value_year)"
4089 remove.bind(1, member);
4090 remove.bind(2, target);
4093 sql,
"UPDATE financial_facts SET work_id=? WHERE work_id=?"
4095 rewrite.bind(1, target);
4096 rewrite.bind(2, member);
4100struct concept_relation_row
final {
4108 sqlite3*
const sql,
const std::string& target,
const std::string& member
4112 "SELECT id,subject_concept_id,relation_type,object_concept_id "
4113 "FROM concept_relations "
4114 "WHERE subject_concept_id=? OR object_concept_id=? ORDER BY id"
4116 query.bind(1, member);
4117 query.bind(2, member);
4118 std::vector<concept_relation_row> rows;
4124 .relation_type = query
.text(2
),
4129 for (
const auto& row : rows) {
4130 const std::string subject
4131 = row.subject == member ? target : row.subject;
4132 const std::string object = row.object == member ? target : row.object;
4133 if (subject == object) {
4134 statement remove(sql,
"DELETE FROM concept_relations WHERE id=?");
4135 remove.bind(1, row.id);
4139 statement collision(
4142 "(strength IS (SELECT strength FROM concept_relations WHERE id=?) "
4143 "AND from_year IS (SELECT from_year FROM concept_relations WHERE id=?) "
4144 "AND to_year IS (SELECT to_year FROM concept_relations WHERE id=?) "
4145 "AND region_code IS (SELECT region_code FROM concept_relations WHERE id=?) "
4146 "AND confidence IS (SELECT confidence FROM concept_relations WHERE id=?)) "
4147 "FROM concept_relations WHERE subject_concept_id=? "
4148 "AND relation_type=? AND object_concept_id=? AND id<>? LIMIT 1"
4150 for (
int binding = 1; binding <= 5; ++binding) {
4151 collision.bind(binding, row.id);
4153 collision.bind(6, subject);
4154 collision.bind(7, row.relation_type);
4155 collision.bind(8, object);
4156 collision.bind(9, row.id);
4157 if (collision.step()) {
4158 if (collision.integer(1) == 0) {
4159 throw database_error(
"conflicting concept relation");
4161 const std::int64_t keeper = collision.integer(0);
4162 transfer_assertion_evidence(
4163 sql,
"concept_relation_evidence", keeper, row.id
4165 statement remove(sql,
"DELETE FROM concept_relations WHERE id=?");
4166 remove.bind(1, row.id);
4171 "UPDATE concept_relations SET subject_concept_id=?,"
4172 "object_concept_id=? WHERE id=?"
4174 rewrite.bind(1, subject);
4175 rewrite.bind(2, object);
4176 rewrite.bind(3, row.id);
4183 sqlite3*
const sql,
const std::string& member
4185 statement remove(sql,
"DELETE FROM entities WHERE id=?");
4186 remove.bind(1, member);
4188 if (sqlite3_changes(sql) != 1) {
4189 throw database_error(
"merged entity disappeared before deletion");
4194 std::vector<std::string> result;
4195 result.reserve(operation.at(
"members").size());
4196 for (
const auto& value : operation.at(
"members")) {
4197 result.push_back(value.get<std::string>());
4203 parsed_batch& batch, sqlite3*
const sql,
const std::string_view family,
4204 const std::string& target,
const std::vector<std::string>& members
4206 batch.affected_entities.emplace(target);
4207 std::vector<std::string> merged { target };
4208 merged.insert(merged.end(), members.begin(), members.end());
4209 auto add_values = [&](
const std::string_view query_text) {
4210 statement query(sql, query_text);
4211 for (
const auto& id : merged) {
4212 sqlite3_reset(query.native());
4213 sqlite3_clear_bindings(query.native());
4215 while (query.step()) {
4216 batch.affected_entities.emplace(query.text(0));
4220 if (family ==
"agent") {
4221 add_values(
"SELECT work_id FROM credits WHERE agent_id=?");
4222 }
else if (family ==
"work") {
4223 add_values(
"SELECT agent_id FROM credits WHERE work_id=?");
4225 "SELECT concept_id FROM work_concepts WHERE work_id=?"
4227 }
else if (family ==
"concept") {
4229 "SELECT work_id FROM work_concepts WHERE concept_id=?"
4232 "SELECT object_concept_id FROM concept_relations "
4233 "WHERE subject_concept_id=?"
4236 "SELECT subject_concept_id FROM concept_relations "
4237 "WHERE object_concept_id=?"
4243 parsed_batch& batch, sqlite3*
const sql,
const json& operation
4245 const std::string target = operation.at(
"target");
4246 const auto members = merge_members(operation);
4247 mark_merge_neighborhood(batch, sql,
"agent", target, members);
4248 reject_preferred_name_conflict(sql, target, members);
4249 resolve_scalar_fields(
4250 sql,
"agents",
"entity_id", target, members, operation,
4251 {
"agent_type",
"birth_year",
"death_year" }, {
"agent_type" }
4253 if (operation.at(
"set").contains(
"agent_type")) {
4255 sql,
"UPDATE entities SET entity_type=? WHERE id=?"
4257 sync.bind_json_value(1, operation.at(
"set").at(
"agent_type"));
4258 sync.bind(2, target);
4261 for (
const auto& member : members) {
4262 merge_entity_scoped_rows(sql, target, member);
4263 merge_credits(sql,
"agent_id", target, member);
4264 delete_merged_entity(sql, member);
4269 parsed_batch& batch, sqlite3*
const sql,
const json& operation
4271 const std::string target = operation.at(
"target");
4272 const auto members = merge_members(operation);
4273 mark_merge_neighborhood(batch, sql,
"work", target, members);
4274 reject_preferred_name_conflict(sql, target, members);
4275 resolve_scalar_fields(
4276 sql,
"works",
"entity_id", target, members, operation,
4277 {
"medium",
"year_start",
"year_end",
"date_precision",
4278 "date_start_text",
"date_end_text",
"date_qualifier",
4279 "language_code",
"country_code",
"production_info_json" },
4282 for (
const auto& member : members) {
4283 merge_entity_scoped_rows(sql, target, member);
4284 merge_credits(sql,
"work_id", target, member);
4285 merge_financial_facts(sql, target, member);
4286 merge_work_concepts(sql,
"work_id", target, member);
4287 merge_parent_guides(sql,
"work_id", target, member);
4288 statement manifestations(
4289 sql,
"UPDATE manifestations SET work_id=? WHERE work_id=?"
4291 manifestations.bind(1, target);
4292 manifestations.bind(2, member);
4293 manifestations.execute();
4294 delete_merged_entity(sql, member);
4299 parsed_batch& batch, sqlite3*
const sql,
const json& operation
4301 const std::string target = operation.at(
"target");
4302 const auto members = merge_members(operation);
4303 mark_merge_neighborhood(batch, sql,
"concept", target, members);
4304 reject_preferred_name_conflict(sql, target, members);
4305 if (operation.at(
"set").contains(
"slug")) {
4306 const std::string requested = operation.at(
"set").at(
"slug");
4307 for (
const auto& member : members) {
4309 sql,
"SELECT slug FROM concepts WHERE entity_id=?"
4311 inspect.bind(1, member);
4312 if (!inspect.step()) {
4313 throw database_error(
"merge concept disappeared");
4315 if (inspect.text(0) == requested) {
4316 std::string temporary =
"merge-temporary-";
4317 for (
const char raw_character : member) {
4318 const auto character
4319 =
static_cast<
unsigned char>(raw_character);
4320 if (std::isdigit(character) != 0) {
4321 temporary.push_back(
static_cast<
char>(character));
4324 const std::string base = temporary;
4325 std::size_t attempt = 0;
4326 statement collision(
4328 "SELECT 1 FROM concepts WHERE slug=? LIMIT 1"
4331 sqlite3_reset(collision.native());
4332 sqlite3_clear_bindings(collision.native());
4333 collision.bind(1, temporary);
4334 if (!collision.step()) {
4338 temporary = base +
"-" + std::to_string(attempt);
4341 sql,
"UPDATE concepts SET slug=? WHERE entity_id=?"
4343 release.bind(1, temporary);
4344 release.bind(2, member);
4349 resolve_scalar_fields(
4350 sql,
"concepts",
"entity_id", target, members, operation,
4351 {
"concept_type",
"slug" }, {
"concept_type",
"slug" }
4353 for (
const auto& member : members) {
4354 merge_entity_scoped_rows(sql, target, member);
4355 merge_work_concepts(sql,
"concept_id", target, member);
4356 merge_parent_guides(sql,
"concept_id", target, member);
4357 merge_concept_relations(sql, target, member);
4358 delete_merged_entity(sql, member);
4363 const auto& merge = batch.document.at(
"merge");
4364 if (
const auto rows = merge.find(
"agents");
4365 rows != merge.end() && rows->is_array()) {
4366 for (std::size_t index = 0; index < rows->size(); ++index) {
4367 const auto& row = (*rows)[index];
4368 set_application_context(
4369 batch, indexed_path(
"/merge/agents", index), row
4371 merge_agents(batch, sql, row);
4374 if (
const auto rows = merge.find(
"works");
4375 rows != merge.end() && rows->is_array()) {
4376 for (std::size_t index = 0; index < rows->size(); ++index) {
4377 const auto& row = (*rows)[index];
4378 set_application_context(
4379 batch, indexed_path(
"/merge/works", index), row
4381 merge_works(batch, sql, row);
4384 if (
const auto rows = merge.find(
"concepts");
4385 rows != merge.end() && rows->is_array()) {
4386 for (std::size_t index = 0; index < rows->size(); ++index) {
4387 const auto& row = (*rows)[index];
4388 set_application_context(
4389 batch, indexed_path(
"/merge/concepts", index), row
4391 merge_concepts(batch, sql, row);
4397 utf8proc_uint8_t* mapped =
nullptr;
4398 const auto options =
static_cast<utf8proc_option_t>(
4399 UTF8PROC_STABLE | UTF8PROC_COMPOSE | UTF8PROC_COMPAT
4402 const utf8proc_ssize_t mapped_size = utf8proc_map(
4403 reinterpret_cast<
const utf8proc_uint8_t*>(value.data()),
4404 static_cast<utf8proc_ssize_t>(value.size()), &mapped, options
4406 if (mapped_size < 0 || mapped ==
nullptr) {
4410 std::vector<std::string> tokens;
4412 utf8proc_ssize_t offset = 0;
4413 while (offset < mapped_size) {
4414 utf8proc_int32_t codepoint = 0;
4415 const utf8proc_ssize_t consumed = utf8proc_iterate(
4416 mapped + offset, mapped_size - offset, &codepoint
4418 if (consumed <= 0) {
4422 const utf8proc_category_t category = utf8proc_category(codepoint);
4423 const bool word = (category >= UTF8PROC_CATEGORY_LU
4424 && category <= UTF8PROC_CATEGORY_LO)
4425 || (category >= UTF8PROC_CATEGORY_MN
4426 && category <= UTF8PROC_CATEGORY_ME)
4427 || (category >= UTF8PROC_CATEGORY_ND
4428 && category <= UTF8PROC_CATEGORY_NO);
4430 std::array<utf8proc_uint8_t, 4> encoded {};
4431 const utf8proc_ssize_t encoded_size
4432 = utf8proc_encode_char(codepoint, encoded.data());
4434 reinterpret_cast<
const char*>(encoded.data()),
4435 static_cast<std::size_t>(encoded_size)
4437 }
else if (!token.empty()) {
4438 tokens.push_back(std::move(token));
4444 if (!token.empty()) {
4445 tokens.push_back(std::move(token));
4447 std::ranges::sort(tokens);
4449 for (
const auto& current : tokens) {
4450 if (!result.empty()) {
4451 result.push_back(
' ');
4458struct hint_label
final {
4473 "CASE WHEN e.entity_type IN('person','organization','group') "
4474 "THEN 'agent' ELSE e.entity_type END,"
4475 "CASE WHEN e.entity_type='concept' THEN COALESCE(c.slug,'') "
4477 " SELECT n.value FROM names n WHERE n.entity_id=e.id "
4478 " ORDER BY n.is_preferred DESC,n.id LIMIT 1"
4480 "FROM entities e LEFT JOIN concepts c ON c.entity_id=e.id "
4481 "WHERE e.entity_type IN("
4482 "'person','organization','group','work','concept') ORDER BY e.id"
4484 std::vector<hint_label> result;
4485 std::map<std::string, std::size_t, std::less<>> index_by_id;
4493 .preferred_fingerprints = {},
4494 .alternate_fingerprints = {},
4496 label.fingerprint = normalized_label(label.label);
4499 if (label
.family ==
"concept") {
4500 label.preferred_fingerprints.push_back(label
.fingerprint);
4503 index_by_id.emplace(label
.id, result.size());
4504 result.push_back(std::move(label));
4508 "SELECT n.entity_id,n.value,n.is_preferred FROM names n "
4509 "JOIN entities e ON e.id=n.entity_id "
4510 "WHERE e.entity_type IN("
4511 "'person','organization','group','work','concept') "
4512 "ORDER BY n.entity_id,n.id"
4515 const auto found = index_by_id.find(names
.text(0
));
4516 if (found == index_by_id.end()) {
4519 std::string normalized = normalized_label(names.text(1));
4520 if (!normalized.empty()) {
4521 auto& label = result[found->second];
4522 label.fingerprints.push_back(normalized);
4523 if (label.family !=
"concept" && names
.integer(2
) != 0) {
4524 label.preferred_fingerprints.push_back(normalized);
4526 label.alternate_fingerprints.push_back(std::move(normalized));
4530 for (
auto& label : result) {
4531 const auto deduplicate = [](std::vector<std::string>& values) {
4532 std::ranges::sort(values);
4534 std::unique(values.begin(), values.end()), values.end()
4537 deduplicate(label.fingerprints);
4538 deduplicate(label.preferred_fingerprints);
4539 deduplicate(label.alternate_fingerprints);
4540 if (label.fingerprint.empty() && !label.fingerprints.empty()) {
4541 label.fingerprint = label.fingerprints.front();
4543 if (label.preferred_fingerprints.empty()
4544 && !label.fingerprint.empty()) {
4545 label.preferred_fingerprints.push_back(label.fingerprint);
4548 label.alternate_fingerprints, label.fingerprint
4555 sqlite3*
const sql,
const std::string& id
4560 "CASE WHEN e.entity_type IN('person','organization','group') "
4561 "THEN 'agent' ELSE e.entity_type END,"
4562 "CASE WHEN e.entity_type='concept' THEN COALESCE(c.slug,'') "
4564 " SELECT n.value FROM names n WHERE n.entity_id=e.id "
4565 " ORDER BY n.is_preferred DESC,n.id LIMIT 1"
4567 "FROM entities e LEFT JOIN concepts c ON c.entity_id=e.id "
4568 "WHERE e.id=? AND e.entity_type IN("
4569 "'person','organization','group','work','concept')"
4573 return std::nullopt;
4581 .preferred_fingerprints = {},
4582 .alternate_fingerprints = {},
4584 result.fingerprint = normalized_label(result.label);
4587 if (result
.family ==
"concept") {
4588 result.preferred_fingerprints.push_back(result
.fingerprint);
4594 "SELECT value,is_preferred FROM names WHERE entity_id=? ORDER BY id"
4598 std::string normalized = normalized_label(names.text(0));
4599 if (normalized.empty()) {
4602 result.fingerprints.push_back(normalized);
4604 result.preferred_fingerprints.push_back(normalized);
4606 result.alternate_fingerprints.push_back(std::move(normalized));
4609 const auto deduplicate = [](std::vector<std::string>& values) {
4610 std::ranges::sort(values);
4611 values.erase(std::unique(values.begin(), values.end()), values.end());
4613 deduplicate(result.fingerprints);
4614 deduplicate(result.preferred_fingerprints);
4615 deduplicate(result.alternate_fingerprints);
4616 if (result
.fingerprint.empty() && !result.fingerprints.empty()) {
4619 if (result.preferred_fingerprints.empty()
4621 result.preferred_fingerprints.push_back(result
.fingerprint);
4623 std::erase(result.alternate_fingerprints, result.fingerprint);
4629 std::set<std::string, std::less<>> result;
4630 std::vector<std::string_view> characters;
4631 std::size_t offset = 0;
4632 while (offset < value.size()) {
4633 utf8proc_int32_t code_point = 0;
4634 const utf8proc_ssize_t width = utf8proc_iterate(
4635 reinterpret_cast<
const utf8proc_uint8_t*>(
4636 value.data() + offset
4638 static_cast<utf8proc_ssize_t>(value.size() - offset),
4643 "normalized label contains invalid UTF-8"
4646 characters.emplace_back(
4647 value.data() + offset,
static_cast<std::size_t>(width)
4649 offset +=
static_cast<std::size_t>(width);
4651 if (characters.size() < 3U) {
4652 if (!value.empty()) {
4653 result.emplace(value);
4657 for (std::size_t index = 0; index + 3U <= characters.size(); ++index) {
4658 std::string trigram;
4660 characters[index].size() + characters[index + 1U].size()
4661 + characters[index + 2U].size()
4663 trigram += characters[index];
4664 trigram += characters[index + 1U];
4665 trigram += characters[index + 2U];
4666 result.emplace(std::move(trigram));
4672 const std::string_view left,
const std::string_view right
4674 const auto left_values = trigrams(left);
4675 const auto right_values = trigrams(right);
4676 std::size_t intersection = 0;
4677 for (
const auto& value : left_values) {
4678 intersection +=
static_cast<std::size_t>(
4679 right_values.contains(value)
4682 const std::size_t combined
4683 = left_values.size() + right_values.size() - intersection;
4684 return combined == 0U
4686 :
static_cast<
double>(intersection)
4687 /
static_cast<
double>(combined);
4692struct hint_candidate_support
final {
4702 return block_type.ends_with(
"_trigram");
4706 const std::string_view block_type
4708 if (block_type ==
"label_fingerprint") {
4711 if (block_type ==
"label_trigram"
4712 || block_type ==
"work_year_title_trigram"
4713 || block_type ==
"work_medium_title_trigram") {
4716 if (block_type ==
"work_year_title_fingerprint"
4717 || block_type ==
"work_medium_title_fingerprint"
4718 || block_type ==
"work_primary_agent") {
4721 if (block_type ==
"agent_work_role"
4722 || block_type ==
"concept_work"
4723 || block_type ==
"concept_neighbor") {
4726 if (block_type ==
"external_identifier") {
4729 throw database_error(
"unknown merge-hint block type");
4733 const std::string_view context,
const std::string_view value
4735 return std::string(context) +
"\n" + std::string(value);
4739 const std::string_view block_key
4741 const std::size_t separator = block_key.rfind(
'\n');
4742 return separator == std::string_view::npos
4743 ? std::string(block_key)
4744 : std::string(block_key.substr(separator + 1U));
4748 hint_candidate_map& candidates,
const std::string& first,
4749 const std::string& second,
const std::string_view block_type,
4750 const std::string_view block_key
4752 if (first == second) {
4755 auto& support = candidates[
4757 std::min(first, second),
4758 std::max(first, second),
4761 if (is_trigram_block(block_type)) {
4762 support.shared_trigrams.emplace(trigram_support_key(block_key));
4764 support.direct =
true;
4769 const hint_candidate_map& candidates
4771 std::set<hint_pair> result;
4772 for (
const auto& [pair, support] : candidates) {
4774
4775
4776
4777
4778
4779 if (support.direct || support.shared_trigrams.size() >= 2U) {
4780 result.emplace(pair);
4787 statement& block, statement& member,
const hint_label& label,
4788 const std::string_view block_type,
const std::string& block_key
4790 if (block_key.empty()) {
4796 block.bind(2, block_type);
4797 block.bind(3, block_key);
4799 throw database_error(
"cannot resolve merge-hint block key");
4801 const std::int64_t block_id = block
.integer(0
);
4804 sqlite3_clear_bindings(member
.native());
4806 member.bind(2, label
.id);
4813 "INSERT INTO merge_hint_blocks("
4814 "entity_type,block_type,block_key) VALUES(?,?,?) "
4815 "ON CONFLICT(entity_type,block_type,block_key) DO UPDATE SET "
4816 "block_key=excluded.block_key RETURNING id"
4820 "INSERT INTO merge_hint_block_members(block_id,entity_id) "
4821 "VALUES(?,?) ON CONFLICT(entity_id,block_id) DO NOTHING"
4823 for (
const auto& fingerprint : label.fingerprints) {
4825 block, member, label,
"label_fingerprint", fingerprint
4827 for (
const auto& trigram : trigrams(fingerprint)) {
4829 block, member, label,
"label_trigram", trigram
4837 "SELECT medium,year_start FROM works WHERE entity_id=?"
4839 work.bind(1, label
.id);
4841 const std::string medium = work
.text(0
);
4842 const std::optional<std::string> year = work.is_null(1)
4844 : std::optional<std::string>(
4845 std::to_string(work.integer(1))
4847 for (
const auto& fingerprint : label.fingerprints) {
4849 block, member, label,
"work_medium_title_fingerprint",
4850 contextual_block_key(medium, fingerprint)
4854 block, member, label,
"work_year_title_fingerprint",
4855 contextual_block_key(*year, fingerprint)
4858 for (
const auto& trigram : trigrams(fingerprint)) {
4860 block, member, label,
"work_medium_title_trigram",
4861 contextual_block_key(medium, trigram)
4865 block, member, label,
"work_year_title_trigram",
4866 contextual_block_key(*year, trigram)
4874 "SELECT DISTINCT agent_id FROM credits WHERE work_id=? "
4875 "AND importance IN('primary','key')"
4877 agents.bind(1, label
.id);
4880 block, member, label,
"work_primary_agent", agents
.text(0
)
4883 }
else if (label
.family ==
"agent") {
4884 statement work_roles(
4886 "SELECT DISTINCT work_id,role FROM credits WHERE agent_id=?"
4888 work_roles.bind(1, label
.id);
4891 block, member, label,
"agent_work_role",
4892 json::array({ work_roles.text(0), work_roles.text(1) }).dump()
4895 }
else if (label
.family ==
"concept") {
4898 "SELECT DISTINCT work_id FROM work_concepts WHERE concept_id=?"
4900 works.bind(1, label
.id);
4903 block, member, label,
"concept_work", works
.text(0
)
4906 statement neighbors(
4908 "SELECT object_concept_id,relation_type "
4909 "FROM concept_relations WHERE subject_concept_id=?1 "
4910 "UNION SELECT subject_concept_id,relation_type "
4911 "FROM concept_relations WHERE object_concept_id=?1"
4913 neighbors.bind(1, label
.id);
4916 block, member, label,
"concept_neighbor",
4917 json::array({ neighbors.text(0), neighbors.text(1) }).dump()
4922 statement external_ids(
4924 "SELECT DISTINCT lower(trim(scheme)),lower(trim(value)) "
4925 "FROM external_ids WHERE entity_id=?"
4927 external_ids.bind(1, label
.id);
4930 block, member, label,
"external_identifier",
4931 json::array({ external_ids.text(0), external_ids.text(1) }).dump()
4938 const std::set<std::string, std::less<>>& affected,
4939 std::map<std::string, hint_label, std::less<>>& label_cache
4942 sql,
"DELETE FROM merge_hint_block_members WHERE entity_id=?"
4944 for (
const auto& id : affected) {
4945 sqlite3_reset(remove.native());
4946 sqlite3_clear_bindings(remove.native());
4949 auto label = hint_label_for_id(sql, id);
4953 insert_hint_blocks(sql, *label);
4954 label_cache.insert_or_assign(id, std::move(*label));
4960 const std::set<std::string, std::less<>>& affected
4962 hint_candidate_map candidates;
4965 "SELECT b.id,b.block_type,b.block_key "
4966 "FROM merge_hint_block_members m "
4967 "JOIN merge_hint_blocks b ON b.id=m.block_id "
4968 "WHERE m.entity_id=? ORDER BY b.id"
4972 "SELECT entity_id FROM merge_hint_block_members "
4973 "WHERE block_id=? ORDER BY entity_id LIMIT ?"
4975 for (
const auto& id : affected) {
4976 sqlite3_reset(blocks.native());
4977 sqlite3_clear_bindings(blocks.native());
4979 while (blocks.step()) {
4980 const std::int64_t block_id = blocks.integer(0);
4981 const std::string block_type = blocks.text(1);
4982 const std::string block_key = blocks.text(2);
4983 const std::size_t maximum
4984 = maximum_hint_block_size(block_type);
4985 sqlite3_reset(peers.native());
4986 sqlite3_clear_bindings(peers.native());
4987 peers.bind(1, block_id);
4988 peers.bind(2,
static_cast<std::int64_t>(maximum + 1U));
4989 std::vector<std::string> members;
4990 while (peers.step()) {
4991 members.push_back(peers.text(0));
4993 if (members.size() > maximum) {
4996 for (
const auto& peer : members) {
4997 record_hint_candidate(
4998 candidates, id, peer, block_type, block_key
5003 return reviewable_blocked_pairs(candidates);
5009 hint_candidate_map candidates;
5012 "SELECT b.entity_type,b.block_type,b.block_key,m.entity_id "
5013 "FROM merge_hint_blocks b "
5014 "JOIN merge_hint_block_members m ON m.block_id=b.id "
5015 "ORDER BY b.entity_type,b.block_type,b.block_key,m.entity_id"
5017 std::string current_family;
5018 std::string current_type;
5019 std::string current_key;
5020 std::vector<std::string> members;
5021 auto flush = [&]() {
5022 if (members.size() >= 2U
5023 && members.size() <= maximum_hint_block_size(current_type)) {
5024 for (std::size_t left = 0; left < members.size(); ++left) {
5025 for (std::size_t right = left + 1U;
5026 right < members.size(); ++right) {
5027 record_hint_candidate(
5028 candidates, members[left], members[right],
5029 current_type, current_key
5037 const std::string family = blocks
.text(0
);
5038 const std::string block_type = blocks
.text(1
);
5039 const std::string block_key = blocks
.text(2
);
5040 if (!current_type.empty()
5041 && std::tie(family, block_type, block_key)
5042 != std::tie(current_family, current_type, current_key)) {
5045 current_family = family;
5046 current_type = block_type;
5047 current_key = block_key;
5048 members.push_back(blocks
.text(3
));
5050 if (!current_type.empty()) {
5053 return reviewable_blocked_pairs(candidates);
5058 sql,
"SELECT 1 FROM merge_hint_block_members LIMIT 1"
5064
5065
5066
5067
5068
5069 statement clear(sql,
"DELETE FROM merge_hint_blocks");
5071 for (
const auto& label : all_hint_labels(sql)) {
5072 insert_hint_blocks(sql, label);
5077 sqlite3*
const sql,
const std::string_view query_text,
5078 const std::string& id
5080 statement query(sql, query_text);
5082 std::set<std::string, std::less<>> result;
5084 result.emplace(query
.text(0
));
5090 const std::set<std::string, std::less<>>& left,
5091 const std::set<std::string, std::less<>>& right
5093 std::size_t intersection = 0;
5094 for (
const auto& value : left) {
5095 intersection +=
static_cast<std::size_t>(right.contains(value));
5097 const std::size_t union_size
5098 = left.size() + right.size() - intersection;
5099 return union_size == 0U
5101 :
static_cast<
double>(intersection)
5102 /
static_cast<
double>(union_size);
5106 const std::set<std::string, std::less<>>& left,
5107 const std::set<std::string, std::less<>>& right
5109 return static_cast<std::size_t>(std::ranges::count_if(
5111 [&right](
const std::string& value) {
5112 return right.contains(value);
5118 const std::vector<std::string>& left,
5119 const std::vector<std::string>& right
5121 double result = 0.0;
5122 for (
const auto& left_value : left) {
5123 for (
const auto& right_value : right) {
5127 rapidfuzz::fuzz::token_sort_ratio(
5128 left_value, right_value
5130 rapidfuzz::fuzz::token_set_ratio(
5131 left_value, right_value
5133 rapidfuzz::fuzz::ratio(
5134 left_value, right_value, 35.0
5144 const hint_label& left,
const hint_label& right
5147
5148
5149
5150
5151
5153 maximum_text_similarity(
5154 left.alternate_fingerprints, right.fingerprints
5156 maximum_text_similarity(
5157 left.fingerprints, right.alternate_fingerprints
5162struct hint_components
final {
5169 sqlite3*
const sql,
const hint_label& left,
const hint_label& right
5171 hint_components result;
5173 const double preferred_name_similarity = maximum_text_similarity(
5174 left.preferred_fingerprints, right.preferred_fingerprints
5176 const double alias_similarity
5178 const auto left_works = query_value_set(
5179 sql,
"SELECT work_id FROM credits WHERE agent_id=?", left.id
5181 const auto right_works = query_value_set(
5182 sql,
"SELECT work_id FROM credits WHERE agent_id=?", right.id
5184 const auto left_roles = query_value_set(
5185 sql,
"SELECT role FROM credits WHERE agent_id=?", left.id
5187 const auto right_roles = query_value_set(
5188 sql,
"SELECT role FROM credits WHERE agent_id=?", right.id
5190 const double work_score = jaccard(left_works, right_works);
5191 const double role_score = jaccard(left_roles, right_roles);
5192 result
.graph = 0.7 * work_score + 0.3 * role_score;
5193 result.signals[
"preferred_name_similarity"]
5194 = preferred_name_similarity;
5195 result.signals[
"alias_similarity"] = alias_similarity;
5196 result.signals[
"shared_work_count"]
5197 = intersection_count(left_works, right_works);
5198 result.signals[
"shared_work_jaccard"] = work_score;
5199 result.signals[
"shared_role_count"]
5200 = intersection_count(left_roles, right_roles);
5201 result.signals[
"shared_role_jaccard"] = role_score;
5204 "SELECT l.birth_year,r.birth_year,l.death_year,r.death_year "
5205 "FROM agents l,agents r WHERE l.entity_id=? AND r.entity_id=?"
5207 years.bind(1, left
.id);
5208 years.bind(2, right
.id);
5210 double compatibility_sum = 0.0;
5212 for (
const auto [left_column, right_column, signal_prefix] :
5214 std::tuple { 0, 1,
"birth_year" },
5215 std::tuple { 2, 3,
"death_year" },
5217 if (!years.is_null(left_column)
5218 && !years.is_null(right_column)) {
5220 const auto difference = std::abs(
5221 years.integer(left_column)
5222 - years.integer(right_column)
5224 const double compatibility = std::max(
5226 1.0 -
static_cast<
double>(difference) / 5.0
5228 compatibility_sum += compatibility;
5230 std::string(signal_prefix) +
"_difference"
5233 std::string(signal_prefix) +
"_compatible"
5234 ] = difference == 0;
5239 : compatibility_sum /
static_cast<
double>(comparable);
5240 result.signals[
"life_date_compatibility"] = result
.context;
5242 const auto left_external_ids = query_value_set(
5244 "SELECT json_array(lower(trim(scheme)),lower(trim(value))) "
5245 "FROM external_ids WHERE entity_id=?",
5248 const auto right_external_ids = query_value_set(
5250 "SELECT json_array(lower(trim(scheme)),lower(trim(value))) "
5251 "FROM external_ids WHERE entity_id=?",
5254 const auto identifier_collisions
5255 = intersection_count(left_external_ids, right_external_ids);
5256 result.signals[
"external_identifier_collision_count"]
5257 = identifier_collisions;
5258 result.signals[
"external_identifier_collision"]
5259 = identifier_collisions != 0U;
5260 }
else if (left
.family ==
"work") {
5261 const double preferred_title_similarity = maximum_text_similarity(
5262 left.preferred_fingerprints, right.preferred_fingerprints
5264 const double alternate_title_similarity
5266 const auto left_agents = query_value_set(
5268 "SELECT agent_id FROM credits WHERE work_id=? "
5269 "AND importance IN('primary','key')",
5272 const auto right_agents = query_value_set(
5274 "SELECT agent_id FROM credits WHERE work_id=? "
5275 "AND importance IN('primary','key')",
5278 const auto left_concepts = query_value_set(
5279 sql,
"SELECT concept_id FROM work_concepts WHERE work_id=?", left.id
5281 const auto right_concepts = query_value_set(
5282 sql,
"SELECT concept_id FROM work_concepts WHERE work_id=?", right.id
5284 const double agent_score = jaccard(left_agents, right_agents);
5285 const double concept_score = jaccard(left_concepts, right_concepts);
5286 result
.graph = 0.6 * agent_score + 0.4 * concept_score;
5287 result.signals[
"preferred_title_similarity"]
5288 = preferred_title_similarity;
5289 result.signals[
"alternate_title_similarity"]
5290 = alternate_title_similarity;
5291 result.signals[
"shared_primary_agent_count"]
5292 = intersection_count(left_agents, right_agents);
5293 result.signals[
"primary_agent_jaccard"] = agent_score;
5294 result.signals[
"shared_concept_count"]
5295 = intersection_count(left_concepts, right_concepts);
5296 result.signals[
"concept_set_jaccard"] = concept_score;
5299 "SELECT l.medium=r.medium,"
5300 "CASE WHEN l.year_start IS NULL OR r.year_start IS NULL THEN NULL "
5301 "ELSE abs(l.year_start-r.year_start) END,"
5302 "CASE WHEN l.year_end IS NULL OR r.year_end IS NULL THEN NULL "
5303 "ELSE abs(l.year_end-r.year_end) END "
5304 "FROM works l,works r WHERE l.entity_id=? AND r.entity_id=?"
5306 context.bind(1, left
.id);
5307 context.bind(2, right
.id);
5309 const double medium_score
5311 double date_score = 0.0;
5312 int comparable_dates = 0;
5313 for (
const auto [column, signal] :
5315 std::pair { 1,
"start_year_difference" },
5316 std::pair { 2,
"end_year_difference" },
5318 if (context.is_null(column)) {
5322 const auto difference = context.integer(column);
5323 date_score += std::max(
5324 0.0, 1.0 -
static_cast<
double>(difference) / 10.0
5326 result.signals[signal] = difference;
5328 if (comparable_dates != 0) {
5329 date_score /=
static_cast<
double>(comparable_dates);
5331 result
.context = 0.6 * medium_score + 0.4 * date_score;
5332 result.signals[
"medium_equal"] = context
.integer(0
) != 0;
5333 result.signals[
"date_compatibility"] = date_score;
5335 }
else if (left
.family ==
"concept") {
5336 result.signals[
"slug_similarity"] = maximum_text_similarity(
5337 { left.fingerprint }, { right.fingerprint }
5339 result.signals[
"token_fingerprint_equal"]
5341 const auto left_works = query_value_set(
5342 sql,
"SELECT work_id FROM work_concepts WHERE concept_id=?", left.id
5344 const auto right_works = query_value_set(
5345 sql,
"SELECT work_id FROM work_concepts WHERE concept_id=?", right.id
5347 const auto left_neighbors = query_value_set(
5349 "SELECT object_concept_id||':'||relation_type "
5350 "FROM concept_relations WHERE subject_concept_id=?1 "
5351 "UNION SELECT subject_concept_id||':'||relation_type "
5352 "FROM concept_relations WHERE object_concept_id=?1",
5355 statement right_neighbors_query(
5357 "SELECT object_concept_id||':'||relation_type "
5358 "FROM concept_relations WHERE subject_concept_id=?1 "
5359 "UNION SELECT subject_concept_id||':'||relation_type "
5360 "FROM concept_relations WHERE object_concept_id=?1"
5362 right_neighbors_query.bind(1, right
.id);
5363 std::set<std::string, std::less<>> right_neighbors;
5364 while (right_neighbors_query
.step()) {
5365 right_neighbors.emplace(right_neighbors_query
.text(0
));
5367 const double work_score = jaccard(left_works, right_works);
5368 const double neighbor_score
5369 = jaccard(left_neighbors, right_neighbors);
5370 result
.graph = 0.7 * work_score + 0.3 * neighbor_score;
5371 result.signals[
"shared_work_count"]
5372 = intersection_count(left_works, right_works);
5373 result.signals[
"shared_work_jaccard"] = work_score;
5374 result.signals[
"shared_relation_neighbor_count"]
5375 = intersection_count(left_neighbors, right_neighbors);
5376 result.signals[
"relation_neighborhood_jaccard"]
5380 "SELECT l.concept_type=r.concept_type FROM concepts l,concepts r "
5381 "WHERE l.entity_id=? AND r.entity_id=?"
5383 type.bind(1, left
.id);
5384 type.bind(2, right
.id);
5387 result.signals[
"concept_type_equal"] = type
.integer(0
) != 0;
5394 sqlite3*
const sql,
const hint_label& first,
const hint_label& second
5396 const hint_label& left = first
.id < second
.id ? first : second;
5397 const hint_label& right = first
.id < second
.id ? second : first;
5398 bool fingerprint_equal =
false;
5399 double token_sort = 0.0;
5400 double token_set = 0.0;
5402 double trigram_context = 0.0;
5403 double text_score = 0.0;
5406 for (
const auto& left_value : left.fingerprints) {
5407 for (
const auto& right_value : right.fingerprints) {
5409 = fingerprint_equal || left_value == right_value;
5410 const double current_sort
5411 = rapidfuzz::fuzz::token_sort_ratio(
5412 left_value, right_value
5415 const double current_set
5416 = rapidfuzz::fuzz::token_set_ratio(
5417 left_value, right_value
5420 const double current_edit
5421 = rapidfuzz::fuzz::ratio(left_value, right_value, 35.0)
5423 const double current_trigram
5424 = trigram_score(left_value, right_value);
5425 const double current_text
5426 = std::max({ current_sort, current_set, current_edit });
5427 token_sort = std::max(token_sort, current_sort);
5428 token_set = std::max(token_set, current_set);
5429 edit = std::max(edit, current_edit);
5431 = std::max(trigram_context, current_trigram);
5432 if (current_text > text_score) {
5433 text_score = current_text;
5434 best_left = left_value;
5435 best_right = right_value;
5439 hint_components components
5442 const double score = std::clamp(
5443 0.65 * text_score + 0.25 * components
.graph
5448 {
"token_fingerprint_equal", fingerprint_equal },
5449 {
"token_sort_ratio", token_sort },
5450 {
"token_set_ratio", token_set },
5451 {
"normalized_edit_similarity", edit },
5452 {
"trigram_jaccard", trigram_context },
5453 {
"normalized_left", best_left },
5454 {
"normalized_right", best_right },
5455 {
"alternate_label_match",
5458 if (left
.family ==
"concept") {
5459 signals[
"slug_similarity"] = std::max(
5461 rapidfuzz::fuzz::token_sort_ratio(
5464 rapidfuzz::fuzz::token_set_ratio(
5467 rapidfuzz::fuzz::ratio(
5473 signals[left
.family ==
"agent"
5474 ?
"alias_similarity"
5475 :
"alternate_title_similarity"] = text_score;
5477 signals.update(components.signals);
5480 "INSERT INTO merge_hints("
5481 "entity_type,left_id,right_id,score,text_score,graph_score,"
5482 "context_score,signals_json,status) VALUES(?,?,?,?,?,?,?,?,'open') "
5483 "ON CONFLICT(entity_type,left_id,right_id) DO UPDATE SET "
5484 "score=excluded.score,text_score=excluded.text_score,"
5485 "graph_score=excluded.graph_score,context_score=excluded.context_score,"
5486 "signals_json=excluded.signals_json WHERE merge_hints.status='open'"
5489 insert.bind(2, left
.id);
5490 insert.bind(3, right
.id);
5495 insert.bind(8, signals.dump());
5501 const std::set<std::string, std::less<>>& affected
5503 if (affected.empty()) {
5508 "DELETE FROM merge_hints WHERE status='open' "
5509 "AND (left_id=? OR right_id=?)"
5511 for (
const auto& id : affected) {
5512 sqlite3_reset(remove.native());
5513 sqlite3_clear_bindings(remove.native());
5519 std::map<std::string, hint_label, std::less<>> labels;
5520 refresh_hint_blocks(sql, affected, labels);
5521 for (
const auto& [left, right] :
5522 incrementally_blocked_pairs(sql, affected)) {
5523 auto load = [&](
const std::string& id) ->
const hint_label* {
5524 const auto cached = labels.find(id);
5525 if (cached != labels.end()) {
5526 return &cached->second;
5528 auto label = hint_label_for_id(sql, id);
5532 return &labels.emplace(id, std::move(*label)).first->second;
5534 const hint_label*
const left_label = load(left);
5535 const hint_label*
const right_label = load(right);
5536 if (left_label !=
nullptr && right_label !=
nullptr
5537 && left_label->family == right_label->family) {
5538 upsert_hint(sql, *left_label, *right_label);
5544 transaction change
(product
);
5545 product.execute(
"DELETE FROM merge_hints WHERE status='open'");
5546 product.execute(
"DELETE FROM merge_hint_blocks");
5547 const auto labels = all_hint_labels(product.native());
5548 std::map<std::string,
const hint_label*, std::less<>> by_id;
5549 for (
const auto& label : labels) {
5550 by_id.emplace(label.id, &label);
5551 insert_hint_blocks(product.native(), label);
5553 for (
const auto& [left, right] :
5554 all_blocked_pairs(product.native())) {
5555 const auto left_label = by_id.find(left);
5556 const auto right_label = by_id.find(right);
5557 if (left_label != by_id.end() && right_label != by_id.end()
5558 && left_label->second->family == right_label->second->family) {
5560 product.native(), *left_label->second, *right_label->second
5566 "SELECT count(*) FROM merge_hints WHERE status='open'"
5569 throw database_error(
"cannot count rebuilt merge hints");
5571 const std::size_t inserted
5572 =
static_cast<std::size_t>(count
.integer(0
));
5578 sqlite3*
const sql,
const std::string& batch_id
5581 sql,
"SELECT 1 FROM applied_batches WHERE batch_id=?"
5583 query.bind(1, batch_id);
5588 statement check(sql,
"PRAGMA foreign_key_check");
5590 throw database_error(
5591 "foreign-key validation failed for table " + check
.text(0
)
5597 database& product,
const std::string& batch_id,
5598 const std::vector<inbox_issue>& issues
5600 if (!valid_batch_id(batch_id) || issues.empty()) {
5603 transaction change
(product
);
5606 "INSERT INTO ingest_issues("
5607 "batch_id,code,json_path,message,value_json,status)"
5608 " VALUES(?,?,?,?,?,'open') "
5609 "ON CONFLICT(batch_id,code,json_path) DO UPDATE SET "
5610 "message=excluded.message,value_json=excluded.value_json,status='open'"
5612 for (
const auto& issue : issues) {
5613 sqlite3_reset(insert.native());
5614 sqlite3_clear_bindings(insert.native());
5615 insert.bind(1, batch_id);
5616 insert.bind(2, issue.code);
5617 insert.bind(3, issue.json_path);
5618 insert.bind(4, issue.message);
5619 if (issue.value_json.empty()) {
5620 insert.bind_null(5);
5622 insert.bind(5, issue.value_json);
5630 transaction change
(product
);
5637
5638
5639
5640
5641 parsed_batch recheck = batch;
5642 recheck.issues.clear();
5644 if (!recheck.issues.empty()) {
5645 batch.issues = std::move(recheck.issues);
5646 throw database_error(
"reference changed after batch prevalidation");
5650
5651
5652
5653
5662 batch.application_path =
"/merge_hints";
5664 regenerate_incremental_hints(
5665 product
.native(), batch.affected_entities
5670 "UPDATE ingest_issues SET status='resolved' "
5671 "WHERE batch_id=? AND status='open'"
5676 batch.application_path =
"/";
5682 "INSERT INTO applied_batches(batch_id) VALUES(?)"
5691 struct stat state {};
5692 return ::lstat(path.c_str(), &state) == 0 && S_ISDIR(state.st_mode)
5693 && !S_ISLNK(state.st_mode);
5697 const fs::path& path,
const std::string_view description
5701 std::string(description) +
" must be a real directory: "
5708 struct stat state {};
5709 if (::lstat(path.c_str(), &state) != 0) {
5711 "product database does not exist: " + path.string()
5714 if (!S_ISREG(state.st_mode) || S_ISLNK(state.st_mode)) {
5716 "product database must be a real regular file: " + path.string()
5722 std::ifstream input(path, std::ios::binary);
5724 throw inbox_error(
"cannot open current product schema: " + path.string());
5726 std::ostringstream buffer;
5727 buffer << input.rdbuf();
5728 if (!input.eof() && input.fail()) {
5729 throw inbox_error(
"cannot read current product schema: " + path.string());
5731 return buffer.str();
5735 const fs::path& repository_root,
const fs::path& path
5737 struct stat state {};
5738 if (::lstat(path.c_str(), &state) == 0) {
5742 if (errno != ENOENT) {
5744 "cannot inspect product database: " + path.string()
5747 require_real_directory(path.parent_path(),
"database directory");
5748 sqlite3* raw =
nullptr;
5749 const std::string native = path.string();
5750 if (sqlite3_open_v2(
5751 native.c_str(), &raw,
5752 SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_EXCLUSIVE,
5756 const std::string message = sqlite_message(raw,
"create product database");
5757 if (raw !=
nullptr) {
5763 char* error =
nullptr;
5764 const std::string schema = read_schema(
5765 repository_root /
"schema" /
"product_v5.sql"
5768 raw, schema.c_str(),
nullptr,
nullptr, &error
5771 const std::string message
5772 = error ==
nullptr ? sqlite3_errmsg(raw) : error;
5773 sqlite3_free(error);
5774 throw inbox_error(
"cannot initialize product database: " + message);
5779 std::error_code ignored;
5780 fs::remove(path, ignored);
5788 require_real_directory(inbox,
"inbox");
5789 std::vector<fs::path> paths;
5790 std::error_code error;
5791 for (fs::directory_iterator iterator(inbox, error), end;
5792 !error && iterator != end; iterator.increment(error)) {
5793 const fs::path path = iterator->path();
5794 const std::string filename = path.filename().string();
5795 struct stat state {};
5796 if (::lstat(path.c_str(), &state) != 0) {
5797 throw inbox_error(
"cannot inspect inbox entry: " + path.string());
5799 if (filename ==
"rejected" && S_ISDIR(state.st_mode)
5800 && !S_ISLNK(state.st_mode)) {
5803 if (S_ISLNK(state.st_mode)) {
5804 throw inbox_error(
"symbolic link in inbox is rejected: " + path.string());
5806 if (S_ISDIR(state.st_mode)) {
5808 "unexpected directory in inbox: " + path.string()
5811 if (!S_ISREG(state.st_mode)) {
5813 "non-regular inbox entry is rejected: " + path.string()
5816 if (path.extension() !=
".json") {
5818 "only plain .json batch files are accepted: " + path.string()
5821 paths.push_back(path);
5824 throw inbox_error(
"cannot scan inbox: " + error.message());
5826 std::ranges::sort(paths);
5827 std::vector<file_snapshot> result;
5828 result.reserve(paths.size());
5829 for (
const auto& path : paths) {
5830 result.push_back(read_batch_file(path));
5836 parsed_batch result;
5837 result.file = std::move(snapshot);
5839 result.document = parse_strict_json(result.file.bytes);
5841 const auto contract = arachnespace::contracts::validate(
5845 for (
const auto& diagnostic : contract.diagnostics) {
5846 const std::string path = diagnostic.instance_path.empty()
5848 : diagnostic.instance_path;
5849 const bool present = std::ranges::any_of(
5850 result.issues, [&](
const inbox_issue& issue) {
5854 return issue.json_path == path;
5859 result,
"contract_" + diagnostic.code, path,
5865 }
catch (
const std::exception& error) {
5866 add_issue(result,
"invalid_json",
"/", error.what());
5872 std::vector<parsed_batch>& batches, sqlite3*
const sql
5874 std::map<std::string, std::vector<std::size_t>, std::less<>> occurrences;
5875 for (std::size_t index = 0; index < batches.size(); ++index) {
5876 if (batches[index].structurally_valid) {
5877 occurrences[batches[index].batch_id].push_back(index);
5880 for (
const auto& [batch_id, indexes] : occurrences) {
5881 if (indexes.size() < 2U || batch_was_applied(sql, batch_id)) {
5884 for (
const std::size_t index : indexes) {
5886 batches[index],
"duplicate_pending_batch_id",
"/batch_id",
5887 "more than one pending file uses batch_id " + batch_id,
5888 &batches[index].document[
"batch_id"]
5897 "inbox file changed after commit and was not deleted: "
5898 + snapshot.path.string()
5901 std::error_code error;
5902 if (!fs::remove(snapshot.path, error) || error) {
5904 "cannot delete committed inbox file " + snapshot.path.string()
5905 +
": " + error.message()
5911 const file_snapshot& snapshot,
const fs::path& rejected
5915 "rejected inbox file changed and remains in place: "
5916 + snapshot.path.string()
5919 if (!fs::exists(rejected)) {
5920 std::error_code error;
5921 static_cast<
void>(fs::create_directory(rejected, error));
5924 "cannot create rejected inbox directory "
5925 + rejected.string() +
": " + error.message()
5931 "rejected inbox path is not a real directory: "
5935 fs::path destination = rejected / snapshot.path.filename();
5936 for (std::size_t suffix = 1U;; ++suffix) {
5937 struct stat existing {};
5938 if (::lstat(destination.c_str(), &existing) != 0) {
5939 if (errno == ENOENT) {
5943 "cannot inspect rejected destination "
5944 + destination.string()
5947 if (suffix == 100000U) {
5949 "cannot allocate a rejected filename for "
5950 + snapshot.path.filename().string()
5953 destination = rejected
5954 / (snapshot.path.stem().string() +
"-"
5955 + std::to_string(suffix) + snapshot.path.extension().string());
5957 std::error_code error;
5958 fs::rename(snapshot.path, destination, error);
5961 "cannot move rejected inbox file " + snapshot.path.string()
5962 +
" to " + destination.string() +
": " + error.message()
5968 parsed_batch& batch, database& product,
const fs::path& rejected
5971 record_issues(product, batch
.batch_id, batch.issues);
5972 }
catch (
const std::exception& error) {
5974 batch,
"issue_storage_error",
"/",
5975 "structured issues could not be stored; rejected file remains in "
5977 + std::string(error.what())
5982 move_rejected_unchanged(batch.file, rejected);
5983 }
catch (
const std::exception& error) {
5985 batch,
"rejected_file_not_moved",
"/",
5986 std::string(error.what())
5995 .path = batch.file.path,
5998 .issues = batch.issues,
6003 const fs::path& repository_root,
const bool apply
6005 const fs::path inbox = repository_root /
"inbox";
6006 const fs::path database_path
6007 = repository_root /
"database" /
"art-islands.sqlite";
6013 auto snapshots = snapshot_inbox(inbox);
6014 std::vector<parsed_batch> batches;
6015 batches.reserve(snapshots.size());
6016 for (
auto& snapshot : snapshots) {
6017 batches.push_back(parse_batch(std::move(snapshot)));
6019 database product
(database_path
, apply
);
6020 reject_duplicate_pending_ids(batches, product
.native());
6022 for (
auto& batch : batches) {
6023 if (!batch.structurally_valid || !batch.issues.empty()) {
6026 if (batch_was_applied(product.native(), batch.batch_id)) {
6027 batch.already_applied =
true;
6030 prevalidate_semantics(batch, product);
6031 if (batch.issues.empty()) {
6032 allocate_local_references(batch, allocation);
6036 inbox_result result;
6038 for (
auto& batch : batches) {
6039 if (!batch.issues.empty()) {
6040 ++result.rejected_count;
6041 if (apply && valid_batch_id(batch.batch_id)) {
6042 record_and_move_rejected(
6043 batch, product, inbox /
"rejected"
6046 result.batches.push_back(
6047 report_for(batch, inbox_batch_status::rejected)
6051 if (batch.already_applied) {
6052 ++result.already_applied_count;
6054 remove_unchanged(batch.file);
6056 result.batches.push_back(
6057 report_for(batch, inbox_batch_status::already_applied)
6062 ++result.valid_count;
6063 result.batches.push_back(
6064 report_for(batch, inbox_batch_status::valid)
6069 apply_one_batch(batch, product);
6070 }
catch (
const std::exception& error) {
6071 if (batch.issues.empty()) {
6072 const std::string message = error.what();
6073 std::string code =
"application_error";
6074 if (batch.application_path.starts_with(
"/merge/")) {
6075 code =
"merge_conflict";
6077 message.find(
"constraint") != std::string::npos
6078 || message.find(
"UNIQUE") != std::string::npos
6079 || message.find(
"CHECK") != std::string::npos
6080 || message.find(
"FOREIGN KEY") != std::string::npos
6082 code =
"constraint_violation";
6083 }
else if (message.find(
"disappeared") != std::string::npos
6084 || message.find(
"changed")
6085 != std::string::npos) {
6086 code =
"concurrent_change";
6088 std::optional<json> rejected_value;
6089 if (!batch.application_value_json.empty()) {
6090 rejected_value = json::parse(
6091 batch.application_value_json
6095 batch, code, batch.application_path, message,
6096 rejected_value ? &*rejected_value :
nullptr
6099 ++result.rejected_count;
6100 record_and_move_rejected(
6101 batch, product, inbox /
"rejected"
6103 result.batches.push_back(
6104 report_for(batch, inbox_batch_status::rejected)
6108 if (batch.already_applied) {
6109 ++result.already_applied_count;
6110 result.batches.push_back(
6111 report_for(batch, inbox_batch_status::already_applied)
6114 ++result.applied_count;
6115 result.batches.push_back(
6116 report_for(batch, inbox_batch_status::applied)
6120
6121
6122
6123
6124
6125 remove_unchanged(batch.file);
6140 return "already_applied";
6157 = repository_root /
"database" /
"art-islands.sqlite";
6159 database product
(path
, true);
database & operator=(database &&)=delete
database & operator=(const database &)=delete
sqlite3 * native() const noexcept
void execute(const std::string_view sql)
database(const database &)=delete
database(const fs::path &path, const bool writable)
database(database &&)=delete
file_descriptor & operator=(const file_descriptor &)=delete
file_descriptor(const int value)
file_descriptor(const file_descriptor &)=delete
sqlite3_stmt * native() const noexcept
statement & operator=(statement &&)=delete
void bind_null(const int index)
statement(sqlite3 *const database, const std::string_view sql)
std::string text(const int column) const
void bind_json_value(const int index, const json &value)
statement(statement &&)=delete
statement(const statement &)=delete
double real(const int column) const
void bind(const int index, const std::int64_t value)
std::int64_t integer(const int column) const
bool is_null(const int column) const
void bind(const int index, const double value)
void bind(const int index, const std::string_view value)
statement & operator=(const statement &)=delete
transaction(database &value)
transaction & operator=(const transaction &)=delete
transaction(const transaction &)=delete
void require_clean_foreign_keys(sqlite3 *const sql)
std::set< std::string, std::less<> > string_set(const json &array)
const std::set< std::string, std::less<> > credit_roles
local_id_allocation_state initial_allocation_state(database &product)
void merge_parent_guides(sqlite3 *const sql, const std::string_view id_column, const std::string &target, const std::string &member)
std::set< std::string, std::less<> > trigrams(const std::string_view value)
void prevalidate_integer_or_local_reference(parsed_batch &batch, sqlite3 *const database_value, const json &record, const std::string &key, const std::string &path, const std::string_view local_family, const std::string_view table)
void validate_create_manifestation(parsed_batch &batch, const json &value, const std::string &path, std::unordered_map< std::string, std::string > &locals)
void add_issue(parsed_batch &batch, const std::string_view code, const std::string_view path, const std::string_view message, const json *const value=nullptr)
bool valid_slug(const std::string_view value)
const std::set< std::string, std::less<> > concept_types
constexpr int current_product_schema
void validate_create_financial(parsed_batch &batch, const json &value, const std::string &path)
void validate_delete_object(parsed_batch &batch, const json &update, const std::string &path)
void require_real_database_file(const fs::path &path)
std::string pointer_escape(const std::string_view value)
bool row_exists(sqlite3 *const database_value, const std::string_view table, const std::string_view column, const json &id)
const std::set< std::string, std::less<> > media
bool valid_local_id(const std::string_view value)
void reject_duplicate_pending_ids(std::vector< parsed_batch > &batches, sqlite3 *const sql)
void require_integer_range(parsed_batch &batch, const json &object, const std::string &key, const std::string &path, const std::int64_t minimum, const std::int64_t maximum)
const std::set< std::string, std::less<> > historical_roles
const std::map< std::string, value_kind, std::less<> > source_mutable
const std::set< std::string, std::less<> > manifestation_types
void regenerate_incremental_hints(sqlite3 *const sql, const std::set< std::string, std::less<> > &affected)
void insert_hint_blocks(sqlite3 *const sql, const hint_label &label)
const std::map< std::string, value_kind, std::less<> > agent_merge_mutable
bool is_trigram_block(const std::string_view block_type)
inbox_result run_inbox(const fs::path &repository_root, const bool apply)
const std::set< std::string, std::less<> > importance_values
void update_row(sqlite3 *const sql, const std::string_view table, const std::string_view id_column, const json &operation)
void validate_array(parsed_batch &batch, const json &parent, const std::string &key, const std::string &parent_path, Validator &&validator)
std::string formatted_entity_id(const std::string_view family, const std::int64_t sequence)
void validate_create_name(parsed_batch &batch, const json &value, const std::string &path)
void apply_one_batch(parsed_batch &batch, database &product)
void merge_entity_scoped_rows(sqlite3 *const sql, const std::string &target, const std::string &member)
void merge_agents(parsed_batch &batch, sqlite3 *const sql, const json &operation)
bool batch_was_applied(sqlite3 *const sql, const std::string &batch_id)
double jaccard(const std::set< std::string, std::less<> > &left, const std::set< std::string, std::less<> > &right)
const std::set< std::string, std::less<> > name_types
bool snapshot_still_current(const file_snapshot &snapshot)
std::string ref_string(const json &record, const char *key)
std::pair< std::string, std::string > hint_pair
parsed_batch parse_batch(file_snapshot snapshot)
std::optional< hint_label > hint_label_for_id(sqlite3 *const sql, const std::string &id)
void validate_create_parent_guide(parsed_batch &batch, const json &value, const std::string &path, std::unordered_map< std::string, std::string > &locals)
void validate_create_work(parsed_batch &batch, const json &value, const std::string &path, std::unordered_map< std::string, std::string > &locals)
json parse_strict_json(const std::string &bytes)
void validate_create_external_id(parsed_batch &batch, const json &value, const std::string &path)
bool real_directory(const fs::path &path)
std::string trigram_support_key(const std::string_view block_key)
void merge_financial_facts(sqlite3 *const sql, const std::string &target, const std::string &member)
inbox_batch_report report_for(const parsed_batch &batch, const inbox_batch_status status)
void create_names_and_identifiers(parsed_batch &batch, sqlite3 *const sql)
const std::map< std::string, value_kind, std::less<> > work_mutable
void validate_create_agent(parsed_batch &batch, const json &value, const std::string &path, std::unordered_map< std::string, std::string > &locals)
void validate_document_shape(parsed_batch &batch)
void validate_create_concept(parsed_batch &batch, const json &value, const std::string &path, std::unordered_map< std::string, std::string > &locals)
void apply_updates(parsed_batch &batch, sqlite3 *const sql)
void merge_works(parsed_batch &batch, sqlite3 *const sql, const json &operation)
const std::set< std::string, std::less<> > source_types
double maximum_text_similarity(const std::vector< std::string > &left, const std::vector< std::string > &right)
void insert_hint_block(statement &block, statement &member, const hint_label &label, const std::string_view block_type, const std::string &block_key)
constexpr std::uintmax_t maximum_batch_bytes
void upsert_hint(sqlite3 *const sql, const hint_label &first, const hint_label &second)
void ensure_product_database(const fs::path &repository_root, const fs::path &path)
void apply_deletes(parsed_batch &batch, sqlite3 *const sql)
double alternate_text_similarity(const hint_label &left, const hint_label &right)
void check_keys(parsed_batch &batch, const json &object, const std::string &path, const std::set< std::string, std::less<> > &allowed, const std::set< std::string, std::less<> > &required={})
void create_facts(parsed_batch &batch, sqlite3 *const sql)
void validate_integer_or_local_reference_shape(parsed_batch &batch, const json &record, const std::string &key, const std::string &path)
void require_nonempty_string(parsed_batch &batch, const json &object, const std::string &key, const std::string &path)
void create_sources_and_evidence(parsed_batch &batch, sqlite3 *const sql)
void(*)( parsed_batch &, const json &, const std::string &) record_validator
std::set< std::string, std::less<> > query_value_set(sqlite3 *const sql, const std::string_view query_text, const std::string &id)
void validate_create_measurement(parsed_batch &batch, const json &value, const std::string &path)
file_snapshot read_batch_file(const fs::path &path)
void require_current_product_structure(sqlite3 *const sql)
const std::map< std::string, value_kind, std::less<> > agent_mutable
void resolve_scalar_fields(sqlite3 *const sql, const std::string_view table, const std::string_view id_column, const std::string &target, const std::vector< std::string > &members, const json &operation, const std::vector< std::string > &columns, const std::set< std::string, std::less<> > &required)
std::string read_schema(const fs::path &path)
bool valid_batch_id(const std::string_view value)
const std::set< std::string, std::less<> > work_concept_types
void merge_work_concepts(sqlite3 *const sql, const std::string_view id_column, const std::string &target, const std::string &member)
void validate_create_evidence(parsed_batch &batch, const json &value, const std::string &path, std::unordered_map< std::string, std::string > &locals)
void validate_update_record(parsed_batch &batch, const json &value, const std::string &path, const std::string_view family, const std::map< std::string, value_kind, std::less<> > &mutable_fields, const std::set< std::string, std::less<> > &required_fields, const std::map< std::string, std::set< std::string, std::less<> >, std::less<> > &enum_fields, const bool allow_empty=false, const bool integer_id=false)
std::string normalized_label(const std::string_view value)
void mark_merge_neighborhood(parsed_batch &batch, sqlite3 *const sql, const std::string_view family, const std::string &target, const std::vector< std::string > &members)
void validate_create_source(parsed_batch &batch, const json &value, const std::string &path, std::unordered_map< std::string, std::string > &locals)
hint_components calculate_hint_components(sqlite3 *const sql, const hint_label &left, const hint_label &right)
const std::set< std::string, std::less<> > guide_categories
void allocate_local_references(parsed_batch &batch, local_id_allocation_state &sequences)
bool query_exists(sqlite3 *const sql, const std::string_view query_text, const std::string &target, const std::string &member)
const std::set< std::string, std::less<> > stances
std::int64_t maximum_row_id(sqlite3 *const sql, const std::string_view table)
const std::set< std::string, std::less<> > concept_relation_types
void validate_merge_record(parsed_batch &batch, const json &value, const std::string &path, const std::string_view family, const std::map< std::string, value_kind, std::less<> > &mutable_fields, const std::set< std::string, std::less<> > &required_fields, const std::map< std::string, std::set< std::string, std::less<> >, std::less<> > &enum_fields, std::map< std::string, std::string, std::less<> > &merged_entities)
void create_assertions(parsed_batch &batch, sqlite3 *const sql)
void validate_entity_reference_shape(parsed_batch &batch, const json &record, const std::string &key, const std::string &path)
std::size_t rebuild_hints(database &product)
std::int64_t maximum_entity_suffix(sqlite3 *const sql, const std::string_view prefix)
std::string resolve_entity(const parsed_batch &batch, const json &value)
const std::set< std::string, std::less<> > spoiler_levels
void remove_unchanged(const file_snapshot &snapshot)
std::string indexed_path(const std::string_view parent, const std::size_t index)
void validate_evidence_list(parsed_batch &batch, const json &value, const std::string &path)
void require_enum(parsed_batch &batch, const json &object, const std::string &key, const std::string &path, const std::set< std::string, std::less<> > &allowed)
void validate_create_concept_relation(parsed_batch &batch, const json &value, const std::string &path, std::unordered_map< std::string, std::string > &locals)
const std::set< std::string, std::less<> > agent_types
std::int64_t resolve_row_reference(const std::unordered_map< std::string, std::int64_t > &locals, const json &value)
std::set< hint_pair > all_blocked_pairs(sqlite3 *const sql)
file_identity identity_of(const struct stat &state)
void validate_create_credit(parsed_batch &batch, const json &value, const std::string &path)
void record_and_move_rejected(parsed_batch &batch, database &product, const fs::path &rejected)
void merge_credits(sqlite3 *const sql, const std::string_view id_column, const std::string &target, const std::string &member)
std::string contextual_block_key(const std::string_view context, const std::string_view value)
std::string sql_column(const std::string_view field)
void validate_local_id(parsed_batch &batch, const json &record, const std::string &path, std::unordered_map< std::string, std::string > &locals, const std::string_view family)
void refresh_hint_blocks(sqlite3 *const sql, const std::set< std::string, std::less<> > &affected, std::map< std::string, hint_label, std::less<> > &label_cache)
void prevalidate_semantics(parsed_batch &batch, database &product)
std::vector< file_snapshot > snapshot_inbox(const fs::path &inbox)
void record_issues(database &product, const std::string &batch_id, const std::vector< inbox_issue > &issues)
void require_number_range(parsed_batch &batch, const json &object, const std::string &key, const std::string &path, const double minimum, const double maximum)
bool valid_canonical_id(const std::string_view value, const std::string_view family)
void transfer_assertion_evidence(sqlite3 *const sql, const std::string_view evidence_table, const std::int64_t keeper, const std::int64_t duplicate)
std::int64_t next_sequence(std::int64_t &sequence, const std::string_view description)
double trigram_score(const std::string_view left, const std::string_view right)
void merge_concept_relations(sqlite3 *const sql, const std::string &target, const std::string &member)
const std::map< std::string, value_kind, std::less<> > manifestation_mutable
std::size_t intersection_count(const std::set< std::string, std::less<> > &left, const std::set< std::string, std::less<> > &right)
std::vector< hint_label > all_hint_labels(sqlite3 *const sql)
void bootstrap_hint_blocks_if_empty(sqlite3 *const sql)
void prevalidate_entity_reference(parsed_batch &batch, sqlite3 *const database_value, const json &record, const std::string &key, const std::string &path, const std::string_view expected_family={})
std::set< hint_pair > reviewable_blocked_pairs(const hint_candidate_map &candidates)
void validate_create_work_concept(parsed_batch &batch, const json &value, const std::string &path, std::unordered_map< std::string, std::string > &locals)
void require_real_directory(const fs::path &path, const std::string_view description)
void reject_preferred_name_conflict(sqlite3 *const sql, const std::string &target, const std::vector< std::string > &members)
std::vector< std::string > merge_members(const json &operation)
void set_application_context(parsed_batch &batch, const std::string_view path, const json &value)
void bind_optional(statement &insert, const int index, const json &row, const char *key, const bool serialize=false)
std::size_t maximum_hint_block_size(const std::string_view block_type)
std::string entity_family(sqlite3 *const database_value, const std::string &id)
void move_rejected_unchanged(const file_snapshot &snapshot, const fs::path &rejected)
std::set< hint_pair > incrementally_blocked_pairs(sqlite3 *const sql, const std::set< std::string, std::less<> > &affected)
std::string sqlite_message(sqlite3 *const value, const std::string_view operation)
bool is_kind(const json &value, const value_kind kind)
void merge_concepts(parsed_batch &batch, sqlite3 *const sql, const json &operation)
bool looks_like_canonical_entity_id(const std::string_view value)
void apply_merges(parsed_batch &batch, sqlite3 *const sql)
void delete_merged_entity(sqlite3 *const sql, const std::string &member)
const std::set< std::string, std::less<> > measurement_units
void create_entities(parsed_batch &batch, sqlite3 *const sql)
void record_hint_candidate(hint_candidate_map &candidates, const std::string &first, const std::string &second, const std::string_view block_type, const std::string_view block_key)
const std::map< std::string, value_kind, std::less<> > concept_mutable
const std::set< std::string, std::less<> > date_precisions
bool same_identity(const file_identity &left, const file_identity &right)
void attach_evidence(const parsed_batch &batch, sqlite3 *const sql, const std::string_view table, const std::int64_t assertion_id, const json &evidence)
const std::set< std::string, std::less<> > measurement_types
void require_kind(parsed_batch &batch, const json &object, const std::string &key, const std::string &path, const value_kind kind)
inbox_result apply_product_inbox(const std::filesystem::path &repository_root)
const char * to_string(inbox_batch_status status) noexcept
inbox_result check_product_inbox(const std::filesystem::path &repository_root)
std::size_t rebuild_product_merge_hints(const std::filesystem::path &repository_root)
std::string relation_type
std::set< std::string, std::less<> > shared_trigrams
std::vector< std::string > fingerprints
std::vector< std::string > alternate_fingerprints
std::vector< std::string > preferred_fingerprints
std::int64_t evidence_sequence
std::map< std::string, std::int64_t, std::less<> > entity_sequences
std::map< std::string, std::int64_t, std::less<> > assertion_sequences
std::int64_t source_sequence
std::unordered_map< std::string, std::int64_t > evidence_local_ids
std::unordered_map< std::string, std::int64_t > source_local_ids
std::unordered_map< std::string, std::string > entity_local_ids
std::unordered_map< std::string, std::string > resolved_entity_ids
std::vector< inbox_issue > issues
std::unordered_map< std::string, std::int64_t > assertion_local_ids
std::string application_value_json
std::string application_path
std::set< std::string, std::less<> > affected_entities
std::size_t rejected_count