tina4ruby 3.2.1 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +19 -20
- data/lib/tina4/auth.rb +137 -27
- data/lib/tina4/auto_crud.rb +55 -3
- data/lib/tina4/cli.rb +75 -2
- data/lib/tina4/cors.rb +1 -1
- data/lib/tina4/database.rb +131 -28
- data/lib/tina4/database_result.rb +122 -8
- data/lib/tina4/env.rb +1 -1
- data/lib/tina4/frond.rb +148 -2
- data/lib/tina4/localization.rb +1 -1
- data/lib/tina4/middleware.rb +349 -1
- data/lib/tina4/migration.rb +132 -11
- data/lib/tina4/orm.rb +17 -8
- data/lib/tina4/public/js/tina4-dev-admin.min.js +1 -1
- data/lib/tina4/public/js/tina4js.min.js +47 -0
- data/lib/tina4/query_builder.rb +374 -0
- data/lib/tina4/queue.rb +128 -90
- data/lib/tina4/queue_backends/lite_backend.rb +42 -7
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -0
- data/lib/tina4/rack_app.rb +194 -18
- data/lib/tina4/request.rb +14 -1
- data/lib/tina4/response.rb +26 -0
- data/lib/tina4/router.rb +127 -0
- data/lib/tina4/service_runner.rb +1 -1
- data/lib/tina4/session.rb +6 -1
- data/lib/tina4/session_handlers/database_handler.rb +66 -0
- data/lib/tina4/swagger.rb +1 -1
- data/lib/tina4/template.rb +10 -1
- data/lib/tina4/validator.rb +174 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +23 -4
- data/lib/tina4/websocket_backplane.rb +118 -0
- data/lib/tina4.rb +64 -4
- metadata +12 -3
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";var Tina4=(()=>{var H=Object.defineProperty;var pe=Object.getOwnPropertyDescriptor;var ge=Object.getOwnPropertyNames;var he=Object.prototype.hasOwnProperty;var me=(e,n)=>{for(var t in n)H(e,t,{get:n[t],enumerable:!0})},ye=(e,n,t,o)=>{if(n&&typeof n=="object"||typeof n=="function")for(let r of ge(n))!he.call(e,r)&&r!==t&&H(e,r,{get:()=>n[r],enumerable:!(o=pe(n,r))||o.enumerable});return e};var ve=e=>ye(H({},"__esModule",{value:!0}),e);var Ae={};me(Ae,{Tina4Element:()=>I,api:()=>ie,batch:()=>q,computed:()=>z,effect:()=>m,html:()=>X,isSignal:()=>w,navigate:()=>L,pwa:()=>le,route:()=>oe,router:()=>re,signal:()=>h,ws:()=>ue});var C=null,_=null,M=null;function b(e){M=e}function J(){return M}var B=null,V=null;var N=0,P=new Set;function h(e,n){let t=e,o=new Set,r={_t4:!0,get value(){if(C&&(o.add(C),_)){let a=C;_.push(()=>o.delete(a))}return t},set value(a){if(Object.is(a,t))return;let i=t;if(t=a,r._debugInfo&&r._debugInfo.updateCount++,V&&V(r,i,a),N>0)for(let s of o)P.add(s);else{let s;for(let c of[...o])try{c()}catch(l){s===void 0&&(s=l)}if(s!==void 0)throw s}},_subscribe(a){return o.add(a),()=>{o.delete(a)}},peek(){return t}};return B&&(r._debugInfo={label:n,createdAt:Date.now(),updateCount:0,subs:o},B(r,n)),r}function z(e){let n=h(void 0);return m(()=>{n.value=e()}),{_t4:!0,get value(){return n.value},set value(t){throw new Error("[tina4] computed signals are read-only")},_subscribe(t){return n._subscribe(t)},peek(){return n.peek()}}}function m(e){let n=!1,t=[],o=()=>{if(n)return;for(let s of t)s();t=[];let a=C,i=_;C=o,_=t;try{e()}finally{C=a,_=i}};o();let r=()=>{n=!0;for(let a of t)a();t=[]};return M&&M.push(r),r}function q(e){N++;try{e()}finally{if(N--,N===0){let n=[...P];P.clear();let t;for(let o of n)try{o()}catch(r){t===void 0&&(t=r)}if(t!==void 0)throw t}}}function w(e){return e!==null&&typeof e=="object"&&e._t4===!0}var Q=new WeakMap,D="t4:";function X(e,...n){let t=Q.get(e);if(!t){t=document.createElement("template");let i="";for(let s=0;s<e.length;s++)i+=e[s],s<n.length&&(Te(i)?i+=`__t4_${s}__`:i+=`<!--${D}${s}-->`);t.innerHTML=i,Q.set(e,t)}let o=t.content.cloneNode(!0),r=be(o);for(let{marker:i,index:s}of r)ke(i,n[s]);let a=we(o);for(let i of a)Ce(i,n);return o}function be(e){let n=[];return W(e,t=>{if(t.nodeType===8){let o=t.data;if(o&&o.startsWith(D)){let r=parseInt(o.slice(D.length),10);n.push({marker:t,index:r})}}}),n}function we(e){let n=[];return W(e,t=>{t.nodeType===1&&n.push(t)}),n}function W(e,n){let t=e.childNodes;for(let o=0;o<t.length;o++){let r=t[o];n(r),W(r,n)}}function ke(e,n){let t=e.parentNode;if(t)if(w(n)){let o=document.createTextNode("");t.replaceChild(o,e),m(()=>{o.data=String(n.value??"")})}else if(typeof n=="function"){let o=document.createComment("");t.replaceChild(o,e);let r=[],a=[];m(()=>{for(let d of a)d();a=[];let i=[],s=J();b(i);let c=n();b(s),a=i;for(let d of r)d.parentNode?.removeChild(d);r=[];let l=F(c),f=o.parentNode;if(f)for(let d of l)f.insertBefore(d,o),r.push(d)})}else if(Y(n))t.replaceChild(n,e);else if(n instanceof Node)t.replaceChild(n,e);else if(Array.isArray(n)){let o=document.createDocumentFragment();for(let r of n){let a=F(r);for(let i of a)o.appendChild(i)}t.replaceChild(o,e)}else{let o=document.createTextNode(String(n??""));t.replaceChild(o,e)}}function Ce(e,n){let t=[];for(let o of Array.from(e.attributes)){let r=o.name,a=o.value;if(r.startsWith("@")){let s=r.slice(1),c=a.match(/__t4_(\d+)__/);if(c){let l=n[parseInt(c[1],10)];typeof l=="function"&&e.addEventListener(s,f=>q(()=>l(f)))}t.push(r);continue}if(r.startsWith("?")){let s=r.slice(1),c=a.match(/__t4_(\d+)__/);if(c){let l=n[parseInt(c[1],10)];if(w(l)){let f=l;m(()=>{f.value?e.setAttribute(s,""):e.removeAttribute(s)})}else typeof l=="function"?m(()=>{l()?e.setAttribute(s,""):e.removeAttribute(s)}):l&&e.setAttribute(s,"")}t.push(r);continue}if(r.startsWith(".")){let s=r.slice(1),c=a.match(/__t4_(\d+)__/);if(c){let l=n[parseInt(c[1],10)];w(l)?m(()=>{e[s]=l.value}):e[s]=l}t.push(r);continue}let i=a.match(/__t4_(\d+)__/);if(i){let s=n[parseInt(i[1],10)];if(w(s)){let c=s;m(()=>{e.setAttribute(r,String(c.value??""))})}else typeof s=="function"?m(()=>{e.setAttribute(r,String(s()??""))}):e.setAttribute(r,String(s??""))}}for(let o of t)e.removeAttribute(o)}function F(e){if(e==null||e===!1)return[];if(Y(e))return Array.from(e.childNodes);if(e instanceof Node)return[e];if(Array.isArray(e)){let n=[];for(let t of e)n.push(...F(t));return n}return[document.createTextNode(String(e))]}function Y(e){return e!=null&&typeof e=="object"&&e.nodeType===11}function Te(e){let n=!1,t=!1,o=!1;for(let r=0;r<e.length;r++){let a=e[r];a==="<"&&!n&&!t&&(o=!0),a===">"&&!n&&!t&&(o=!1),o&&(a==='"'&&!n&&(t=!t),a==="'"&&!t&&(n=!n))}return o}var Z=null,ee=null;var I=class extends HTMLElement{constructor(){super();this._props={};this._rendered=!1;let t=this.constructor;this._root=t.shadow?this.attachShadow({mode:"open"}):this;for(let[o,r]of Object.entries(t.props))this._props[o]=h(this._coerce(this.getAttribute(o),r))}static{this.props={}}static{this.styles=""}static{this.shadow=!0}static get observedAttributes(){return Object.keys(this.props)}connectedCallback(){if(this._rendered)return;this._rendered=!0;let t=this.constructor;if(t.styles&&t.shadow&&this._root instanceof ShadowRoot){let r=document.createElement("style");r.textContent=t.styles,this._root.appendChild(r)}let o=this.render();o&&this._root.appendChild(o),this.onMount(),Z&&Z(this)}disconnectedCallback(){this.onUnmount(),ee&&ee(this)}attributeChangedCallback(t,o,r){let i=this.constructor.props[t];i&&this._props[t]&&(this._props[t].value=this._coerce(r,i))}prop(t){if(!this._props[t])throw new Error(`[tina4] Prop '${t}' not declared in static props of <${this.tagName.toLowerCase()}>`);return this._props[t]}emit(t,o){this.dispatchEvent(new CustomEvent(t,{bubbles:!0,composed:!0,...o}))}onMount(){}onUnmount(){}_coerce(t,o){return o===Boolean?t!==null:o===Number?t!==null?Number(t):0:t??""}};var U=[],T=null,S="history",_e=!1,E=[],O=[],te=0;function oe(e,n){let t=[],o;e==="*"?o=".*":o=e.replace(/\{(\w+)\}/g,(a,i)=>(t.push(i),"([^/]+)"));let r=new RegExp(`^${o}$`);typeof n=="function"?U.push({pattern:e,regex:r,paramNames:t,handler:n}):U.push({pattern:e,regex:r,paramNames:t,handler:n.handler,guard:n.guard})}function L(e,n){if(S==="hash")if(n?.replace){let t=new URL(location.href);t.hash="#"+e,history.replaceState(null,"",t.toString()),R()}else location.hash="#"+e;else n?.replace?history.replaceState(null,"",e):history.pushState(null,"",e),R()}function R(){if(!T)return;let e=performance.now(),n=++te,t=S==="hash"?location.hash.slice(1)||"/":location.pathname;for(let o of U){let r=t.match(o.regex);if(!r)continue;let a={};if(o.paramNames.forEach((c,l)=>{a[c]=decodeURIComponent(r[l+1])}),o.guard){let c=o.guard();if(c===!1)return;if(typeof c=="string"){L(c,{replace:!0});return}}for(let c of O)c();O=[],T.innerHTML="";let i=[];b(i);let s=o.handler(a);if(s instanceof Promise)s.then(c=>{if(b(null),n!==te){for(let f of i)f();return}ne(T,c),O=i;let l=performance.now()-e;for(let f of E)f({path:t,params:a,pattern:o.pattern,durationMs:l})});else{b(null),ne(T,s),O=i;let c=performance.now()-e;for(let l of E)l({path:t,params:a,pattern:o.pattern,durationMs:c})}return}}function ne(e,n){n instanceof DocumentFragment||n instanceof Node?e.replaceChildren(n):typeof n=="string"?e.innerHTML=n:n!=null&&e.replaceChildren(document.createTextNode(String(n)))}var re={start(e){if(T=document.querySelector(e.target),!T)throw new Error(`[tina4] Router target '${e.target}' not found in DOM`);S=e.mode??"history",_e=!0,window.addEventListener("popstate",R),S==="hash"&&window.addEventListener("hashchange",R),document.addEventListener("click",n=>{if(n.metaKey||n.ctrlKey||n.shiftKey||n.altKey)return;let t=n.target.closest("a[href]");if(!t||t.origin!==location.origin||t.hasAttribute("target")||t.hasAttribute("download")||t.getAttribute("rel")?.includes("external"))return;n.preventDefault();let o=S==="hash"?t.getAttribute("href"):t.pathname;L(o)}),R()},on(e,n){return E.push(n),()=>{let t=E.indexOf(n);t>=0&&E.splice(t,1)}}};var y={baseUrl:"",auth:!1,tokenKey:"tina4_token",headers:{}},j=[],K=[],Se=0;function se(){try{return localStorage.getItem(y.tokenKey)}catch{return null}}function Ee(e){try{localStorage.setItem(y.tokenKey,e)}catch{}}async function x(e,n,t,o){let r={method:e,headers:{"Content-Type":"application/json",...y.headers}};if(y.auth){let d=se();d&&(r.headers.Authorization=`Bearer ${d}`)}if(t!==void 0&&e!=="GET"){let d=typeof t=="object"&&t!==null?{...t}:t;if(y.auth&&typeof d=="object"&&d!==null){let p=se();p&&(d.formToken=p)}r.body=JSON.stringify(d)}if(o?.headers&&Object.assign(r.headers,o.headers),o?.params){let d=Object.entries(o.params).map(([p,v])=>`${encodeURIComponent(p)}=${encodeURIComponent(String(v))}`).join("&");n+=(n.includes("?")?"&":"?")+d}let a=y.baseUrl+n;r._url=a,r._requestId=++Se;for(let d of j){let p=d(r);p&&(r=p)}let i=await fetch(a,r),s=i.headers.get("FreshToken");s&&Ee(s);let c=i.headers.get("Content-Type")??"",l;c.includes("json")?l=await i.json():l=await i.text();let f={status:i.status,data:l,ok:i.ok,headers:i.headers,_requestId:r._requestId};for(let d of K){let p=d(f);p&&(f=p)}if(!i.ok)throw f;return f.data}var ie={configure(e){Object.assign(y,e)},get(e,n){return x("GET",e,void 0,n)},post(e,n,t){return x("POST",e,n,t)},put(e,n,t){return x("PUT",e,n,t)},patch(e,n,t){return x("PATCH",e,n,t)},delete(e,n){return x("DELETE",e,void 0,n)},intercept(e,n){e==="request"?j.push(n):K.push(n)},_reset(){y.baseUrl="",y.auth=!1,y.tokenKey="tina4_token",y.headers={},j.length=0,K.length=0}};function ae(e){let n=e.cacheStrategy??"network-first",t=JSON.stringify(e.precache??[]),o=e.offlineRoute?`'${e.offlineRoute}'`:"null";return`
|
|
2
|
+
const CACHE = 'tina4-v1';
|
|
3
|
+
const PRECACHE = ${t};
|
|
4
|
+
const OFFLINE = ${o};
|
|
5
|
+
|
|
6
|
+
self.addEventListener('install', (e) => {
|
|
7
|
+
e.waitUntil(
|
|
8
|
+
caches.open(CACHE).then((c) => c.addAll(PRECACHE)).then(() => self.skipWaiting())
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
self.addEventListener('activate', (e) => {
|
|
13
|
+
e.waitUntil(self.clients.claim());
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
self.addEventListener('fetch', (e) => {
|
|
17
|
+
const req = e.request;
|
|
18
|
+
if (req.method !== 'GET') return;
|
|
19
|
+
|
|
20
|
+
${n==="cache-first"?`
|
|
21
|
+
e.respondWith(
|
|
22
|
+
caches.match(req).then((cached) => cached || fetch(req).then((res) => {
|
|
23
|
+
const clone = res.clone();
|
|
24
|
+
caches.open(CACHE).then((c) => c.put(req, clone));
|
|
25
|
+
return res;
|
|
26
|
+
})).catch(() => OFFLINE ? caches.match(OFFLINE) : new Response('Offline', { status: 503 }))
|
|
27
|
+
);`:n==="stale-while-revalidate"?`
|
|
28
|
+
e.respondWith(
|
|
29
|
+
caches.match(req).then((cached) => {
|
|
30
|
+
const fetched = fetch(req).then((res) => {
|
|
31
|
+
caches.open(CACHE).then((c) => c.put(req, res.clone()));
|
|
32
|
+
return res;
|
|
33
|
+
});
|
|
34
|
+
return cached || fetched;
|
|
35
|
+
}).catch(() => OFFLINE ? caches.match(OFFLINE) : new Response('Offline', { status: 503 }))
|
|
36
|
+
);`:`
|
|
37
|
+
e.respondWith(
|
|
38
|
+
fetch(req).then((res) => {
|
|
39
|
+
const clone = res.clone();
|
|
40
|
+
caches.open(CACHE).then((c) => c.put(req, clone));
|
|
41
|
+
return res;
|
|
42
|
+
}).catch(() => caches.match(req).then((cached) =>
|
|
43
|
+
cached || (OFFLINE ? caches.match(OFFLINE) : new Response('Offline', { status: 503 }))
|
|
44
|
+
))
|
|
45
|
+
);`}
|
|
46
|
+
});
|
|
47
|
+
`.trim()}function ce(e){let n={name:e.name,short_name:e.shortName??e.name,start_url:"/",display:e.display??"standalone",background_color:e.backgroundColor??"#ffffff",theme_color:e.themeColor??"#000000"};return e.icon&&(n.icons=[{src:e.icon,sizes:"192x192",type:"image/png"},{src:e.icon,sizes:"512x512",type:"image/png"}]),n}var le={register(e){let n=ce(e),t=new Blob([JSON.stringify(n)],{type:"application/json"}),o=document.createElement("link");o.rel="manifest",o.href=URL.createObjectURL(t),document.head.appendChild(o);let r=document.querySelector('meta[name="theme-color"]');if(r||(r=document.createElement("meta"),r.name="theme-color",document.head.appendChild(r)),r.content=e.themeColor??"#000000","serviceWorker"in navigator){let a=ae(e),i=new Blob([a],{type:"text/javascript"}),s=URL.createObjectURL(i);navigator.serviceWorker.register(s).catch(c=>{console.warn("[tina4] Service worker registration failed:",c)})}},generateServiceWorker(e){return ae(e)},generateManifest(e){return ce(e)}};var Re={reconnect:!0,reconnectDelay:1e3,reconnectMaxDelay:3e4,reconnectAttempts:1/0,protocols:[]};function xe(e,n={}){let t={...Re,...n},o=h("connecting"),r=h(!1),a=h(null),i=h(null),s=h(0),c={message:[],open:[],close:[],error:[]},l=null,f=!1,d=t.reconnectDelay,p=null,v=0;function de(u){if(typeof u!="string")return u;try{return JSON.parse(u)}catch{return u}}function $(){o.value=v>0?"reconnecting":"connecting";try{l=new WebSocket(e,t.protocols)}catch{o.value="closed",r.value=!1;return}l.onopen=()=>{o.value="open",r.value=!0,i.value=null,v=0,d=t.reconnectDelay,s.value=0;for(let u of c.open)u()},l.onmessage=u=>{let g=de(u.data);a.value=g;for(let k of c.message)k(g)},l.onclose=u=>{o.value="closed",r.value=!1;for(let g of c.close)g(u.code,u.reason);!f&&t.reconnect&&v<t.reconnectAttempts&&fe()},l.onerror=u=>{i.value=u;for(let g of c.error)g(u)}}function fe(){v++,s.value=v,o.value="reconnecting",p=setTimeout(()=>{p=null,$()},d),d=Math.min(d*2,t.reconnectMaxDelay)}let G={status:o,connected:r,lastMessage:a,error:i,reconnectCount:s,send(u){if(!l||l.readyState!==WebSocket.OPEN)throw new Error("[tina4] WebSocket is not connected");let g=typeof u=="string"?u:JSON.stringify(u);l.send(g)},on(u,g){return c[u].push(g),()=>{let k=c[u],A=k.indexOf(g);A>=0&&k.splice(A,1)}},pipe(u,g){let k=A=>{u.value=g(A,u.value)};return G.on("message",k)},close(u,g){f=!0,p&&(clearTimeout(p),p=null),l&&l.close(u??1e3,g??""),o.value="closed",r.value=!1}};return $(),G}var ue={connect:xe};return ve(Ae);})();
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
# QueryBuilder — Fluent SQL query builder.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# # Standalone
|
|
8
|
+
# result = Tina4::QueryBuilder.from("users", db: db)
|
|
9
|
+
# .select("id", "name")
|
|
10
|
+
# .where("active = ?", [1])
|
|
11
|
+
# .order_by("name ASC")
|
|
12
|
+
# .limit(10)
|
|
13
|
+
# .get
|
|
14
|
+
#
|
|
15
|
+
# # From ORM model
|
|
16
|
+
# result = User.query
|
|
17
|
+
# .where("age > ?", [18])
|
|
18
|
+
# .order_by("name")
|
|
19
|
+
# .get
|
|
20
|
+
#
|
|
21
|
+
class QueryBuilder
|
|
22
|
+
def initialize(table, db: nil)
|
|
23
|
+
@table = table
|
|
24
|
+
@db = db
|
|
25
|
+
@columns = ["*"]
|
|
26
|
+
@wheres = []
|
|
27
|
+
@params = []
|
|
28
|
+
@joins = []
|
|
29
|
+
@group_by_cols = []
|
|
30
|
+
@havings = []
|
|
31
|
+
@having_params = []
|
|
32
|
+
@order_by_cols = []
|
|
33
|
+
@limit_val = nil
|
|
34
|
+
@offset_val = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Create a QueryBuilder for a table.
|
|
38
|
+
#
|
|
39
|
+
# @param table_name [String] The database table name.
|
|
40
|
+
# @param db [Object, nil] Optional database connection.
|
|
41
|
+
# @return [QueryBuilder]
|
|
42
|
+
def self.from(table_name, db: nil)
|
|
43
|
+
new(table_name, db: db)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Set the columns to select.
|
|
47
|
+
#
|
|
48
|
+
# @param columns [Array<String>] Column names.
|
|
49
|
+
# @return [self]
|
|
50
|
+
def select(*columns)
|
|
51
|
+
@columns = columns unless columns.empty?
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Add a WHERE condition with AND.
|
|
56
|
+
#
|
|
57
|
+
# @param condition [String] SQL condition with ? placeholders.
|
|
58
|
+
# @param params [Array] Parameter values.
|
|
59
|
+
# @return [self]
|
|
60
|
+
def where(condition, params = [])
|
|
61
|
+
@wheres << ["AND", condition]
|
|
62
|
+
@params.concat(params)
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Add a WHERE condition with OR.
|
|
67
|
+
#
|
|
68
|
+
# @param condition [String] SQL condition with ? placeholders.
|
|
69
|
+
# @param params [Array] Parameter values.
|
|
70
|
+
# @return [self]
|
|
71
|
+
def or_where(condition, params = [])
|
|
72
|
+
@wheres << ["OR", condition]
|
|
73
|
+
@params.concat(params)
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Add an INNER JOIN.
|
|
78
|
+
#
|
|
79
|
+
# @param table [String] Table to join.
|
|
80
|
+
# @param on_clause [String] Join condition.
|
|
81
|
+
# @return [self]
|
|
82
|
+
def join(table, on_clause)
|
|
83
|
+
@joins << "INNER JOIN #{table} ON #{on_clause}"
|
|
84
|
+
self
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Add a LEFT JOIN.
|
|
88
|
+
#
|
|
89
|
+
# @param table [String] Table to join.
|
|
90
|
+
# @param on_clause [String] Join condition.
|
|
91
|
+
# @return [self]
|
|
92
|
+
def left_join(table, on_clause)
|
|
93
|
+
@joins << "LEFT JOIN #{table} ON #{on_clause}"
|
|
94
|
+
self
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Add a GROUP BY column.
|
|
98
|
+
#
|
|
99
|
+
# @param column [String] Column name.
|
|
100
|
+
# @return [self]
|
|
101
|
+
def group_by(column)
|
|
102
|
+
@group_by_cols << column
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Add a HAVING clause.
|
|
107
|
+
#
|
|
108
|
+
# @param expression [String] HAVING expression with ? placeholders.
|
|
109
|
+
# @param params [Array] Parameter values.
|
|
110
|
+
# @return [self]
|
|
111
|
+
def having(expression, params = [])
|
|
112
|
+
@havings << expression
|
|
113
|
+
@having_params.concat(params)
|
|
114
|
+
self
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Add an ORDER BY clause.
|
|
118
|
+
#
|
|
119
|
+
# @param expression [String] Column and direction (e.g. "name ASC").
|
|
120
|
+
# @return [self]
|
|
121
|
+
def order_by(expression)
|
|
122
|
+
@order_by_cols << expression
|
|
123
|
+
self
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Set LIMIT and optional OFFSET.
|
|
127
|
+
#
|
|
128
|
+
# @param count [Integer] Maximum rows to return.
|
|
129
|
+
# @param offset [Integer, nil] Number of rows to skip.
|
|
130
|
+
# @return [self]
|
|
131
|
+
def limit(count, offset = nil)
|
|
132
|
+
@limit_val = count
|
|
133
|
+
@offset_val = offset unless offset.nil?
|
|
134
|
+
self
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Build and return the SQL string without executing.
|
|
138
|
+
#
|
|
139
|
+
# @return [String] The constructed SQL query.
|
|
140
|
+
def to_sql
|
|
141
|
+
sql = "SELECT #{@columns.join(', ')} FROM #{@table}"
|
|
142
|
+
|
|
143
|
+
sql += " #{@joins.join(' ')}" unless @joins.empty?
|
|
144
|
+
|
|
145
|
+
sql += " WHERE #{build_where}" unless @wheres.empty?
|
|
146
|
+
|
|
147
|
+
sql += " GROUP BY #{@group_by_cols.join(', ')}" unless @group_by_cols.empty?
|
|
148
|
+
|
|
149
|
+
sql += " HAVING #{@havings.join(' AND ')}" unless @havings.empty?
|
|
150
|
+
|
|
151
|
+
sql += " ORDER BY #{@order_by_cols.join(', ')}" unless @order_by_cols.empty?
|
|
152
|
+
|
|
153
|
+
sql
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Execute the query and return the database result.
|
|
157
|
+
#
|
|
158
|
+
# @return [Object] The result from db.fetch.
|
|
159
|
+
def get
|
|
160
|
+
ensure_db!
|
|
161
|
+
sql = to_sql
|
|
162
|
+
all_params = @params + @having_params
|
|
163
|
+
|
|
164
|
+
@db.fetch(
|
|
165
|
+
sql,
|
|
166
|
+
all_params.empty? ? [] : all_params,
|
|
167
|
+
limit: @limit_val || 100,
|
|
168
|
+
offset: @offset_val || 0
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Execute the query and return a single row.
|
|
173
|
+
#
|
|
174
|
+
# @return [Hash, nil] A single row hash, or nil.
|
|
175
|
+
def first
|
|
176
|
+
ensure_db!
|
|
177
|
+
sql = to_sql
|
|
178
|
+
all_params = @params + @having_params
|
|
179
|
+
|
|
180
|
+
@db.fetch_one(sql, all_params.empty? ? [] : all_params)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Execute the query and return the row count.
|
|
184
|
+
#
|
|
185
|
+
# @return [Integer] Number of matching rows.
|
|
186
|
+
def count
|
|
187
|
+
ensure_db!
|
|
188
|
+
|
|
189
|
+
# Build a count query by replacing columns
|
|
190
|
+
original = @columns
|
|
191
|
+
@columns = ["COUNT(*) as cnt"]
|
|
192
|
+
sql = to_sql
|
|
193
|
+
@columns = original
|
|
194
|
+
|
|
195
|
+
all_params = @params + @having_params
|
|
196
|
+
|
|
197
|
+
row = @db.fetch_one(sql, all_params.empty? ? [] : all_params)
|
|
198
|
+
return 0 if row.nil?
|
|
199
|
+
|
|
200
|
+
# Handle case-insensitive column names
|
|
201
|
+
(row["cnt"] || row["CNT"] || row[:cnt] || row[:CNT] || 0).to_i
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Check whether any matching rows exist.
|
|
205
|
+
#
|
|
206
|
+
# @return [Boolean]
|
|
207
|
+
def exists?
|
|
208
|
+
count > 0
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Convert the fluent builder state into a MongoDB-compatible query hash.
|
|
212
|
+
#
|
|
213
|
+
# @return [Hash] with keys :filter, :projection, :sort, :limit, :skip (only non-empty).
|
|
214
|
+
def to_mongo
|
|
215
|
+
result = {}
|
|
216
|
+
|
|
217
|
+
# -- projection --
|
|
218
|
+
if @columns != ["*"]
|
|
219
|
+
result[:projection] = @columns.each_with_object({}) { |col, h| h[col.strip] = 1 }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# -- filter --
|
|
223
|
+
unless @wheres.empty?
|
|
224
|
+
param_index = 0
|
|
225
|
+
and_conditions = []
|
|
226
|
+
or_conditions = []
|
|
227
|
+
|
|
228
|
+
@wheres.each_with_index do |(connector, condition), i|
|
|
229
|
+
mongo_cond, param_index = parse_condition_to_mongo(condition, param_index)
|
|
230
|
+
if i == 0 || connector == "AND"
|
|
231
|
+
and_conditions << mongo_cond
|
|
232
|
+
else
|
|
233
|
+
or_conditions << mongo_cond
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
if or_conditions.any?
|
|
238
|
+
and_merged = merge_mongo_conditions(and_conditions)
|
|
239
|
+
all_branches = [and_merged] + or_conditions
|
|
240
|
+
result[:filter] = { "$or" => all_branches }
|
|
241
|
+
else
|
|
242
|
+
result[:filter] = merge_mongo_conditions(and_conditions)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# -- sort --
|
|
247
|
+
unless @order_by_cols.empty?
|
|
248
|
+
sort = {}
|
|
249
|
+
@order_by_cols.each do |expr|
|
|
250
|
+
parts = expr.strip.split(/\s+/)
|
|
251
|
+
field = parts[0]
|
|
252
|
+
direction = (parts[1] && parts[1].upcase == "DESC") ? -1 : 1
|
|
253
|
+
sort[field] = direction
|
|
254
|
+
end
|
|
255
|
+
result[:sort] = sort
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# -- limit / skip --
|
|
259
|
+
result[:limit] = @limit_val unless @limit_val.nil?
|
|
260
|
+
result[:skip] = @offset_val unless @offset_val.nil?
|
|
261
|
+
|
|
262
|
+
result
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
private
|
|
266
|
+
|
|
267
|
+
# Parse a single SQL condition into a MongoDB filter hash.
|
|
268
|
+
#
|
|
269
|
+
# @return [Array(Hash, Integer)] [mongo_condition, updated_param_index]
|
|
270
|
+
def parse_condition_to_mongo(condition, param_index)
|
|
271
|
+
cond = condition.strip
|
|
272
|
+
|
|
273
|
+
# IS NOT NULL
|
|
274
|
+
if cond.match?(/\A(\w+)\s+IS\s+NOT\s+NULL\z/i)
|
|
275
|
+
field = cond.match(/\A(\w+)/)[1]
|
|
276
|
+
return [{ field => { "$exists" => true, "$ne" => nil } }, param_index]
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# IS NULL
|
|
280
|
+
if cond.match?(/\A(\w+)\s+IS\s+NULL\z/i)
|
|
281
|
+
field = cond.match(/\A(\w+)/)[1]
|
|
282
|
+
return [{ field => { "$exists" => false } }, param_index]
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# NOT IN
|
|
286
|
+
if (m = cond.match(/\A(\w+)\s+NOT\s+IN\s*\(\s*\?\s*\)\z/i))
|
|
287
|
+
val = @params[param_index]
|
|
288
|
+
values = val.is_a?(Array) ? val : [val]
|
|
289
|
+
return [{ m[1] => { "$nin" => values } }, param_index + 1]
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# IN
|
|
293
|
+
if (m = cond.match(/\A(\w+)\s+IN\s*\(\s*\?\s*\)\z/i))
|
|
294
|
+
val = @params[param_index]
|
|
295
|
+
values = val.is_a?(Array) ? val : [val]
|
|
296
|
+
return [{ m[1] => { "$in" => values } }, param_index + 1]
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# LIKE
|
|
300
|
+
if (m = cond.match(/\A(\w+)\s+LIKE\s+\?\z/i))
|
|
301
|
+
val = (@params[param_index] || "").to_s
|
|
302
|
+
pattern = val.gsub("%", ".*").gsub("_", ".")
|
|
303
|
+
return [{ m[1] => { "$regex" => pattern, "$options" => "i" } }, param_index + 1]
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Comparison operators: >=, <=, <>, !=, >, <, =
|
|
307
|
+
if (m = cond.match(/\A(\w+)\s*(>=|<=|<>|!=|>|<|=)\s*\?\z/))
|
|
308
|
+
field = m[1]
|
|
309
|
+
op = m[2]
|
|
310
|
+
val = @params[param_index]
|
|
311
|
+
|
|
312
|
+
op_map = {
|
|
313
|
+
"=" => nil, "!=" => "$ne", "<>" => "$ne",
|
|
314
|
+
">" => "$gt", ">=" => "$gte",
|
|
315
|
+
"<" => "$lt", "<=" => "$lte"
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
mongo_op = op_map[op]
|
|
319
|
+
if mongo_op.nil?
|
|
320
|
+
return [{ field => val }, param_index + 1]
|
|
321
|
+
end
|
|
322
|
+
return [{ field => { mongo_op => val } }, param_index + 1]
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Fallback
|
|
326
|
+
[{ "$where" => cond }, param_index]
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Merge multiple single-field mongo condition hashes into one.
|
|
330
|
+
# Uses $and if field keys conflict.
|
|
331
|
+
def merge_mongo_conditions(conditions)
|
|
332
|
+
return conditions[0] if conditions.size == 1
|
|
333
|
+
|
|
334
|
+
merged = {}
|
|
335
|
+
has_conflict = false
|
|
336
|
+
|
|
337
|
+
conditions.each do |cond|
|
|
338
|
+
cond.each do |key, val|
|
|
339
|
+
if merged.key?(key)
|
|
340
|
+
has_conflict = true
|
|
341
|
+
break
|
|
342
|
+
end
|
|
343
|
+
merged[key] = val
|
|
344
|
+
end
|
|
345
|
+
break if has_conflict
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
return { "$and" => conditions } if has_conflict
|
|
349
|
+
|
|
350
|
+
merged
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Build the WHERE clause from accumulated conditions.
|
|
354
|
+
def build_where
|
|
355
|
+
parts = []
|
|
356
|
+
@wheres.each_with_index do |(connector, condition), index|
|
|
357
|
+
if index == 0
|
|
358
|
+
parts << condition
|
|
359
|
+
else
|
|
360
|
+
parts << "#{connector} #{condition}"
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
parts.join(" ")
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Ensure a database connection is available.
|
|
367
|
+
def ensure_db!
|
|
368
|
+
return unless @db.nil?
|
|
369
|
+
|
|
370
|
+
@db = Tina4.database if defined?(Tina4.database) && Tina4.database
|
|
371
|
+
raise "QueryBuilder: No database connection provided." if @db.nil?
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|