diff --git a/.gitignore b/.gitignore index b0d6104..e47c171 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,7 @@ pom.xml.asc .cpcache/ /*.db .lsp/.cache/db.transit.json +api-key.txt +cache/ +*.zip +.eastwood diff --git a/CHANGELOG.md b/CHANGELOG.md index ee5ce74..c82c6cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # CHANGELOG +# Unreleased (store schema v2) + +Databases must be rebuilt: v1 databases (and older legacy formats) are +rejected at open with a clear error. + +* Import the HISTORIC_CODES (deprecated code → live code map) and + VTM_INGREDIENTS files from the supplementary (bonus) distribution +* Store all fields defined by the dm+d schema that were previously dropped, + including invalidity flags on every product type, previous identifiers, + ingredient denominator strengths, licensing authority changes, flavour, + colour, and lookup code history (CDDT/CDPREV) +* New history APIs: `fetch-history`, `previous-ids`, `current-ids` map + deprecated identifiers to their current equivalents and vice versa +* New `status` API returning store version, creation time, release date, + TRUD provenance, source file inventory and entity counts +* Strict `open-store`: validates SQLite application_id (0x646D2B64 'dm+d') + and store version, with descriptive errors; read-only pooled DataSource +* New full-text product name search via `search` (FTS5; tokenized prefix + matching); new `plan-products` for streaming iteration over all products; + `lookup-types` enumerates all lookup tables +* Remove unused pathom3 graph API and unused dependencies + # v1.0.208 - 2025-01-23 * Use a SQLite DataSource rather than a Connection in case used by multiple threads concurrently diff --git a/README.md b/README.md index 44bd7ab..e72c629 100644 --- a/README.md +++ b/README.md @@ -545,6 +545,24 @@ com.eldrix/dmd {:git/url "https://github.com/wardle/dmd.git" :sha "XXX"} ``` +A dm+d database file is self-describing. `dmd-database?` identifies a file as +a dm+d database from its SQLite header (application_id 0x646D2B64, "dm+d") +without opening it; `open-store` validates both that marker and the store +schema version, returning a read-only pooled DataSource; and `status` returns +the store version, dm+d release date, source TRUD provenance and entity +counts. Each release should be built into a fresh database file; databases +created by older versions of this library are rejected at open and should be +rebuilt. + +```clojure +(require '[com.eldrix.dmd.core :as dmd]) +(def st (dmd/open-store "dmd-2025-01-16.db")) +(dmd/status st) ;; => {:version 2 :release #object[java.time.LocalDate "2025-01-16"] ...} +(dmd/search st "amlodip" :types #{:VMP}) ;; full-text product name search +(dmd/previous-ids st 86389004) ;; => #{9906511000001107} - deprecated ids for a live code +(dmd/current-ids st 9906511000001107) ;; => #{86389004} - live ids for a deprecated code +``` + # Frequently asked questions ### What is the use of `dmd`? diff --git a/cmd/com/eldrix/dmd/cli.clj b/cmd/com/eldrix/dmd/cli.clj index 695d145..85e40a1 100644 --- a/cmd/com/eldrix/dmd/cli.clj +++ b/cmd/com/eldrix/dmd/cli.clj @@ -63,9 +63,10 @@ (defn run-server [{:keys [db port] :or {port "8080"}}] (if db - (let [st (dmd/open-store db)] + (let [st (dmd/open-store db) + {:keys [version release counts]} (dmd/status st)] (log/info "starting server using " db " on port" port) - (log/info "UK dm+d release:" (.format java.time.format.DateTimeFormatter/ISO_DATE (dmd/fetch-release-date st))) + (log/info "UK dm+d release:" (str release) "(store version" (str version ")") counts) (try (serve/start-server st (Integer/parseInt port)) (catch NumberFormatException e (log/error "invalid port:" port)))) diff --git a/deps.edn b/deps.edn index 97c1a1c..b2e7b73 100644 --- a/deps.edn +++ b/deps.edn @@ -2,8 +2,6 @@ :deps {org.clojure/clojure {:mvn/version "1.12.0"} org.clojure/core.async {:mvn/version "1.7.701"} - org.clojure/core.match {:mvn/version "1.1.0"} - org.clojure/data.csv {:mvn/version "1.1.0"} org.clojure/data.json {:mvn/version "2.5.1"} org.clojure/data.xml {:mvn/version "0.2.0-alpha9"} org.clojure/data.zip {:mvn/version "1.1.0"} @@ -14,8 +12,7 @@ com.github.seancorfield/honeysql {:mvn/version "2.6.1270"} com.github.seancorfield/next.jdbc {:mvn/version "1.3.981"} org.xerial/sqlite-jdbc {:mvn/version "3.48.0.0"} - com.zaxxer/HikariCP {:mvn/version "6.2.1"} - com.wsscode/pathom3 {:mvn/version "2025.01.16-alpha"}} + com.zaxxer/HikariCP {:mvn/version "6.2.1"}} :aliases {:build {:deps {io.github.clojure/tools.build {:git/tag "v0.10.6" :git/sha "52cf7d6"} diff --git a/src/com/eldrix/dmd/core.clj b/src/com/eldrix/dmd/core.clj index 1f4c89e..5891e6d 100644 --- a/src/com/eldrix/dmd/core.clj +++ b/src/com/eldrix/dmd/core.clj @@ -40,9 +40,13 @@ releases (dl/download-release api-key cache-dir release-date) _ (log/info "Downloaded dm+d releases " releases) unzipped (doall (map #(trud/unzip-nested (:archiveFilePath %)) releases)) - filename' (if filename filename (str "dmd-" (.format DateTimeFormatter/ISO_LOCAL_DATE (:releaseDate (first releases))) ".db"))] + filename' (if filename filename (str "dmd-" (.format DateTimeFormatter/ISO_LOCAL_DATE (:releaseDate (first releases))) ".db")) + trud-info (mapv (fn [release] + (-> (select-keys release [:itemIdentifier :id :name :releaseDate :archiveFileName]) + (update :releaseDate str))) + releases)] (log/info "Creating dm+d file-based database :" filename') - (install-from-dirs filename' (map #(.toFile ^java.nio.file.Path %) unzipped)) + (install-from-dirs filename' (map #(.toFile ^java.nio.file.Path %) unzipped) :trud trud-info) ;(zipfile/delete-paths unzipped) (log/info "Created dm+d file-based database :" filename')))) @@ -53,28 +57,95 @@ (install-release api-key-file cache-dir)) (defn open-store - "Open a dm+d store. Returns what should be regarded as an opaque handle, - that should be closed using `close`. Currently this is a DataSource but - this is subject to change. `f` can be anything coercible to a file using + "Open a dm+d store. Returns what should be regarded as an opaque handle, + that should be closed using `close`. Currently this is a DataSource but + this is subject to change. `f` can be anything coercible to a file using [[clojure.java.io/as-file]]. Throws an exception if the file does not exist." [f] (st4/open-store f)) +(defn sqlite-database? + "Returns true if `f` is a SQLite 3 database file." + [f] + (st4/sqlite-database? f)) + +(defn dmd-database? + "Returns true if `f` is a dm+d SQLite database created by this library. + Strict: legacy dm+d files predating the application_id marker return false." + [f] + (st4/dmd-database? f)) + (defn close [st] (st4/close st)) (defn fetch-release-date [store] (st4/fetch-release-date store)) +(defn status + "Returns a structured description of an open dm+d store, including store + schema version, creation date, dm+d release date, source TRUD release + information and file inventory when available, and entity counts." + [store] + (st4/status store)) + (defn fetch-product [store product-id] (st4/fetch-product store product-id)) (defn fetch-product-by-exact-name [conn nm] (st4/fetch-product-by-exact-name conn nm)) +(defn search + "Search product names, returning a sequence of maps of :SEARCH/ID, + :SEARCH/TYPE and :SEARCH/NM, best matches first. Each token is matched as + a prefix; multiple tokens must all match. + Options: + - :types - product types to include e.g. #{:VMP :AMP}; default, all + - :limit - maximum number of results; default 100" + [conn s & {:keys [_types _limit] :as opts}] + (st4/search conn s opts)) + (defn fetch-lookup [conn lookup-kind] (st4/fetch-all-lookup conn lookup-kind)) +(def lookup-types + "Set of lookup types, as keywords, usable with [[fetch-lookup]]." + st4/lookup-types) + +(defn fetch-history + "Returns all history entries in which `id` is the current identifier, + ordered by start date, including 'self' entries recording the period of + validity of the current identifier itself." + [conn id] + (st4/fetch-history conn id)) + +(defn previous-ids + "Returns the set of prior identifiers for the given current identifier." + [conn id] + (st4/previous-ids conn id)) + +(defn current-ids + "Returns the set of identifiers in current use for the given (usually + historic) identifier." + [conn id] + (st4/current-ids conn id)) + +(defn vtm-ingredients + "Returns ingredient (ISID) identifiers for the given VTM." + [conn vtmid] + (st4/vtm-ingredients conn vtmid)) + +(defn vtms-for-ingredient + "Returns VTM identifiers for the given ingredient." + [conn isid] + (st4/vtms-for-ingredient conn isid)) + +(defn plan-products + "Returns a reducible over all rows of the given product type (:VTM :VMP + :AMP :VMPP or :AMPP), for streaming iteration; each row is a `next.jdbc` + row abstraction with columns accessible by keyword." + [conn product-type] + (st4/plan-products conn product-type)) + (defn ^:deprecated vmps-from-atc "DEPRECATED: use [[vpids-from-atc]] instead." [conn atc] diff --git a/src/com/eldrix/dmd/graph.clj b/src/com/eldrix/dmd/graph.clj deleted file mode 100644 index 3c5ca11..0000000 --- a/src/com/eldrix/dmd/graph.clj +++ /dev/null @@ -1,130 +0,0 @@ -(ns com.eldrix.dmd.graph - "Provides a graph API around UK dm+d structures." - (:require - [com.eldrix.dmd.core :as dmd] - [com.wsscode.pathom3.connect.operation :as pco] - [com.wsscode.pathom3.connect.indexes :as pci] - [com.wsscode.pathom3.connect.built-in.resolvers :as pbir] - [com.wsscode.pathom3.interface.eql :as p.eql])) - -(defn namespace-map - "Add the namespace to all keywords, recursing into the map." - [n x] - (if-not (map? x) - x - (reduce-kv - (fn [m k v] - (assoc m - (if-not (keyword? k) - k - (keyword n (name k))) - (cond - (map? v) (namespace-map n v) - (coll? v) (map #(namespace-map n %) v) - :else v))) {} x))) - -(pco/defresolver product-from-snomed-identifier - "Returns a UK product by identifier. - Fortunately, there is a 1:1 mapping between dm+d product codes and SNOMED CT - identifiers. This means that as long as we output SNOMED namespaced - identifiers, client applications can navigate seamlessly between dm+d and - SNOMED." - [{::keys [store]} {:info.snomed.Concept/keys [id]}] - {::pco/output [:uk.nhs.dmd/VTMID :uk.nhs.dmd/VPID :uk.nhs.dmd/VPPID - :uk.nhs.dmd/APID :uk.nhs.dmd/APPID :uk.nhs.dmd/ISID - :uk.nhs.dmd/NM :uk.nhs.dmd/TYPE]} - (namespace-map "uk.nhs.dmd" (dmd/fetch-product store id))) - -(pco/defresolver fetch-vtm - [{::keys [store]} {:uk.nhs.dmd/keys [VTMID]}] - {::pco/output [:uk.nhs.dmd/VTMID - :uk.nhs.dmd/TYPE - :uk.nhs.dmd/NM - :uk.nhs.dmd/INVALID - :uk.nhs.dmd/ABBREVNM - :uk.nhs.dmd/VTMIDPREV - :uk.nhs.dmd/VTMIDDT]} - (namespace-map "uk.nhs.dmd" (dmd/fetch-product store VTMID))) - -(def code-properties - [:uk.nhs.dmd/CD - :uk.nhs.dmd/CDDT - :uk.nhs.dmd/CDPREV - :uk.nhs.dmd/DESC]) - -(pco/defresolver fetch-extended-vmp - [{::keys [store]} {:uk.nhs.dmd/keys [VPID]}] - {::pco/output [:uk.nhs.dmd/VPID - :uk.nhs.dmd/VTMID - {:uk.nhs.dmd/VTM [:uk.nhs.dmd/VTMID :uk.nhs.dmd/NM]} - :uk.nhs.dmd/NM - {:uk.nhs.dmd/UNIT_DOSE_UOM code-properties} - {:uk.nhs.dmd/DRUG_ROUTE code-properties} - :uk.nhs.dmd/BNF :uk.nhs.dmd/ATC - {:uk.nhs.dmd/CONTROL_DRUG_INFO code-properties} - {:uk.nhs.dmd/ONT_DRUG_FORM code-properties} - :uk.nhs.dmd/VPIDPREV :uk.nhs.dmd/DDD - {:uk.nhs.dmd/PRES_STAT code-properties} - :uk.nhs.dmd/PRES_STATCD - {:uk.nhs.dmd/UDFS_UOM code-properties} - {:uk.nhs.dmd/DF_IND code-properties} - {:uk.nhs.dmd/BASIS code-properties} - {:uk.nhs.dmd/VIRTUAL_PRODUCT_INGREDIENT [:uk.nhs.dmd/ISID - :uk.nhs.dmd/ISIDPREV - :uk.nhs.dmd/STRNT_NMRTR_VAL - :uk.nhs.dmd/NM - :uk.nhs.dmd/ISIDDT - {:uk.nhs.dmd/STRNT_NMRTR_UOM code-properties} - {:uk.nhs.dmd/BASIS_STRNT code-properties} - :uk.nhs.dmd/STRNT_NMRTR_UOMCD - :uk.nhs.dmd/BASIS_STRNTCD]} - :uk.nhs.dmd/DDD_UOMCD - :uk.nhs.dmd/BASISCD - :uk.nhs.dmd/UNIT_DOSE_UOMCD - {:uk.nhs.dmd/DRUG_FORM code-properties}]} - (namespace-map "uk.nhs.dmd" (dmd/fetch-product store VPID))) - -(pco/defresolver fetch-extended-amp - [{::keys [store]} {:uk.nhs.dmd/keys [APID]}] - {::pco/output [:uk.nhs.dmd/VPID]} - (namespace-map "uk.nhs.dmd" (dmd/fetch-product store APID))) - -(pco/defresolver vmps-for-product - [{::keys [store]} {:uk.nhs.dmd/keys [ID]}] - {::pco/output [{:uk.nhs.dmd/VMPS [:uk.nhs.dmd/VPID]}]} - {:uk.nhs.dmd/VMPS (dmd/vmps-for-product store ID)}) - -(def all-resolvers - "dm+d resolvers; each expects an environment that contains - a key `:com.eldrix.dmd.graph/store` representing a dm+d store." - [product-from-snomed-identifier - fetch-vtm - fetch-extended-vmp - fetch-extended-amp - vmps-for-product - ;; these alias-resolvers make it possible to navigate seamlessly from a dm+d - ;; product to the SNOMED structures, including relationships and therefore - ;; inference - (pbir/alias-resolver :uk.nhs.dmd/VTMID :info.snomed.Concept/id) - (pbir/alias-resolver :uk.nhs.dmd/VPID :info.snomed.Concept/id) - (pbir/alias-resolver :uk.nhs.dmd/APID :info.snomed.Concept/id) - (pbir/alias-resolver :uk.nhs.dmd/VPPID :info.snomed.Concept/id) - (pbir/alias-resolver :uk.nhs.dmd/APPID :info.snomed.Concept/id) - (pbir/alias-resolver :uk.nhs.dmd/ISID :info.snomed.Concept/id)]) - -(comment - (def store (dmd/open-store "dmd-2021-09-06.db")) - (def registry (-> (pci/register all-resolvers) - (assoc ::store store))) - (p.eql/process registry - [{[:uk.nhs.dmd/VTMID 108537001] - '[* :uk.nhs.dmd/NM]}]) - (namespace-map "uk.nhs.dmd" (dmd/fetch-product store 108537001)) ;; VTM - (map #(namespace-map "uk.nhs.dmd" %) (dmd/vmps-for-product store 108537001)) - - (p.eql/process registry - [{[:uk.nhs.dmd/VPID 39731911000001109] - [:uk.nhs.dmd/VTMID - :uk.nhs.dmd/NM - :uk.nhs.dmd/VMPS - :info.snomed.Concept/id]}])) diff --git a/src/com/eldrix/dmd/import.clj b/src/com/eldrix/dmd/import.clj index 27c845f..c7b9c9c 100644 --- a/src/com/eldrix/dmd/import.clj +++ b/src/com/eldrix/dmd/import.clj @@ -31,7 +31,7 @@ (def ^:private file-ordering "Order of file import for relational integrity, if needed." - [:LOOKUP :INGREDIENT :VTM :VMP :AMP :VMPP :AMPP :GTIN :BNF]) + [:LOOKUP :INGREDIENT :VTM :VMP :AMP :VMPP :AMPP :GTIN :BNF :HISTORY :VTM_ING]) (def ^DateTimeFormatter df (DateTimeFormatter/ofPattern "ddMMyy")) @@ -39,17 +39,18 @@ (def ^:private file-matcher "There is no formal specification for filename structure, but this is the de facto standard." - #"^f_([a-z]*)\d_\d(\d{6})\.xml$") + #"^f_([a-z_]*)\d_\d(\d{6})\.xml$") (defn ^:private parse-dmd-filename - "Parse a dm+d filename if possible." + "Parse a dm+d filename if possible. Files of unknown type sort last." [f] (let [f2 (clojure.java.io/as-file f)] (when-let [[_ nm date] (re-matches file-matcher (.getName f2))] - (let [kw (keyword (str/upper-case nm))] + (let [kw (keyword (str/upper-case nm)) + order (.indexOf ^List file-ordering kw)] {:type kw :date (LocalDate/parse date df) - :order (.indexOf ^List file-ordering kw) + :order (if (neg? order) Integer/MAX_VALUE order) :file f2})))) (defn should-include? @@ -185,6 +186,7 @@ :PX_CHRGS parse-flag :DISP_FEES parse-flag ;; unlike the documentation, this is actually a flag (1 or omitted). :BB parse-flag + :LTD_STAB parse-flag :CAL_PACK parse-flag :SPEC_CONTCD unsafe-parse-long :FP34D parse-flag @@ -204,7 +206,11 @@ :DDD_UOMCD unsafe-parse-long :DDD unsafe-parse-double :STARTDT parse-date - :ENDDT parse-date}) + :ENDDT parse-date + + ;; historic codes + :IDCURRENT unsafe-parse-long + :IDPREVIOUS unsafe-parse-long}) (defn- parse-property [k v] (if-let [parser (get property-parsers k)] @@ -284,6 +290,28 @@ (a/> entities (map :index) (remove nil?) (run! #(jdbc/execute! conn [%])))) + (->> entities + (mapcat (fn [{:keys [index]}] (if (string? index) [index] index))) + (run! #(jdbc/execute! conn [%])))) (defn batch->sql "For the given batch of dm+d entities, return a map of :stmts and :errors @@ -299,13 +367,83 @@ {} (group-by :TYPE batch))) +;; First 16 bytes of every SQLite 3 file: the literal "SQLite format 3\000". +(def ^:private ^"[B" sqlite-magic + (byte-array (map byte (concat "SQLite format 3" [0])))) + +(defn- read-sqlite-header + "Read the 100-byte SQLite header. Returns the byte array, or nil if the + file does not exist, is not a regular file, or is shorter than 100 bytes." + ^"[B" [f] + (let [file (io/file f)] + (when (and (.isFile file) (>= (.length file) 100)) + (with-open [in (io/input-stream file)] + (let [buf (byte-array 100)] + (when (= 100 (.readNBytes in buf 0 100)) buf)))))) + +(defn- header-int32 + "Read a big-endian unsigned 32-bit int at offset `off`." + ^long [^bytes header ^long off] + (bit-or (bit-shift-left (bit-and 0xFF (aget header off)) 24) + (bit-shift-left (bit-and 0xFF (aget header (+ off 1))) 16) + (bit-shift-left (bit-and 0xFF (aget header (+ off 2))) 8) + (bit-and 0xFF (aget header (+ off 3))))) + +(defn- sqlite-magic? [^bytes header] + (java.util.Arrays/equals (java.util.Arrays/copyOfRange header 0 16) sqlite-magic)) + +(defn sqlite-database? + "Returns true if `f` is a SQLite 3 database file (checked by header magic)." + [f] + (boolean (some-> (read-sqlite-header f) sqlite-magic?))) + +(defn dmd-database? + "Returns true if `f` is a dm+d SQLite database created by this library + (SQLite magic + application_id == 0x646D2B64). Strict by default: legacy + dm+d databases predating application_id support return false." + [f] + (boolean (when-let [header (read-sqlite-header f)] + (and (sqlite-magic? header) + (= application-id (header-int32 header 68)))))) + +(defn- pool + ^HikariDataSource [filename {:keys [maximum-pool-size init-sql]}] + (connection/->pool HikariDataSource + (cond-> {:jdbcUrl (str "jdbc:sqlite:" filename) + :maximumPoolSize (or maximum-pool-size 4)} + init-sql (assoc :connectionInitSql init-sql)))) + +(defn- read-pragma [conn pragma] + (some-> (jdbc/execute-one! conn [(str "PRAGMA " pragma)]) vals first)) + (defn open-store + "Open a read-only dm+d store, returning a pooled DataSource that supports + concurrent use. Throws an exception if the file does not exist, is not a + dm+d database (checked via SQLite application_id), or was created with an + incompatible store version (checked via SQLite user_version). Legacy + databases, including those predating application_id support, are rejected + and must be rebuilt using this version of the library." [filename] - (if (.exists (io/file filename)) - (jdbc/get-datasource (str "jdbc:sqlite:" filename)) - (throw (ex-info (str "file not found:" filename) {})))) - -(defn close "Close the store." [_]) ;; NOP in current implementation + (when-not (.exists (io/file filename)) + (throw (ex-info (str "file not found: " filename) {:filename filename}))) + (when-not (dmd-database? filename) + (throw (ex-info (str (if (sqlite-database? filename) + "not a dm+d database (or a legacy dm+d database that must be rebuilt): " + "not a SQLite database: ") filename) + {:filename filename}))) + (let [ds (pool filename {:init-sql "PRAGMA query_only = 1"}) + version (read-pragma ds "user_version")] + (when-not (= store-version version) + (.close ds) + (throw (ex-info (str "incompatible dm+d database version: found " version + ", expected " store-version "; rebuild the database using this version of dmd") + {:filename filename :version version :expected store-version}))) + ds)) + +(defn close + "Close the store, releasing pooled connections." + [^HikariDataSource ds] + (when ds (.close ds))) (s/fdef fetch-release-date :args (s/cat :conn ::conn)) @@ -313,25 +451,66 @@ [conn] (some-> (:METADATA/release (jdbc/execute-one! conn ["select release from metadata"])) LocalDate/parse)) +(def ^:private count-tables + "Tables counted by [[status]], as a vector of key and table name." + [[:VTM "VTM"] [:VMP "VMP"] [:AMP "AMP"] [:VMPP "VMPP"] [:AMPP "AMPP"] + [:INGREDIENT "INGREDIENT"] [:HISTORY "HISTORY"] [:VTM_INGREDIENT "VTM__INGREDIENT"] + [:GTIN "GTIN__AMPP"] [:BNF "BNF_DETAILS"]]) + +(s/fdef status + :args (s/cat :conn ::conn)) +(defn status + "Returns a structured description of an open dm+d store: + - :version - store (schema) version + - :created - java.time.LocalDateTime the database was created + - :release - java.time.LocalDate of the dm+d release + - :trud - source TRUD release information, when installed via TRUD + - :files - inventory of imported source files + - :counts - a map of entity type to row count" + [conn] + (let [{:METADATA/keys [version created release trud]} (jdbc/execute-one! conn ["select * from metadata"])] + {:version version + :created (some-> created LocalDateTime/parse) + :release (some-> release LocalDate/parse) + :trud (some-> trud (json/read-str :key-fn keyword)) + :files (mapv (fn [{:FILES/keys [type name date]}] + {:type (keyword type) :name name :date (some-> date LocalDate/parse)}) + (jdbc/execute! conn ["select * from files"])) + :counts (reduce (fn [acc [k table]] + (assoc acc k (:n (jdbc/execute-one! conn [(str "select count(*) as n from " table)])))) + {} count-tables)})) + (defn create-store - [filename dirs & {:keys [batch-size release-date] :or {batch-size 50000}}] + "Create a dm+d store at `filename` from the directories `dirs`. + Options: + - :batch-size - number of components per write batch (default 50000) + - :release-date - date of the dm+d release + - :trud - source TRUD release information, stored for provenance" + [filename dirs & {:keys [batch-size release-date trud] :or {batch-size 50000}}] (when (.exists (io/file filename)) (throw (ex-info (str "dm+d database already exists: " filename) {}))) - (let [ds (jdbc/get-datasource (str "jdbc:sqlite:" filename)) + (let [ds (pool filename {:maximum-pool-size 1}) ;; SQLite serialises writers; one connection is enough ch (async/chan 5 (comp (partition-all batch-size) (map batch->sql)))] - (async/thread - (doseq [dir dirs] - (dim/stream-dmd dir ch :close? false)) - (async/close! ch)) - (create-tables ds) - (jdbc/execute! ds ["insert into metadata (version, created, release) values (?,?,?)" - store-version (LocalDateTime/now) release-date]) (try + (async/thread + (doseq [dir dirs] + (dim/stream-dmd dir ch :close? false)) + (async/close! ch)) + (jdbc/execute! ds [(str "PRAGMA application_id = " application-id)]) + (jdbc/execute! ds [(str "PRAGMA user_version = " store-version)]) + (create-tables ds) + (jdbc/execute! ds ["insert into metadata (version, created, release, trud) values (?,?,?,?)" + store-version (LocalDateTime/now) release-date (some-> trud json/write-str)]) + (jdbc/execute-batch! ds "insert into FILES (type, name, date) values (?,?,?)" + (mapv (fn [{:keys [type date ^java.io.File file]}] + [(name type) (.getName file) (str date)]) + (mapcat dim/dmd-file-seq dirs)) + {}) (loop [{:keys [stmts errors] :as batch} (async/ (keys x) first namespace)] (assoc x :TYPE nspace)))) +(defn ^:private fts5-query + "Turn a user-entered string into a safe FTS5 MATCH expression: each + whitespace-delimited token is quoted (preventing FTS5 query syntax + injection) and matched as a prefix; multiple tokens combine as AND." + [s] + (->> (str/split (or s "") #"\s+") + (remove str/blank?) + (map #(str "\"" (str/replace % "\"" "\"\"") "\"*")) + (str/join " "))) + +(s/def ::types (s/coll-of (set (keys product-tables)))) +(s/def ::limit pos-int?) +(s/fdef search + :args (s/cat :conn ::conn :s (s/nilable string?) + :opts (s/keys* :opt-un [::types ::limit]))) +(defn search + "Search product names, returning a sequence of maps of :SEARCH/ID, + :SEARCH/TYPE and :SEARCH/NM, best matches first. Each token of `s` is + matched as a prefix; multiple tokens must all match. + Options: + - :types - product types to include e.g. #{:VMP :AMP}; default, all + - :limit - maximum number of results; default 100" + [conn s & {:keys [types limit] :or {limit 100}}] + (let [q (fts5-query s)] + (if (str/blank? q) + [] + (if (seq types) + (jdbc/execute! conn (into [(str "select ID, TYPE, NM from SEARCH where NM match ? and TYPE in (" + (str/join "," (repeat (count types) "?")) + ") order by rank limit ?") q] + (concat (map name types) [limit]))) + (jdbc/execute! conn ["select ID, TYPE, NM from SEARCH where NM match ? order by rank limit ?" q limit]))))) + +(s/fdef fetch-history + :args (s/cat :conn ::conn :id ::product-id)) +(defn fetch-history + "Returns all history entries in which `id` is the current identifier, + ordered by start date. Includes 'self' entries (IDCURRENT=IDPREVIOUS) which + record the period of validity of the current identifier itself." + [conn id] + (jdbc/execute! conn ["select * from HISTORY where IDCURRENT=? order by STARTDT" id])) + +(s/fdef previous-ids + :args (s/cat :conn ::conn :id ::product-id)) +(defn previous-ids + "Returns the set of prior identifiers for the given current identifier, + excluding the identifier itself." + [conn id] + (into #{} (map :IDPREVIOUS) + (jdbc/plan conn ["select distinct IDPREVIOUS from HISTORY where IDCURRENT=? and IDPREVIOUS<>IDCURRENT" id]))) + +(s/fdef current-ids + :args (s/cat :conn ::conn :id ::product-id)) +(defn current-ids + "Returns the set of identifiers in current use for the given (usually + historic) identifier, excluding the identifier itself. Usually a single + identifier, but the data model permits more than one." + [conn id] + (into #{} (map :IDCURRENT) + (jdbc/plan conn ["select distinct IDCURRENT from HISTORY where IDPREVIOUS=? and IDPREVIOUS<>IDCURRENT" id]))) + +(s/fdef vtm-ingredients + :args (s/cat :conn ::conn :vtmid ::vtmid)) +(defn vtm-ingredients + "Returns ingredient (ISID) identifiers for the given VTM." + [conn vtmid] + (into [] (map :ISID) (jdbc/plan conn ["select ISID from VTM__INGREDIENT where VTMID=?" vtmid]))) + +(s/fdef vtms-for-ingredient + :args (s/cat :conn ::conn :isid ::isid)) +(defn vtms-for-ingredient + "Returns VTM identifiers for the given ingredient." + [conn isid] + (into [] (map :VTMID) (jdbc/plan conn ["select VTMID from VTM__INGREDIENT where ISID=?" isid]))) + +(s/fdef plan-products + :args (s/cat :conn ::conn :product-type (set (keys product-tables)))) +(defn plan-products + "Returns a reducible (clojure.lang.IReduceInit) over all rows of the given + product type (:VTM :VMP :AMP :VMPP or :AMPP), for streaming iteration + without realising all rows in memory. Each row is a `next.jdbc` row + abstraction; access columns by keyword, e.g. (map :NM)." + [conn product-type] + (let [{:keys [table]} (product-tables product-type)] + (jdbc/plan conn [(str "select * from " table)]))) + (defn ^:private atc->like [s] (-> s (str/replace "*" "%") (str/replace "?" "_"))) diff --git a/test/resources/dmd-2021-08-26/HISTORIC_CODES/f_history1_0260821.xml b/test/resources/dmd-2021-08-26/HISTORIC_CODES/f_history1_0260821.xml new file mode 100644 index 0000000..96e66bc --- /dev/null +++ b/test/resources/dmd-2021-08-26/HISTORIC_CODES/f_history1_0260821.xml @@ -0,0 +1,26 @@ + + +341867110000011023543030072004-12-012017-03-09 +34186711000001102341867110000011022017-03-10 + + +318135008104064110000011012004-05-042009-03-31 +3181350083181350082009-04-01 + + +38751600835120110000011092004-12-012005-07-26 +3875160083875160082005-07-27 + + +207050100000110420705010000011042004-12-01 + + +
353668110000011063850980022004-12-012018-04-17
+
+ +10547007105470072004-12-01 + + +28238000033143110000011032004-12-012007-09-30 + +
diff --git a/test/resources/dmd-2021-08-26/VTM_INGREDIENTS/f_vtm_ing1_0260821.xml b/test/resources/dmd-2021-08-26/VTM_INGREDIENTS/f_vtm_ing1_0260821.xml new file mode 100644 index 0000000..a0e7bdc --- /dev/null +++ b/test/resources/dmd-2021-08-26/VTM_INGREDIENTS/f_vtm_ing1_0260821.xml @@ -0,0 +1,4 @@ + +34186711000001102387516008 +34186711000001102387475002 + diff --git a/test/src/com/eldrix/dmd/importer_test.clj b/test/src/com/eldrix/dmd/importer_test.clj index 8a0f366..6c0f7bf 100644 --- a/test/src/com/eldrix/dmd/importer_test.clj +++ b/test/src/com/eldrix/dmd/importer_test.clj @@ -7,7 +7,8 @@ (def dir "dmd-2021-08-26") (deftest file-ordering - (is (= '(:LOOKUP :INGREDIENT :VTM :VMP :AMP :VMPP :AMPP :GTIN :BNF) (map :type (dim/dmd-file-seq (io/resource dir)))))) + (is (= '(:LOOKUP :INGREDIENT :VTM :VMP :AMP :VMPP :AMPP :GTIN :BNF :HISTORY :VTM_ING) + (map :type (dim/dmd-file-seq (io/resource dir)))))) (deftest import-lookups (is (= {:TYPE [:LOOKUP :BASIS_OF_NAME] @@ -15,6 +16,26 @@ :DESC "rINN - Recommended International Non-proprietary"} (first (dim/get-component (io/resource dir) :LOOKUP :BASIS_OF_NAME))))) +(deftest import-history + (let [history (dim/get-component (io/resource dir) :HISTORY :HISTORY) + by-cls (group-by :CLS history)] + (is (= #{"VTM" "VMP" "ING" "SUPP" "FORM" "ROUTE" "UOM"} (set (keys by-cls)))) + (is (= {:TYPE [:HISTORY :HISTORY] + :CLS "VTM" + :IDCURRENT 34186711000001102 + :IDPREVIOUS 354303007 + :STARTDT (java.time.LocalDate/of 2004 12 1) + :ENDDT (java.time.LocalDate/of 2017 3 9)} + (first (get by-cls "VTM")))) + (let [self-row (second (get by-cls "VTM"))] ;; rows with IDCURRENT=IDPREVIOUS record current id validity + (is (= (:IDCURRENT self-row) (:IDPREVIOUS self-row))) + (is (nil? (:ENDDT self-row)))))) + +(deftest import-vtm-ingredients + (is (= [{:TYPE [:VTM_ING :VTM_ING] :VTMID 34186711000001102 :ISID 387516008} + {:TYPE [:VTM_ING :VTM_ING] :VTMID 34186711000001102 :ISID 387475002}] + (dim/get-component (io/resource dir) :VTM_ING :VTM_ING)))) + (deftest metadata (is (= (java.time.LocalDate/of 2021 8 26) (:release-date (dim/get-release-metadata (io/resource dir)))))) diff --git a/test/src/com/eldrix/dmd/live_test.clj b/test/src/com/eldrix/dmd/live_test.clj index f8b6ee2..64305af 100644 --- a/test/src/com/eldrix/dmd/live_test.clj +++ b/test/src/com/eldrix/dmd/live_test.clj @@ -31,6 +31,32 @@ (is (seq amlodipine-vmps)) (is (= amlodipine-vtm (:VMP/VTM (first amlodipine-vmps)))))) +(deftest ^:live status-and-history + (let [{:keys [version release counts]} (dmd/status *db*)] + (is (= 2 version)) + (is release) + ;; if either count is zero, the supplementary release layout has likely + ;; changed and the bonus files are being silently dropped + (is (pos? (:HISTORY counts))) + (is (pos? (:VTM_INGREDIENT counts))) + ;; a real, stable historic mapping: VTM 86389004 was previously + ;; identified as 9906511000001107 (per the NHSBSA HISTORIC_CODES data) + (is (contains? (dmd/previous-ids *db* 86389004) 9906511000001107)) + (is (contains? (dmd/current-ids *db* 9906511000001107) 86389004)))) + +(deftest ^:live trud-provenance + (let [{:keys [trud files]} (dmd/status *db*)] + (when trud ;; present only when latest-dmd.db was built by this test run via TRUD + (is (= 2 (count trud)) "expected provenance for main and supplementary releases") + (is (every? :releaseDate trud))) + (is (seq files)) + (is (contains? (set (map :type files)) :HISTORY)))) + +(deftest ^:live search + (let [results (dmd/search *db* "amlodipine" :types #{:VMP})] + (is (seq results)) + (is (every? #(= "VMP" (:SEARCH/TYPE %)) results)))) + (comment (dmd/install-release "../trud/api-key.txt" "cache" "latest-dmd.db") (System/getenv "TRUD_API_KEY_FILE") diff --git a/test/src/com/eldrix/dmd/store_test.clj b/test/src/com/eldrix/dmd/store_test.clj index 4dae77d..905c323 100644 --- a/test/src/com/eldrix/dmd/store_test.clj +++ b/test/src/com/eldrix/dmd/store_test.clj @@ -2,7 +2,9 @@ (:require [clojure.spec.test.alpha :as stest] [clojure.test :refer [deftest is run-tests]] [com.eldrix.dmd.core :as dmd] - [clojure.java.io :as io]) + [com.eldrix.dmd.store4 :as st4] + [clojure.java.io :as io] + [next.jdbc :as jdbc]) (:import (java.io File) (java.time LocalDate))) @@ -16,6 +18,77 @@ (dmd/install-from-dirs filename [(io/resource dir)] :batch-size 1) (dmd/open-store filename))) +(deftest open-invalid-files + (let [not-sqlite (doto (File/createTempFile "dmd-test" ".db") (spit "this is not a database")) + not-dmd (File/createTempFile "dmd-test" ".db") + wrong-version (File/createTempFile "dmd-test" ".db")] + (.delete not-dmd) + (.delete wrong-version) + (with-open [conn (jdbc/get-connection (str "jdbc:sqlite:" not-dmd))] + (jdbc/execute-one! conn ["create table TEST (id integer)"])) + (with-open [conn (jdbc/get-connection (str "jdbc:sqlite:" wrong-version))] + (jdbc/execute-one! conn [(str "PRAGMA application_id = " st4/application-id)]) + (jdbc/execute-one! conn ["PRAGMA user_version = 1"]) + (jdbc/execute-one! conn ["create table TEST (id integer)"])) + (is (thrown-with-msg? Exception #"file not found" (dmd/open-store "no-such-file.db"))) + (is (thrown-with-msg? Exception #"not a SQLite database" (dmd/open-store not-sqlite))) + (is (not (dmd/sqlite-database? not-sqlite))) + (is (thrown-with-msg? Exception #"not a dm\+d database" (dmd/open-store not-dmd))) + (is (dmd/sqlite-database? not-dmd)) + (is (not (dmd/dmd-database? not-dmd))) + (is (thrown-with-msg? Exception #"incompatible dm\+d database version" (dmd/open-store wrong-version))) + (is (dmd/dmd-database? wrong-version)) + (run! #(.delete ^File %) [not-sqlite not-dmd wrong-version]))) + +(deftest history-and-queries + (let [st (create-and-open-store)] + (is (= #{354303007} (dmd/previous-ids st 34186711000001102))) + (is (= #{34186711000001102} (dmd/current-ids st 354303007))) + (is (= #{} (dmd/current-ids st 34186711000001102)) "self entries must be excluded") + (is (= #{} (dmd/previous-ids st 2070501000001104)) "supplier has only a self entry") + (let [history (dmd/fetch-history st 318135008)] + (is (= 2 (count history))) + (is (= ["10406411000001101" "318135008"] (map (comp str :HISTORY/IDPREVIOUS) history)) "ordered by start date") + (is (= "VMP" (:HISTORY/CLS (first history))))) + (is (= #{387516008 387475002} (set (dmd/vtm-ingredients st 34186711000001102)))) + (is (= [34186711000001102] (dmd/vtms-for-ingredient st 387475002))) + (is (= #{"Co-amilofruse 2.5mg/20mg tablets" "Co-amilofruse 5mg/40mg tablets"} + (into #{} (map :NM) (dmd/plan-products st :VMP)))) + (is (= 3 (count (into [] (map :APID) (dmd/plan-products st :AMP))))) + (is (contains? dmd/lookup-types :BASIS_OF_NAME)) + (is (= 26 (count dmd/lookup-types))) + (is (every? #(seq (dmd/fetch-lookup st %)) dmd/lookup-types) "every lookup type enumerable and populated") + (dmd/close st))) + +(deftest search-products + (let [st (create-and-open-store)] + (is (= #{"VTM" "VMP" "AMP" "VMPP" "AMPP"} + (into #{} (map :SEARCH/TYPE) (dmd/search st "co-amilofruse")))) + (is (= #{318135008 318136009} + (into #{} (map :SEARCH/ID) (dmd/search st "co-amilof" :types #{:VMP}))) "prefix search") + (is (= #{318136009} + (into #{} (map :SEARCH/ID) (dmd/search st "amilofruse 40mg" :types #{:VMP}))) "multiple tokens combine as AND") + (is (= 1 (count (dmd/search st "co-amilofruse" :limit 1)))) + (is (= [] (dmd/search st ""))) + (is (= [] (dmd/search st " "))) + (is (= [] (dmd/search st nil))) + (is (= [] (dmd/search st "\" OR 1=1")) "FTS5 query syntax must not be interpreted") + (dmd/close st))) + +(deftest store-status + (let [st (create-and-open-store) + {:keys [version created release trud files counts]} (dmd/status st)] + (is (= 2 version)) + (is created) + (is (= (LocalDate/of 2021 8 26) release)) + (is (nil? trud) "no TRUD provenance when installed from local directories") + (is (= 11 (count files))) + (is (= #{:LOOKUP :INGREDIENT :VTM :VMP :AMP :VMPP :AMPP :GTIN :BNF :HISTORY :VTM_ING} + (set (map :type files)))) + (is (= {:VTM 1 :VMP 2 :AMP 3 :VMPP 1 :AMPP 1 :INGREDIENT 4 :HISTORY 10 :VTM_INGREDIENT 2 :GTIN 1 :BNF 1} + counts)) + (dmd/close st))) + ;; test basic import, store and fetch across products. (deftest import-store-and-fetch (let [st (create-and-open-store) @@ -57,6 +130,22 @@ (is (= ["mg" "mg"] (map #(get-in % [:VMP__VIRTUAL_PRODUCT_INGREDIENT/STRNT_NMRTR_UOM :UNIT_OF_MEASURE/DESC]) (:VMP/VIRTUAL_PRODUCT_INGREDIENTS co-amilofruse-vmp-2)))) (is (= #{318136009 318135008} (set (map :VMP/VPID (dmd/vmps-for-product st 34186711000001102))))) (is (= 34186711000001102 (:VTM/VTMID (first (dmd/vtms-for-product st 318135008))))) + ;; fields previously dropped on import (schema v2) + (is (= "Co-amilofruse 2.5/20 tablets" (:VMP/NMPREV co-amilofruse-vmp-1))) + (is (= "2004-05-04" (:VMP/NMDT co-amilofruse-vmp-1))) + (is (:AMP/PARALLEL_IMPORT (dmd/fetch-product st 37706811000001108))) + (is (not (:AMP/INVALID (dmd/fetch-product st 37706811000001108)))) + (let [suppliers (zipmap (map :SUPPLIER/CD (dmd/fetch-lookup st :SUPPLIER)) + (dmd/fetch-lookup st :SUPPLIER))] + (is (:SUPPLIER/INVALID (get suppliers 3145201000001108))) + (is (= 2073601000001105 (:SUPPLIER/CDPREV (get suppliers 15883511000001102)))) + (is (= "2009-08-07" (:SUPPLIER/CDDT (get suppliers 15883511000001102))))) + (let [forms (zipmap (map :FORM/CD (dmd/fetch-lookup st :FORM)) (dmd/fetch-lookup st :FORM))] + (is (= 385098002 (:FORM/CDPREV (get forms 35366811000001106))))) + (let [ingredients (dmd/fetch-product st 318136009)] ;; ingredient previous ids now stored + (is (= #{3512011000001109 3536911000001101} + (set (map #(get-in % [:VMP__VIRTUAL_PRODUCT_INGREDIENT/IS 0 :INGREDIENT/ISIDPREV]) + (:VMP/VIRTUAL_PRODUCT_INGREDIENTS ingredients)))))) (dmd/close st))) (comment