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
+
+
+
+
+
+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