tina4ruby 3.2.1 → 3.9.2

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.
@@ -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