diff --git a/README.md b/README.md index 645f543..c23d2cb 100644 --- a/README.md +++ b/README.md @@ -13,17 +13,17 @@ This extension is a wrapper for [https://github.com/mmomtchev/sqlite-wasm-http]. The http backend is kept open for the lifetime of the element having the corresponding `hx-db` attribute. Following events are emitted: -- `htmx:sql:loadstart` when a query execution begins +- `htmx:xhr:loadstart` when a query execution begins - `event.detail.xhr.db` is a promise for SQLite.Promiser -- `htmx:sql:progress` for each row of the result +- `htmx:xhr:progress` for each row of the result - `event.detail.xhr.db` is a promise for SQLite.Promiser - - `event.detail.xhr.response` is the received data row -- `htmx:sql:loadend` when a query execution is finished (all rows are received) but before any swapping is + - `event.detail.xhr.responseJSON` is the received data row as JSON + - `event.detail.xhr.response` is the received data row as stringified JSON +- `htmx:xhr:loadend` when a query execution is finished (all rows are received) but before any swapping is performed. - `event.detail.xhr.db` is a promise for SQLite.Promiser - - `event.detail.xhr.response` is all received data rows - -In addition, `htmx:beforeOnLoad` will contain `event.detail.xhr.response` with an array of all received data rows. + - `event.detail.xhr.responseJSON` is all received data rows as JSON + - `event.detail.xhr.response` is all received data rows as stringified JSON ### Install @@ -40,18 +40,26 @@ Include the following in your page: ```html - + ``` ### Usage -`hx-sql` elements need a useless `hx-boost` attribute to get "registered" to Htmx. Please let me know if there is a better way to do this. +Use one of attributes +- hx-get="SELECT ..." +- hx-put="UPDATE ..." +- hx-delete="DELETE ..." +- hx-post="INSERT ..." +- hx-post="ALTER ..." +- hx-post="CREATE ..." +- hx-post="DROP ..." +- hx-post="TRUNCATE ..." #### Use a database relative to current page over HTTP ```html -
+
``` @@ -59,7 +67,7 @@ Include the following in your page: ```html -
+
``` @@ -67,7 +75,7 @@ Include the following in your page: ```html -
+
``` @@ -82,24 +90,23 @@ Include the following in your page: -
+
``` #### Handle response rows one-by-one with javascript ```html -
+
``` #### Handle the whole result with javascript ```html -
+
``` @@ -107,13 +114,13 @@ Include the following in your page: ```html -
+
``` -#### Use _value_ as the query if hx-sql is empty +#### Use _value_ as the query if path is just "this.value" ```html - @@ -124,5 +131,5 @@ Include the following in your page: #### Override default config ```html -
+
``` diff --git a/dist/sqlite.js b/dist/sqlite.js index f308dfc..fbe3bbf 100644 --- a/dist/sqlite.js +++ b/dist/sqlite.js @@ -7,137 +7,7 @@ Extension to use SQLite database backend for Htmx over: (function(){ var api; var httpBackendConfig; - - // lot's of copying from htmx source. It should probably export some of this stuff as functions in internal API? - var getParameters = function (elt) { - var results = api.getInputValues(elt, 'post'); - var rawParameters = results.values; - var expressionVars = api.getExpressionVars(elt); - var allParameters = api.mergeObjects(rawParameters, expressionVars); - var filteredParameters = api.filterValues(allParameters, elt); - return filteredParameters; - }; - - // lot's of copying from htmx source. It should probably export some of this stuff as functions in internal API? - var swapAndSettle = function(data, elt, responseInfo) { - var target = api.getTarget(elt); - if (target == null || target == api.DUMMY_ELT) { - api.triggerErrorEvent(elt, 'htmx:targetError', {target: api.getAttributeValue(elt, "hx-target")}); - return; - } - - var beforeSwapDetails = {shouldSwap: true, target: target, elt: elt}; - if (!api.triggerEvent(target, 'htmx:beforeSwap', beforeSwapDetails)) return; - target = beforeSwapDetails.target; - - responseInfo.target = target; - responseInfo.failed = false; - responseInfo.successful = true; - - var serverResponse = data.length == 0 ? "" : JSON.stringify(data); - if (beforeSwapDetails.shouldSwap) { - api.withExtensions(elt, function (extension) { - serverResponse = extension.transformResponse(serverResponse, undefined, elt); - }); - - var swapSpec = api.getSwapSpecification(elt, undefined); - - target.classList.add(htmx.config.swappingClass); - - var doSwap = function () { - try { - var activeElt = document.activeElement; - var selectionInfo = {}; - try { - selectionInfo = { - elt: activeElt, - start: activeElt ? activeElt.selectionStart : null, - end: activeElt ? activeElt.selectionEnd : null - }; - } catch (e) { - // safari issue - see https://github.com/microsoft/playwright/issues/5894 - } - - var settleInfo = api.makeSettleInfo(target); - api.selectAndSwap(swapSpec.swapStyle, target, elt, serverResponse, settleInfo, undefined); - - if (selectionInfo.elt && - !api.bodyContains(selectionInfo.elt) && - api.getRawAttribute(selectionInfo.elt, "id")) { - var newActiveElt = document.getElementById(api.getRawAttribute(selectionInfo.elt, "id")); - var focusOptions = { preventScroll: swapSpec.focusScroll !== undefined ? !swapSpec.focusScroll : !htmx.config.defaultFocusScroll }; - if (newActiveElt) { - if (selectionInfo.start && newActiveElt.setSelectionRange) { - try { - newActiveElt.setSelectionRange(selectionInfo.start, selectionInfo.end); - } catch (e) { - // the setSelectionRange method is present on fields that don't support it, so just let this fail - } - } - newActiveElt.focus(focusOptions); - } - } - - target.classList.remove(htmx.config.swappingClass); - settleInfo.elts.forEach(function (elt) { - if (elt.classList) { - elt.classList.add(htmx.config.settlingClass); - } - api.triggerEvent(elt, 'htmx:afterSwap', responseInfo); - }); - - var doSettle = function () { - settleInfo.tasks.forEach(function (task) { - task.call(); - }); - settleInfo.elts.forEach(function (elt) { - if (elt.classList) { - elt.classList.remove(htmx.config.settlingClass); - } - api.triggerEvent(elt, 'htmx:afterSettle', responseInfo); - }); - } - - if (swapSpec.settleDelay > 0) { - setTimeout(doSettle, swapSpec.settleDelay) - } else { - doSettle(); - } - } catch (e) { - api.triggerErrorEvent(elt, 'htmx:swapError', responseInfo); - throw e; - } - }; - - var shouldTransition = htmx.config.globalViewTransitions - if(swapSpec.hasOwnProperty('transition')){ - shouldTransition = swapSpec.transition; - } - - if(shouldTransition && - api.triggerEvent(elt, 'htmx:beforeTransition', responseInfo) && - typeof Promise !== "undefined" && document.startViewTransition){ - var settlePromise = new Promise(function (_resolve, _reject) { - settleResolve = _resolve; - settleReject = _reject; - }); - var innerDoSwap = doSwap; - doSwap = function() { - document.startViewTransition(function () { - innerDoSwap(); - return settlePromise; - }); - } - } - - - if (swapSpec.swapDelay > 0) { - setTimeout(doSwap, swapSpec.swapDelay) - } else { - doSwap(); - } - } - }; + var sqlConfig; htmx.defineExtension('sqlite', { init: function (internalAPI) { @@ -147,102 +17,132 @@ Extension to use SQLite database backend for Htmx over: timeout: 10000, // 10s cacheSize: 4096 // 4 MB }; + sqlConfig = { + rowMode: 'object' + } }, + onEvent: function (name, evt) { - if (name === "htmx:afterProcessNode") { + if (name === "htmx:beforeRequest") { let elt = evt.detail.elt; - if (elt.hasAttribute('hx-sql')) { - var triggerSpecs = api.getTriggerSpecs(elt); - triggerSpecs.forEach(function(triggerSpec) { - var nodeData = api.getInternalData(elt); - api.addTriggerHandler(elt, triggerSpec, nodeData, function (elt, evt) { - if (htmx.closest(elt, htmx.config.disableSelector)) { - cleanUpElement(elt); - return; - } - - // use Htmx parameters as bind variables - var binds = {}; - Object.entries(getParameters(elt)).forEach(function([k,v]) { - binds['$' + k] = v; - }); + var rc = evt.detail.requestConfig; + var sql; + if (rc.path === 'this.value' || + rc.verb.toUpperCase() === 'GET' && rc.path.toUpperCase().startsWith('SELECT ') || + rc.verb.toUpperCase() === 'PUT' && rc.path.toUpperCase().startsWith('UPDATE ') || + rc.verb.toUpperCase() === 'DELETE' && rc.path.toUpperCase().startsWith('DELETE ') || + rc.verb.toUpperCase() === 'POST' && rc.path.toUpperCase().startsWith('INSERT ') || + rc.verb.toUpperCase() === 'POST' && rc.path.toUpperCase().startsWith('ALTER ') || + rc.verb.toUpperCase() === 'POST' && rc.path.toUpperCase().startsWith('CREATE ') || + rc.verb.toUpperCase() === 'POST' && rc.path.toUpperCase().startsWith('DROP ') || + rc.verb.toUpperCase() === 'POST' && rc.path.toUpperCase().startsWith('TRUNCATE ')) { + sql = rc.path; + } else { + return true; + } - var sql = elt.getAttribute('hx-sql'); - if (sql == "") { - // use form field value if hx-sql is empty - sql = elt.value; - if (!htmx.closest(elt, '[hx-target]')) { - throw new Error("Attribute 'hx-target' is required when 'hx-sql' is empty"); - } - } + if (sql === "this.value") { + // use field value if path is just "SELECT" + sql = elt.value; + if (!evt.detail.target) { + throw new Error("Attribute 'hx-target' is required when value is used is empty"); + } + } - var dbElem = htmx.closest(elt, '[hx-db]'); - if (!dbElem) { - throw new Error("Attribute 'hx-db' is required in the ancestor hierarchy"); - } + var dbElem = htmx.closest(elt, '[hx-db]'); + if (!dbElem) { + throw new Error("Attribute 'hx-db' is required in the ancestor hierarchy"); + } + var dbURI = dbElem.getAttribute('hx-db'); + + var onload = evt.detail.xhr.onload; + var onerror = evt.detail.xhr.onerror; + evt.detail.xhr = { + status: 200, + getAllResponseHeaders: function() { + return this.getResponseHeader("Content-Type:application/json"); + }, + getResponseHeader: function(headerName) { + if (headerName.toLowerCase() === "content-type") { + return "application/json"; + } + return undefined; + } + }; - var configElem = htmx.closest(elt, '[hx-db-config]'); - var conf = configElem ? JSON.parse(configElem.getAttribute('hx-db-config')) : {}; - var config = { ...httpBackendConfig, - ...conf - }; - var dbURI = dbElem.getAttribute('hx-db'); - - var backend; - if (dbElem._htmx_sqlite_http_backend) { - backend = dbElem._htmx_sqlite_http_backend; - } else { - backend = dbURI.match(/^https?:/) ? { http: sqliteWasmHttp.createHttpBackend(config) } - : {}; - dbElem._htmx_sqlite_http_backend = backend; - } - dbElem.addEventListener('htmx:beforeCleanupElement', function(ev) { - if (ev.detail.elt == dbElem && dbElem._htmx_sqlite_http_backend && dbElem._htmx_sqlite_http_backend.http) { - dbElem._htmx_sqlite_http_backend.http.close(); - delete dbElem._htmx_sqlite_http_backend; - } - }); + // use Htmx parameters as bind variables + var binds = {}; + Object.entries(evt.detail.requestConfig.parameters).forEach(function([k,v]) { + binds['$' + k] = v; + }); - var allRows = []; - sqliteWasmHttp.createSQLiteThread(backend) - .then(function(db) { - db('open', { - filename: encodeURI(dbURI.match(/^https?:\/\//) ? dbURI : dbURI.replace(/^opfs:/, '').replace(/^https?:/, 'file:')), - vfs: dbURI.replace(/:.*/,'').replace('https', 'http') - }); - return db; - }) - .then(function(db) { - api.triggerEvent(elt, 'htmx:sql:loadstart', { elt: elt, xhr: { db: db } }); - db('exec', { - sql: sql, - bind: binds, - rowMode: "object", - callback: function(data) { - if (data.row) { - allRows.push(data.row); - api.triggerEvent(elt, 'htmx:sql:progress', { elt: elt, xhr: { db: db, response: data.row } }); - } else { - api.triggerEvent(elt, 'htmx:sql:loadend', { elt: elt, xhr: { db: db } }); + var configElem = htmx.closest(elt, '[hx-request]'); + var conf = configElem ? JSON.parse(configElem.getAttribute('hx-request')) : {}; + var config = { + ...httpBackendConfig, + ...sqlConfig, + ...conf + }; + + var backend; + if (dbElem._htmx_sqlite_http_backend) { + backend = dbElem._htmx_sqlite_http_backend; + } else { + backend = dbURI.match(/^https?:/) ? { http: sqliteWasmHttp.createHttpBackend(config) } + : {}; + dbElem._htmx_sqlite_http_backend = backend; + } + dbElem.addEventListener('htmx:beforeCleanupElement', function(ev) { + if (ev.detail.elt == dbElem && dbElem._htmx_sqlite_http_backend && dbElem._htmx_sqlite_http_backend.http) { + dbElem._htmx_sqlite_http_backend.http.close(); + delete dbElem._htmx_sqlite_http_backend; + } + }); - db('close', {}) // This closes the DB connection - .then(function() { - db.close(); // This terminates the SQLite worker - }); - - var responseInfo = { elt: elt, xhr: { db: db, response: allRows }, target: api.getTarget(elt) }; - var cancel = !api.triggerEvent(elt, 'htmx:beforeOnLoad', responseInfo); - if (responseInfo.target && !cancel) { - swapAndSettle(allRows, elt, responseInfo); - } - } - } - }); - }); + var allRows = []; + sqliteWasmHttp.createSQLiteThread(backend) + .then(function(db) { + db('open', { + filename: encodeURI(dbURI.match(/^https?:\/\//) ? dbURI : dbURI.replace(/^opfs:/, '').replace(/^https?:/, 'file:')), + vfs: dbURI.replace(/:.*/,'').replace('https', 'http') + }); + return db; + }) + .then(function(db) { + api.triggerEvent(elt, 'htmx:xhr:loadstart', { elt: elt, xhr: {...evt.detail.xhr, db: db} }); + db('exec', { + sql: sql, + bind: binds, + rowMode: config.rowMode, + callback: function(data) { + if (data.row) { + allRows.push(data.row); + api.triggerEvent(elt, 'htmx:xhr:progress', { elt: elt, xhr: { ...evt.detail.xhr, db: db, response: JSON.stringify(data.row), responseJSON: data.row } }); + } else { + api.triggerEvent(elt, 'htmx:xhr:loadend', { elt: elt, xhr: { ...evt.detail.xhr, db: db, response: allRows.length == 0 ? '' : JSON.stringify(allRows), responseJSON: allRows } }); + + db('close', {}) // This closes the DB connection + .then(function() { + db.close(); // This terminates the SQLite worker + }); + + evt.detail.xhr.responseJSON = allRows; + evt.detail.xhr.response = allRows.length == 0 ? '' : JSON.stringify(allRows); + onload(); + } + } + }) + .catch(function(data) { + if (data.result) { + evt.detail.error = data.result.message; + } + onerror(); }); }); - } + + // return false to stop Htmx processing. We are calling load() ourselves. + return false; } } }); diff --git a/dist/sqlite.min.js b/dist/sqlite.min.js index bd4096e..72653e0 100644 --- a/dist/sqlite.min.js +++ b/dist/sqlite.min.js @@ -1 +1 @@ -(function(){var f;var u;var g=function(t){var e=f.getInputValues(t,"post");var r=e.values;var n=f.getExpressionVars(t);var a=f.mergeObjects(r,n);var i=f.filterValues(a,t);return i};var d=function(t,s,l){var o=f.getTarget(s);if(o==null||o==f.DUMMY_ELT){f.triggerErrorEvent(s,"htmx:targetError",{target:f.getAttributeValue(s,"hx-target")});return}var e={shouldSwap:true,target:o,elt:s};if(!f.triggerEvent(o,"htmx:beforeSwap",e))return;o=e.target;l.target=o;l.failed=false;l.successful=true;var c=t.length==0?"":JSON.stringify(t);if(e.shouldSwap){f.withExtensions(s,function(t){c=t.transformResponse(c,undefined,s)});var h=f.getSwapSpecification(s,undefined);o.classList.add(htmx.config.swappingClass);var r=function(){try{var t=document.activeElement;var e={};try{e={elt:t,start:t?t.selectionStart:null,end:t?t.selectionEnd:null}}catch(t){}var r=f.makeSettleInfo(o);f.selectAndSwap(h.swapStyle,o,s,c,r,undefined);if(e.elt&&!f.bodyContains(e.elt)&&f.getRawAttribute(e.elt,"id")){var n=document.getElementById(f.getRawAttribute(e.elt,"id"));var a={preventScroll:h.focusScroll!==undefined?!h.focusScroll:!htmx.config.defaultFocusScroll};if(n){if(e.start&&n.setSelectionRange){try{n.setSelectionRange(e.start,e.end)}catch(t){}}n.focus(a)}}o.classList.remove(htmx.config.swappingClass);r.elts.forEach(function(t){if(t.classList){t.classList.add(htmx.config.settlingClass)}f.triggerEvent(t,"htmx:afterSwap",l)});var i=function(){r.tasks.forEach(function(t){t.call()});r.elts.forEach(function(t){if(t.classList){t.classList.remove(htmx.config.settlingClass)}f.triggerEvent(t,"htmx:afterSettle",l)})};if(h.settleDelay>0){setTimeout(i,h.settleDelay)}else{i()}}catch(t){f.triggerErrorEvent(s,"htmx:swapError",l);throw t}};var n=htmx.config.globalViewTransitions;if(h.hasOwnProperty("transition")){n=h.transition}if(n&&f.triggerEvent(s,"htmx:beforeTransition",l)&&typeof Promise!=="undefined"&&document.startViewTransition){var a=new Promise(function(t,e){settleResolve=t;settleReject=e});var i=r;r=function(){document.startViewTransition(function(){i();return a})}}if(h.swapDelay>0){setTimeout(r,h.swapDelay)}else{r()}}};htmx.defineExtension("sqlite",{init:function(t){f=t;u={maxPageSize:4096,timeout:1e4,cacheSize:4096}},onEvent:function(t,e){if(t==="htmx:afterProcessNode"){let r=e.detail.elt;if(r.hasAttribute("hx-sql")){var n=f.getTriggerSpecs(r);n.forEach(function(t){var e=f.getInternalData(r);f.addTriggerHandler(r,t,e,function(a,t){if(htmx.closest(a,htmx.config.disableSelector)){cleanUpElement(a);return}var r={};Object.entries(g(a)).forEach(function([t,e]){r["$"+t]=e});var e=a.getAttribute("hx-sql");if(e==""){e=a.value;if(!htmx.closest(a,"[hx-target]")){throw new Error("Attribute 'hx-target' is required when 'hx-sql' is empty")}}var n=htmx.closest(a,"[hx-db]");if(!n){throw new Error("Attribute 'hx-db' is required in the ancestor hierarchy")}var i=htmx.closest(a,"[hx-db-config]");var s=i?JSON.parse(i.getAttribute("hx-db-config")):{};var l={...u,...s};var o=n.getAttribute("hx-db");var c;if(n._htmx_sqlite_http_backend){c=n._htmx_sqlite_http_backend}else{c=o.match(/^https?:/)?{http:sqliteWasmHttp.createHttpBackend(l)}:{};n._htmx_sqlite_http_backend=c}n.addEventListener("htmx:beforeCleanupElement",function(t){if(t.detail.elt==n&&n._htmx_sqlite_http_backend&&n._htmx_sqlite_http_backend.http){n._htmx_sqlite_http_backend.http.close();delete n._htmx_sqlite_http_backend}});var h=[];sqliteWasmHttp.createSQLiteThread(c).then(function(t){t("open",{filename:encodeURI(o.match(/^https?:\/\//)?o:o.replace(/^opfs:/,"").replace(/^https?:/,"file:")),vfs:o.replace(/:.*/,"").replace("https","http")});return t}).then(function(n){f.triggerEvent(a,"htmx:sql:loadstart",{elt:a,xhr:{db:n}});n("exec",{sql:e,bind:r,rowMode:"object",callback:function(t){if(t.row){h.push(t.row);f.triggerEvent(a,"htmx:sql:progress",{elt:a,xhr:{db:n,response:t.row}})}else{f.triggerEvent(a,"htmx:sql:loadend",{elt:a,xhr:{db:n}});n("close",{}).then(function(){n.close()});var e={elt:a,xhr:{db:n,response:h},target:f.getTarget(a)};var r=!f.triggerEvent(a,"htmx:beforeOnLoad",e);if(e.target&&!r){d(h,a,e)}}}})})})})}}}})})(); \ No newline at end of file +(function(){var f;var v;var b;htmx.defineExtension("sqlite",{init:function(e){f=e;v={maxPageSize:4096,timeout:1e4,cacheSize:4096};b={rowMode:"object"}},onEvent:function(e,a){if(e==="htmx:beforeRequest"){let r=a.detail.elt;var t=a.detail.requestConfig;var s;if(t.path==="this.value"||t.verb.toUpperCase()==="GET"&&t.path.toUpperCase().startsWith("SELECT ")||t.verb.toUpperCase()==="PUT"&&t.path.toUpperCase().startsWith("UPDATE ")||t.verb.toUpperCase()==="DELETE"&&t.path.toUpperCase().startsWith("DELETE ")||t.verb.toUpperCase()==="POST"&&t.path.toUpperCase().startsWith("INSERT ")||t.verb.toUpperCase()==="POST"&&t.path.toUpperCase().startsWith("ALTER ")||t.verb.toUpperCase()==="POST"&&t.path.toUpperCase().startsWith("CREATE ")||t.verb.toUpperCase()==="POST"&&t.path.toUpperCase().startsWith("DROP ")||t.verb.toUpperCase()==="POST"&&t.path.toUpperCase().startsWith("TRUNCATE ")){s=t.path}else{return true}if(s==="this.value"){s=r.value;if(!a.detail.target){throw new Error("Attribute 'hx-target' is required when value is used is empty")}}var i=htmx.closest(r,"[hx-db]");if(!i){throw new Error("Attribute 'hx-db' is required in the ancestor hierarchy")}var n=i.getAttribute("hx-db");var h=a.detail.xhr.onload;var p=a.detail.xhr.onerror;a.detail.xhr={status:200,getAllResponseHeaders:function(){return this.getResponseHeader("Content-Type:application/json")},getResponseHeader:function(e){if(e.toLowerCase()==="content-type"){return"application/json"}return undefined}};var o={};Object.entries(a.detail.requestConfig.parameters).forEach(function([e,t]){o["$"+e]=t});var l=htmx.closest(r,"[hx-request]");var d=l?JSON.parse(l.getAttribute("hx-request")):{};var c={...v,...b,...d};var u;if(i._htmx_sqlite_http_backend){u=i._htmx_sqlite_http_backend}else{u=n.match(/^https?:/)?{http:sqliteWasmHttp.createHttpBackend(c)}:{};i._htmx_sqlite_http_backend=u}i.addEventListener("htmx:beforeCleanupElement",function(e){if(e.detail.elt==i&&i._htmx_sqlite_http_backend&&i._htmx_sqlite_http_backend.http){i._htmx_sqlite_http_backend.http.close();delete i._htmx_sqlite_http_backend}});var x=[];sqliteWasmHttp.createSQLiteThread(u).then(function(e){e("open",{filename:encodeURI(n.match(/^https?:\/\//)?n:n.replace(/^opfs:/,"").replace(/^https?:/,"file:")),vfs:n.replace(/:.*/,"").replace("https","http")});return e}).then(function(t){f.triggerEvent(r,"htmx:xhr:loadstart",{elt:r,xhr:{...a.detail.xhr,db:t}});t("exec",{sql:s,bind:o,rowMode:c.rowMode,callback:function(e){if(e.row){x.push(e.row);f.triggerEvent(r,"htmx:xhr:progress",{elt:r,xhr:{...a.detail.xhr,db:t,response:JSON.stringify(e.row),responseJSON:e.row}})}else{f.triggerEvent(r,"htmx:xhr:loadend",{elt:r,xhr:{...a.detail.xhr,db:t,response:x.length==0?"":JSON.stringify(x),responseJSON:x}});t("close",{}).then(function(){t.close()});a.detail.xhr.responseJSON=x;a.detail.xhr.response=x.length==0?"":JSON.stringify(x);h()}}}).catch(function(e){if(e.result){a.detail.error=e.result.message}p()})});return false}}})})(); \ No newline at end of file diff --git a/package.json b/package.json index 9cdc0d2..2bfd623 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "htmx-sqlite", - "version": "1.0.0", + "version": "1.9.0", "description": "Htmx extension to use SQLite database backend over HTTP or OPFS", "author": "Jyri-Matti Lähteenmäki ", "keywords": [ diff --git a/src/sqlite.js b/src/sqlite.js index d39a5c2..fbe3bbf 100644 --- a/src/sqlite.js +++ b/src/sqlite.js @@ -7,137 +7,7 @@ Extension to use SQLite database backend for Htmx over: (function(){ var api; var httpBackendConfig; - - // lot's of copying from htmx source. It should probably export some of this stuff as functions in internal API? - var getParameters = function (elt) { - var results = api.getInputValues(elt, 'post'); - var rawParameters = results.values; - var expressionVars = api.getExpressionVars(elt); - var allParameters = api.mergeObjects(rawParameters, expressionVars); - var filteredParameters = api.filterValues(allParameters, elt); - return filteredParameters; - }; - - // lot's of copying from htmx source. It should probably export some of this stuff as functions in internal API? - var swapAndSettle = function(data, elt, responseInfo) { - var target = api.getTarget(elt); - if (target == null || target == api.DUMMY_ELT) { - api.triggerErrorEvent(elt, 'htmx:targetError', {target: api.getAttributeValue(elt, "hx-target")}); - return; - } - - var beforeSwapDetails = {shouldSwap: true, target: target, elt: elt}; - if (!api.triggerEvent(target, 'htmx:beforeSwap', beforeSwapDetails)) return; - target = beforeSwapDetails.target; - - responseInfo.target = target; - responseInfo.failed = false; - responseInfo.successful = true; - - var serverResponse = data.length == 0 ? "" : JSON.stringify(data); - if (beforeSwapDetails.shouldSwap) { - api.withExtensions(elt, function (extension) { - serverResponse = extension.transformResponse(serverResponse, undefined, elt); - }); - - var swapSpec = api.getSwapSpecification(elt, undefined); - - target.classList.add(htmx.config.swappingClass); - - var doSwap = function () { - try { - var activeElt = document.activeElement; - var selectionInfo = {}; - try { - selectionInfo = { - elt: activeElt, - start: activeElt ? activeElt.selectionStart : null, - end: activeElt ? activeElt.selectionEnd : null - }; - } catch (e) { - // safari issue - see https://github.com/microsoft/playwright/issues/5894 - } - - var settleInfo = api.makeSettleInfo(target); - api.selectAndSwap(swapSpec.swapStyle, target, elt, serverResponse, settleInfo, undefined); - - if (selectionInfo.elt && - !api.bodyContains(selectionInfo.elt) && - api.getRawAttribute(selectionInfo.elt, "id")) { - var newActiveElt = document.getElementById(api.getRawAttribute(selectionInfo.elt, "id")); - var focusOptions = { preventScroll: swapSpec.focusScroll !== undefined ? !swapSpec.focusScroll : !htmx.config.defaultFocusScroll }; - if (newActiveElt) { - if (selectionInfo.start && newActiveElt.setSelectionRange) { - try { - newActiveElt.setSelectionRange(selectionInfo.start, selectionInfo.end); - } catch (e) { - // the setSelectionRange method is present on fields that don't support it, so just let this fail - } - } - newActiveElt.focus(focusOptions); - } - } - - target.classList.remove(htmx.config.swappingClass); - settleInfo.elts.forEach(function (elt) { - if (elt.classList) { - elt.classList.add(htmx.config.settlingClass); - } - api.triggerEvent(elt, 'htmx:afterSwap', responseInfo); - }); - - var doSettle = function () { - settleInfo.tasks.forEach(function (task) { - task.call(); - }); - settleInfo.elts.forEach(function (elt) { - if (elt.classList) { - elt.classList.remove(htmx.config.settlingClass); - } - api.triggerEvent(elt, 'htmx:afterSettle', responseInfo); - }); - } - - if (swapSpec.settleDelay > 0) { - setTimeout(doSettle, swapSpec.settleDelay) - } else { - doSettle(); - } - } catch (e) { - api.triggerErrorEvent(elt, 'htmx:swapError', responseInfo); - throw e; - } - }; - - var shouldTransition = htmx.config.globalViewTransitions - if(swapSpec.hasOwnProperty('transition')){ - shouldTransition = swapSpec.transition; - } - - if(shouldTransition && - api.triggerEvent(elt, 'htmx:beforeTransition', responseInfo) && - typeof Promise !== "undefined" && document.startViewTransition){ - var settlePromise = new Promise(function (_resolve, _reject) { - settleResolve = _resolve; - settleReject = _reject; - }); - var innerDoSwap = doSwap; - doSwap = function() { - document.startViewTransition(function () { - innerDoSwap(); - return settlePromise; - }); - } - } - - - if (swapSpec.swapDelay > 0) { - setTimeout(doSwap, swapSpec.swapDelay) - } else { - doSwap(); - } - } - }; + var sqlConfig; htmx.defineExtension('sqlite', { init: function (internalAPI) { @@ -147,102 +17,132 @@ Extension to use SQLite database backend for Htmx over: timeout: 10000, // 10s cacheSize: 4096 // 4 MB }; + sqlConfig = { + rowMode: 'object' + } }, + onEvent: function (name, evt) { - if (name === "htmx:afterProcessNode") { + if (name === "htmx:beforeRequest") { let elt = evt.detail.elt; - if (elt.hasAttribute('hx-sql')) { - var triggerSpecs = api.getTriggerSpecs(elt); - triggerSpecs.forEach(function(triggerSpec) { - var nodeData = api.getInternalData(elt); - api.addTriggerHandler(elt, triggerSpec, nodeData, function (elt, evt) { - if (htmx.closest(elt, htmx.config.disableSelector)) { - cleanUpElement(elt); - return; - } - - // use Htmx parameters as bind variables - var binds = {}; - Object.entries(getParameters(elt)).forEach(function([k,v]) { - binds['$' + k] = v; - }); + var rc = evt.detail.requestConfig; + var sql; + if (rc.path === 'this.value' || + rc.verb.toUpperCase() === 'GET' && rc.path.toUpperCase().startsWith('SELECT ') || + rc.verb.toUpperCase() === 'PUT' && rc.path.toUpperCase().startsWith('UPDATE ') || + rc.verb.toUpperCase() === 'DELETE' && rc.path.toUpperCase().startsWith('DELETE ') || + rc.verb.toUpperCase() === 'POST' && rc.path.toUpperCase().startsWith('INSERT ') || + rc.verb.toUpperCase() === 'POST' && rc.path.toUpperCase().startsWith('ALTER ') || + rc.verb.toUpperCase() === 'POST' && rc.path.toUpperCase().startsWith('CREATE ') || + rc.verb.toUpperCase() === 'POST' && rc.path.toUpperCase().startsWith('DROP ') || + rc.verb.toUpperCase() === 'POST' && rc.path.toUpperCase().startsWith('TRUNCATE ')) { + sql = rc.path; + } else { + return true; + } - var sql = elt.getAttribute('hx-sql'); - if (sql == "") { - // use form field value if hx-sql is empty - sql = elt.value; - if (!htmx.closest(elt, '[hx-target]')) { - throw new Error("Attribute 'hx-target' is required when 'hx-sql' is empty"); - } - } + if (sql === "this.value") { + // use field value if path is just "SELECT" + sql = elt.value; + if (!evt.detail.target) { + throw new Error("Attribute 'hx-target' is required when value is used is empty"); + } + } - var dbElem = htmx.closest(elt, '[hx-db]'); - if (!dbElem) { - throw new Error("Attribute 'hx-db' is required in the ancestor hierarchy"); - } + var dbElem = htmx.closest(elt, '[hx-db]'); + if (!dbElem) { + throw new Error("Attribute 'hx-db' is required in the ancestor hierarchy"); + } + var dbURI = dbElem.getAttribute('hx-db'); + + var onload = evt.detail.xhr.onload; + var onerror = evt.detail.xhr.onerror; + evt.detail.xhr = { + status: 200, + getAllResponseHeaders: function() { + return this.getResponseHeader("Content-Type:application/json"); + }, + getResponseHeader: function(headerName) { + if (headerName.toLowerCase() === "content-type") { + return "application/json"; + } + return undefined; + } + }; - var configElem = htmx.closest(elt, '[hx-db-config]'); - var conf = configElem ? JSON.parse(configElem.getAttribute('hx-db-config')) : {}; - var config = { ...httpBackendConfig, - ...conf - }; - var dbURI = dbElem.getAttribute('hx-db'); - - var backend; - if (dbElem._htmx_sqlite_http_backend) { - backend = dbElem._htmx_sqlite_http_backend; - } else { - backend = dbURI.match(/^https?:/) ? { http: sqliteWasmHttp.createHttpBackend(config) } - : {}; - dbElem._htmx_sqlite_http_backend = backend; - } - dbElem.addEventListener('htmx:beforeCleanupElement', function(ev) { - if (ev.detail.elt == dbElem && dbElem._htmx_sqlite_http_backend && dbElem._htmx_sqlite_http_backend.http) { - dbElem._htmx_sqlite_http_backend.http.close(); - delete dbElem._htmx_sqlite_http_backend; - } - }); + // use Htmx parameters as bind variables + var binds = {}; + Object.entries(evt.detail.requestConfig.parameters).forEach(function([k,v]) { + binds['$' + k] = v; + }); - var allRows = []; - sqliteWasmHttp.createSQLiteThread(backend) - .then(function(db) { - db('open', { - filename: encodeURI(dbURI.match(/^https?:\/\//) ? dbURI : dbURI.replace(/^opfs:/, '').replace(/^https?:/, 'file:')), - vfs: dbURI.replace(/:.*/,'').replace('https', 'http') - }); - return db; - }) - .then(function(db) { - api.triggerEvent(elt, 'htmx:sql:loadstart', { elt: elt, xhr: { db: db } }); - db('exec', { - sql: sql, - bind: binds, - rowMode: "object", - callback: function(data) { - if (data.row) { - allRows.push(data.row); - api.triggerEvent(elt, 'htmx:sql:progress', { elt: elt, xhr: { db: db, response: data.row } }); - } else { - api.triggerEvent(elt, 'htmx:sql:loadend', { elt: elt, xhr: { db: db, response: allRows } }); + var configElem = htmx.closest(elt, '[hx-request]'); + var conf = configElem ? JSON.parse(configElem.getAttribute('hx-request')) : {}; + var config = { + ...httpBackendConfig, + ...sqlConfig, + ...conf + }; + + var backend; + if (dbElem._htmx_sqlite_http_backend) { + backend = dbElem._htmx_sqlite_http_backend; + } else { + backend = dbURI.match(/^https?:/) ? { http: sqliteWasmHttp.createHttpBackend(config) } + : {}; + dbElem._htmx_sqlite_http_backend = backend; + } + dbElem.addEventListener('htmx:beforeCleanupElement', function(ev) { + if (ev.detail.elt == dbElem && dbElem._htmx_sqlite_http_backend && dbElem._htmx_sqlite_http_backend.http) { + dbElem._htmx_sqlite_http_backend.http.close(); + delete dbElem._htmx_sqlite_http_backend; + } + }); - db('close', {}) // This closes the DB connection - .then(function() { - db.close(); // This terminates the SQLite worker - }); - - var responseInfo = { elt: elt, xhr: { response: allRows }, target: api.getTarget(elt) }; - var cancel = !api.triggerEvent(elt, 'htmx:beforeOnLoad', responseInfo); - if (responseInfo.target && !cancel) { - swapAndSettle(allRows, elt, responseInfo); - } - } - } - }); - }); + var allRows = []; + sqliteWasmHttp.createSQLiteThread(backend) + .then(function(db) { + db('open', { + filename: encodeURI(dbURI.match(/^https?:\/\//) ? dbURI : dbURI.replace(/^opfs:/, '').replace(/^https?:/, 'file:')), + vfs: dbURI.replace(/:.*/,'').replace('https', 'http') + }); + return db; + }) + .then(function(db) { + api.triggerEvent(elt, 'htmx:xhr:loadstart', { elt: elt, xhr: {...evt.detail.xhr, db: db} }); + db('exec', { + sql: sql, + bind: binds, + rowMode: config.rowMode, + callback: function(data) { + if (data.row) { + allRows.push(data.row); + api.triggerEvent(elt, 'htmx:xhr:progress', { elt: elt, xhr: { ...evt.detail.xhr, db: db, response: JSON.stringify(data.row), responseJSON: data.row } }); + } else { + api.triggerEvent(elt, 'htmx:xhr:loadend', { elt: elt, xhr: { ...evt.detail.xhr, db: db, response: allRows.length == 0 ? '' : JSON.stringify(allRows), responseJSON: allRows } }); + + db('close', {}) // This closes the DB connection + .then(function() { + db.close(); // This terminates the SQLite worker + }); + + evt.detail.xhr.responseJSON = allRows; + evt.detail.xhr.response = allRows.length == 0 ? '' : JSON.stringify(allRows); + onload(); + } + } + }) + .catch(function(data) { + if (data.result) { + evt.detail.error = data.result.message; + } + onerror(); }); }); - } + + // return false to stop Htmx processing. We are calling load() ourselves. + return false; } } }); diff --git a/test/sqlite.js b/test/sqlite.js index 10b3cf4..d369353 100644 --- a/test/sqlite.js +++ b/test/sqlite.js @@ -5,18 +5,18 @@ describe("sqlite extension", function() { beforeEach(function (done) { clearWorkArea(); - let div = make('
') - div.addEventListener('htmx:sql:loadend', () => { + let div = make('
') + div.addEventListener('htmx:xhr:loadend', () => { done(); }); }); describe('General stuff', function () { it('can make multiple queries', function (done) { - var div = make('
'); + var div = make('
'); var handler = () => { div.innerText.should.equal('[{"name":"foo"}]'); - var div2 = make('
'); + var div2 = make('
'); var handler2 = () => { div2.innerText.should.equal('[{"name":"foo"},{"name":"bar"}]'); done(); @@ -27,7 +27,7 @@ describe("sqlite extension", function() { }); it('empty result clears content', function (done) { - var div = make('
'); + var div = make('
'); div.addEventListener('htmx:afterSwap', () => { div.innerText.should.equal(''); done(); @@ -35,10 +35,10 @@ describe("sqlite extension", function() { }); it('events', function (done) { - var div = make('
'); - div.addEventListener('htmx:sql:loadstart', () => { - div.addEventListener('htmx:sql:progress', () => { - div.addEventListener('htmx:sql:loadend', () => { + var div = make('
'); + div.addEventListener('htmx:xhr:loadstart', () => { + div.addEventListener('htmx:xhr:progress', () => { + div.addEventListener('htmx:xhr:loadend', () => { done(); }); }); @@ -50,14 +50,14 @@ describe("sqlite extension", function() { e.reason.should.equal('Timeout while waiting on backend'); done(); }); - make(`
`); + make(`
`); }); }); describe('Bind parameters', function () { it('hx-include', function (done) { make(''); - var div = make('
'); + var div = make('
'); var handler = () => { div.innerText.should.equal('[{"name":"foo"}]'); done(); @@ -66,7 +66,7 @@ describe("sqlite extension", function() { }); it('whole form', function (done) { - var div = make('
'); + var div = make('
'); var handler = () => { div.innerText.should.equal('[{"name":"foo"}]'); done(); @@ -75,10 +75,10 @@ describe("sqlite extension", function() { }); }); - describe('Use value of form field when hx-sql is empty', function () { + describe('Use value of form field when path is "this.value"', function () { it('input', function (done) { let target = make('
'); - make(''); + make(''); var handler = () => { target.innerText.should.equal('[{"name":"foo"}]'); done(); @@ -88,7 +88,7 @@ describe("sqlite extension", function() { it('textarea', function (done) { let target = make('
'); - make(''); + make(''); var handler = () => { target.innerText.should.equal('[{"name":"foo"}]'); done(); @@ -98,7 +98,7 @@ describe("sqlite extension", function() { it('select', function (done) { let target = make('
'); - make(''); + make(''); var handler = () => { target.innerText.should.equal('[{"name":"bar"}]'); done();