tina4ruby 3.13.130 → 3.13.132
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/lib/tina4/dev_admin.rb +0 -207
- data/lib/tina4/frond.rb +37 -75
- data/lib/tina4/model_collection.rb +87 -0
- data/lib/tina4/orm.rb +39 -20
- data/lib/tina4/public/js/tina4-dev-admin.min.js +148 -512
- data/lib/tina4/rack_app.rb +4 -1
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +120 -138
- data/lib/tina4.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bf58fba1e80ebbfcdeccfdf5ecab50bf0ce08754d834eaffe29e9e7e37ff4363
|
|
4
|
+
data.tar.gz: f59a4a709438114acd05275ee4220e032ebe539b3d27547b377de492f2583038
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3b4c3b693f8686a0119ed679627ab646439c4e7e4b75649e55e70e5207556582b06bd3413fe4560cbc60183f6f66f6732f4fe11e4c87b7358b2b7327976f5841
|
|
7
|
+
data.tar.gz: 93b12a0719367dc9c8e4f1a95339d43ae170ea14e4ea52c347d9419044ad508ff894c08ba11a1c930920365a779ca5e0ab95a7e442a8fe60383d35d5fdeac045
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -395,15 +395,6 @@ module Tina4
|
|
|
395
395
|
denial = dev_mutation_denial(env)
|
|
396
396
|
return denial if denial
|
|
397
397
|
|
|
398
|
-
# Dynamic-path routes can't live in the case-tuple below — match
|
|
399
|
-
# them up front. /__dev/api/threads/{id}[/messages] is forwarded
|
|
400
|
-
# verbatim to the Rust agent's /threads/{id}[/messages] surface
|
|
401
|
-
# (mirrors Python's `_api_threads_sub`).
|
|
402
|
-
if path.start_with?("/__dev/api/threads/")
|
|
403
|
-
suffix = path[("/__dev/api".length)..] # leaves "/threads/{id}[/messages]"
|
|
404
|
-
return threads_sub_proxy(env, method, suffix)
|
|
405
|
-
end
|
|
406
|
-
|
|
407
398
|
case [method, path]
|
|
408
399
|
when ["GET", "/__dev"], ["GET", "/__dev/"]
|
|
409
400
|
serve_dashboard
|
|
@@ -606,22 +597,6 @@ module Tina4
|
|
|
606
597
|
when ["POST", "/__dev/api/grounding/token"]
|
|
607
598
|
body = read_json_body(env) || {}
|
|
608
599
|
json_response(grounding_token_save(body))
|
|
609
|
-
when ["POST", "/__dev/api/chat"]
|
|
610
|
-
# Proxy dev-admin chat to the Rust agent's /chat endpoint.
|
|
611
|
-
# The SPA POSTs {message, thread_id?, active_file?, settings?}
|
|
612
|
-
# and expects an SSE stream of `event: status/message/done`
|
|
613
|
-
# chunks. We forward the JSON body verbatim (active_file rides
|
|
614
|
-
# along) and pipe upstream bytes back as they arrive. Mirrors
|
|
615
|
-
# Python's `_api_chat` / `_proxy_to_supervisor` SSE path.
|
|
616
|
-
body = read_json_body(env) || {}
|
|
617
|
-
chat_proxy(body)
|
|
618
|
-
when ["GET", "/__dev/api/threads"]
|
|
619
|
-
# Parity with Python `_api_threads` (GET → list threads).
|
|
620
|
-
json_response(proxy_supervisor("/threads", method: "GET", query: env["QUERY_STRING"]))
|
|
621
|
-
when ["POST", "/__dev/api/threads"]
|
|
622
|
-
# Parity with Python `_api_threads` (POST → create thread).
|
|
623
|
-
body = read_json_body(env) || {}
|
|
624
|
-
json_response(proxy_supervisor("/threads", method: "POST", body: body))
|
|
625
600
|
when ["GET", "/__dev/api/connections"]
|
|
626
601
|
handle_connections_get
|
|
627
602
|
when ["POST", "/__dev/api/connections/test"]
|
|
@@ -664,24 +639,6 @@ module Tina4
|
|
|
664
639
|
e.message.include?("needs a path")
|
|
665
640
|
json_response({ "error" => e.message }, bad_path ? 404 : 503)
|
|
666
641
|
end
|
|
667
|
-
when ["GET", "/__dev/api/thoughts"]
|
|
668
|
-
json_response(thoughts_payload)
|
|
669
|
-
when ["POST", "/__dev/api/supervise/create"]
|
|
670
|
-
body = read_json_body(env) || {}
|
|
671
|
-
json_response(proxy_supervisor("/supervise/create", method: "POST", body: body))
|
|
672
|
-
when ["GET", "/__dev/api/supervise/sessions"]
|
|
673
|
-
json_response(proxy_supervisor("/supervise/sessions", method: "GET", query: env["QUERY_STRING"]))
|
|
674
|
-
when ["GET", "/__dev/api/supervise/diff"]
|
|
675
|
-
json_response(proxy_supervisor("/supervise/diff", method: "GET", query: env["QUERY_STRING"]))
|
|
676
|
-
when ["POST", "/__dev/api/supervise/commit"]
|
|
677
|
-
body = read_json_body(env) || {}
|
|
678
|
-
json_response(proxy_supervisor("/supervise/commit", method: "POST", body: body))
|
|
679
|
-
when ["POST", "/__dev/api/supervise/cancel"]
|
|
680
|
-
body = read_json_body(env) || {}
|
|
681
|
-
json_response(proxy_supervisor("/supervise/cancel", method: "POST", body: body))
|
|
682
|
-
when ["POST", "/__dev/api/execute"]
|
|
683
|
-
body = read_json_body(env) || {}
|
|
684
|
-
execute_proxy(body)
|
|
685
642
|
when ["GET", "/__dev/api/files"]
|
|
686
643
|
json_response(files_list(env))
|
|
687
644
|
when ["GET", "/__dev/api/file"]
|
|
@@ -1580,170 +1537,6 @@ module Tina4
|
|
|
1580
1537
|
{ deployed: name, files: copied }
|
|
1581
1538
|
end
|
|
1582
1539
|
|
|
1583
|
-
# ── New dev-admin surface area (parity with Python/PHP) ────
|
|
1584
|
-
|
|
1585
|
-
def supervisor_base
|
|
1586
|
-
base = ENV["TINA4_SUPERVISOR_URL"].to_s.strip
|
|
1587
|
-
return base unless base.empty?
|
|
1588
|
-
port = (ENV["TINA4_PORT"] || ENV["PORT"] || "7147").to_i + 2000
|
|
1589
|
-
"http://127.0.0.1:#{port}"
|
|
1590
|
-
end
|
|
1591
|
-
|
|
1592
|
-
def thoughts_payload
|
|
1593
|
-
base = supervisor_base
|
|
1594
|
-
begin
|
|
1595
|
-
uri = URI.parse("#{base}/thoughts")
|
|
1596
|
-
req = Net::HTTP::Get.new(uri)
|
|
1597
|
-
resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 5) { |h| h.request(req) }
|
|
1598
|
-
return JSON.parse(resp.body) if resp.is_a?(Net::HTTPSuccess)
|
|
1599
|
-
{ thoughts: [], error: "Supervisor returned #{resp.code}" }
|
|
1600
|
-
rescue StandardError => e
|
|
1601
|
-
{ thoughts: [], error: e.message }
|
|
1602
|
-
end
|
|
1603
|
-
end
|
|
1604
|
-
|
|
1605
|
-
def proxy_supervisor(path, method: "GET", body: nil, query: nil)
|
|
1606
|
-
base = supervisor_base
|
|
1607
|
-
url = "#{base}#{path}"
|
|
1608
|
-
url += "?#{query}" if query && !query.empty?
|
|
1609
|
-
begin
|
|
1610
|
-
uri = URI.parse(url)
|
|
1611
|
-
req = case method.upcase
|
|
1612
|
-
when "POST"
|
|
1613
|
-
r = Net::HTTP::Post.new(uri)
|
|
1614
|
-
r["Content-Type"] = "application/json"
|
|
1615
|
-
r.body = JSON.generate(body || {})
|
|
1616
|
-
r
|
|
1617
|
-
when "PATCH"
|
|
1618
|
-
r = Net::HTTP::Patch.new(uri)
|
|
1619
|
-
r["Content-Type"] = "application/json"
|
|
1620
|
-
r.body = JSON.generate(body || {})
|
|
1621
|
-
r
|
|
1622
|
-
when "PUT"
|
|
1623
|
-
r = Net::HTTP::Put.new(uri)
|
|
1624
|
-
r["Content-Type"] = "application/json"
|
|
1625
|
-
r.body = JSON.generate(body || {})
|
|
1626
|
-
r
|
|
1627
|
-
when "DELETE"
|
|
1628
|
-
r = Net::HTTP::Delete.new(uri)
|
|
1629
|
-
r["Content-Type"] = "application/json"
|
|
1630
|
-
r.body = JSON.generate(body) if body
|
|
1631
|
-
r
|
|
1632
|
-
else
|
|
1633
|
-
Net::HTTP::Get.new(uri)
|
|
1634
|
-
end
|
|
1635
|
-
resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 30) { |h| h.request(req) }
|
|
1636
|
-
begin
|
|
1637
|
-
JSON.parse(resp.body)
|
|
1638
|
-
rescue JSON::ParserError
|
|
1639
|
-
{ body: resp.body, status: resp.code.to_i }
|
|
1640
|
-
end
|
|
1641
|
-
rescue StandardError => e
|
|
1642
|
-
{ error: e.message, supervisor: base }
|
|
1643
|
-
end
|
|
1644
|
-
end
|
|
1645
|
-
|
|
1646
|
-
# Proxy /__dev/api/threads/{id}[/messages] through to the Rust
|
|
1647
|
-
# agent. Mirrors Python's `_api_threads_sub`: we already stripped
|
|
1648
|
-
# the dev-admin prefix in handle_request, so `suffix` is the path
|
|
1649
|
-
# the agent expects (e.g. `/threads/abc` or `/threads/abc/messages`).
|
|
1650
|
-
# PATCH /threads/{id} (archive/rename) and GET /threads/{id}/messages
|
|
1651
|
-
# are the two shapes the SPA actually fires.
|
|
1652
|
-
def threads_sub_proxy(env, method, suffix)
|
|
1653
|
-
case method.upcase
|
|
1654
|
-
when "GET"
|
|
1655
|
-
json_response(proxy_supervisor(suffix, method: "GET", query: env["QUERY_STRING"]))
|
|
1656
|
-
when "POST"
|
|
1657
|
-
body = read_json_body(env) || {}
|
|
1658
|
-
json_response(proxy_supervisor(suffix, method: "POST", body: body))
|
|
1659
|
-
when "PATCH"
|
|
1660
|
-
body = read_json_body(env) || {}
|
|
1661
|
-
json_response(proxy_supervisor(suffix, method: "PATCH", body: body))
|
|
1662
|
-
when "PUT"
|
|
1663
|
-
body = read_json_body(env) || {}
|
|
1664
|
-
json_response(proxy_supervisor(suffix, method: "PUT", body: body))
|
|
1665
|
-
when "DELETE"
|
|
1666
|
-
body = read_json_body(env)
|
|
1667
|
-
json_response(proxy_supervisor(suffix, method: "DELETE", body: body))
|
|
1668
|
-
else
|
|
1669
|
-
[405, { "content-type" => "application/json; charset=utf-8" },
|
|
1670
|
-
[JSON.generate({ error: "method not allowed" })]]
|
|
1671
|
-
end
|
|
1672
|
-
end
|
|
1673
|
-
|
|
1674
|
-
# POST /chat — proxy the SPA's chat payload to the Rust agent and
|
|
1675
|
-
# pipe the upstream SSE response back to the browser. Active-file
|
|
1676
|
-
# content (when present in the body) rides along verbatim.
|
|
1677
|
-
#
|
|
1678
|
-
# NOTE on streaming: Tina4 Ruby's Rack app does not currently
|
|
1679
|
-
# expose a chunk-by-chunk streaming API to handlers (see
|
|
1680
|
-
# response.rb — `response.stream(&block)` is not yet wired through
|
|
1681
|
-
# the case-tuple dispatcher used here). We do the next best thing:
|
|
1682
|
-
# use Net::HTTP#request_get with a block so we receive upstream
|
|
1683
|
-
# SSE chunks as they arrive, buffer them, and return the assembled
|
|
1684
|
-
# body with the upstream content-type intact. The SPA's
|
|
1685
|
-
# EventSource reader works either way (it parses `data:` lines
|
|
1686
|
-
# regardless of arrival cadence) — the TODO below tracks
|
|
1687
|
-
# converting this to a true streamed Rack body once dev_admin
|
|
1688
|
-
# routes are migrated off the case-tuple dispatcher.
|
|
1689
|
-
def chat_proxy(body)
|
|
1690
|
-
base = supervisor_base
|
|
1691
|
-
begin
|
|
1692
|
-
uri = URI.parse("#{base}/chat")
|
|
1693
|
-
req = Net::HTTP::Post.new(uri)
|
|
1694
|
-
req["Content-Type"] = "application/json"
|
|
1695
|
-
req["Accept"] = "text/event-stream"
|
|
1696
|
-
req.body = JSON.generate(body || {})
|
|
1697
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
1698
|
-
http.open_timeout = 2
|
|
1699
|
-
# /chat runs the supervisor → planner → coder loop with one or
|
|
1700
|
-
# more LLM round-trips. Matches Python's 600s budget.
|
|
1701
|
-
http.read_timeout = 600
|
|
1702
|
-
chunks = []
|
|
1703
|
-
upstream_status = 200
|
|
1704
|
-
upstream_ct = "text/event-stream"
|
|
1705
|
-
http.request(req) do |resp|
|
|
1706
|
-
upstream_status = resp.code.to_i
|
|
1707
|
-
upstream_ct = resp["content-type"] || upstream_ct
|
|
1708
|
-
# TODO: stream chunks straight into the Rack response once
|
|
1709
|
-
# dev_admin migrates to response.stream(). For now we
|
|
1710
|
-
# buffer — the SPA's SSE reader still parses correctly.
|
|
1711
|
-
resp.read_body { |chunk| chunks << chunk }
|
|
1712
|
-
end
|
|
1713
|
-
[upstream_status, { "content-type" => upstream_ct }, [chunks.join]]
|
|
1714
|
-
rescue StandardError => e
|
|
1715
|
-
body_str = JSON.generate({
|
|
1716
|
-
error: "supervisor unavailable",
|
|
1717
|
-
detail: e.message,
|
|
1718
|
-
hint: "Run `tina4 serve` (starts the agent server) or set TINA4_SUPERVISOR_URL",
|
|
1719
|
-
supervisor: base
|
|
1720
|
-
})
|
|
1721
|
-
[503, { "content-type" => "application/json; charset=utf-8" }, [body_str]]
|
|
1722
|
-
end
|
|
1723
|
-
end
|
|
1724
|
-
|
|
1725
|
-
def execute_proxy(body)
|
|
1726
|
-
# Proxy POST /execute to the supervisor at framework_port + 2000.
|
|
1727
|
-
# Pass through the response stream as-is (SSE or JSON).
|
|
1728
|
-
base = supervisor_base
|
|
1729
|
-
begin
|
|
1730
|
-
uri = URI.parse("#{base}/execute")
|
|
1731
|
-
req = Net::HTTP::Post.new(uri)
|
|
1732
|
-
req["Content-Type"] = "application/json"
|
|
1733
|
-
req["Accept"] = "text/event-stream"
|
|
1734
|
-
req.body = JSON.generate(body || {})
|
|
1735
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
1736
|
-
http.open_timeout = 2
|
|
1737
|
-
http.read_timeout = 300
|
|
1738
|
-
resp = http.request(req)
|
|
1739
|
-
ct = resp["content-type"] || "application/json; charset=utf-8"
|
|
1740
|
-
[resp.code.to_i, { "content-type" => ct }, [resp.body.to_s]]
|
|
1741
|
-
rescue StandardError => e
|
|
1742
|
-
body_str = JSON.generate({ error: e.message, supervisor: base })
|
|
1743
|
-
[502, { "content-type" => "application/json; charset=utf-8" }, [body_str]]
|
|
1744
|
-
end
|
|
1745
|
-
end
|
|
1746
|
-
|
|
1747
1540
|
def safe_project_path(rel_path)
|
|
1748
1541
|
root = File.expand_path(Dir.pwd)
|
|
1749
1542
|
resolved = File.expand_path(rel_path.to_s, root)
|
data/lib/tina4/frond.rb
CHANGED
|
@@ -2489,6 +2489,40 @@ module Tina4
|
|
|
2489
2489
|
}
|
|
2490
2490
|
end
|
|
2491
2491
|
|
|
2492
|
+
# Collect the body tokens of a {% <open_tag> %}...{% <close_tag> %} block,
|
|
2493
|
+
# honoring nested same-tag pairs. `start` is the index of the opening tag;
|
|
2494
|
+
# returns [body_tokens, index_just_past_the_matching_close]. Shared by
|
|
2495
|
+
# handle_cache / handle_spaceless / handle_autoescape, whose collection
|
|
2496
|
+
# loops were identical apart from the tag names.
|
|
2497
|
+
def collect_block_body(tokens, start, open_tag, close_tag)
|
|
2498
|
+
body_tokens = []
|
|
2499
|
+
i = start + 1
|
|
2500
|
+
depth = 0
|
|
2501
|
+
while i < tokens.length
|
|
2502
|
+
if tokens[i][0] == BLOCK
|
|
2503
|
+
tc, _, _ = strip_tag(tokens[i][1])
|
|
2504
|
+
tag = tc.split[0] || ""
|
|
2505
|
+
if tag == open_tag
|
|
2506
|
+
depth += 1
|
|
2507
|
+
body_tokens << tokens[i]
|
|
2508
|
+
elsif tag == close_tag
|
|
2509
|
+
if depth == 0
|
|
2510
|
+
i += 1
|
|
2511
|
+
break
|
|
2512
|
+
end
|
|
2513
|
+
depth -= 1
|
|
2514
|
+
body_tokens << tokens[i]
|
|
2515
|
+
else
|
|
2516
|
+
body_tokens << tokens[i]
|
|
2517
|
+
end
|
|
2518
|
+
else
|
|
2519
|
+
body_tokens << tokens[i]
|
|
2520
|
+
end
|
|
2521
|
+
i += 1
|
|
2522
|
+
end
|
|
2523
|
+
[body_tokens, i]
|
|
2524
|
+
end
|
|
2525
|
+
|
|
2492
2526
|
# {% cache "key" ttl %}...{% endcache %}
|
|
2493
2527
|
def handle_cache(tokens, start, context)
|
|
2494
2528
|
content, _, _ = strip_tag(tokens[start][1])
|
|
@@ -2523,31 +2557,7 @@ module Tina4
|
|
|
2523
2557
|
end
|
|
2524
2558
|
end
|
|
2525
2559
|
|
|
2526
|
-
body_tokens =
|
|
2527
|
-
i = start + 1
|
|
2528
|
-
depth = 0
|
|
2529
|
-
while i < tokens.length
|
|
2530
|
-
if tokens[i][0] == BLOCK
|
|
2531
|
-
tc, _, _ = strip_tag(tokens[i][1])
|
|
2532
|
-
tag = tc.split[0] || ""
|
|
2533
|
-
if tag == "cache"
|
|
2534
|
-
depth += 1
|
|
2535
|
-
body_tokens << tokens[i]
|
|
2536
|
-
elsif tag == "endcache"
|
|
2537
|
-
if depth == 0
|
|
2538
|
-
i += 1
|
|
2539
|
-
break
|
|
2540
|
-
end
|
|
2541
|
-
depth -= 1
|
|
2542
|
-
body_tokens << tokens[i]
|
|
2543
|
-
else
|
|
2544
|
-
body_tokens << tokens[i]
|
|
2545
|
-
end
|
|
2546
|
-
else
|
|
2547
|
-
body_tokens << tokens[i]
|
|
2548
|
-
end
|
|
2549
|
-
i += 1
|
|
2550
|
-
end
|
|
2560
|
+
body_tokens, i = collect_block_body(tokens, start, "cache", "endcache")
|
|
2551
2561
|
|
|
2552
2562
|
rendered = render_tokens(body_tokens.dup, context)
|
|
2553
2563
|
cap_cache(@fragment_cache, TEMPLATE_CACHE_MAX)
|
|
@@ -2773,31 +2783,7 @@ module Tina4
|
|
|
2773
2783
|
end
|
|
2774
2784
|
|
|
2775
2785
|
def handle_spaceless(tokens, start, context)
|
|
2776
|
-
body_tokens =
|
|
2777
|
-
i = start + 1
|
|
2778
|
-
depth = 0
|
|
2779
|
-
while i < tokens.length
|
|
2780
|
-
if tokens[i][0] == BLOCK
|
|
2781
|
-
tc, _, _ = strip_tag(tokens[i][1])
|
|
2782
|
-
tag = tc.split[0] || ""
|
|
2783
|
-
if tag == "spaceless"
|
|
2784
|
-
depth += 1
|
|
2785
|
-
body_tokens << tokens[i]
|
|
2786
|
-
elsif tag == "endspaceless"
|
|
2787
|
-
if depth == 0
|
|
2788
|
-
i += 1
|
|
2789
|
-
break
|
|
2790
|
-
end
|
|
2791
|
-
depth -= 1
|
|
2792
|
-
body_tokens << tokens[i]
|
|
2793
|
-
else
|
|
2794
|
-
body_tokens << tokens[i]
|
|
2795
|
-
end
|
|
2796
|
-
else
|
|
2797
|
-
body_tokens << tokens[i]
|
|
2798
|
-
end
|
|
2799
|
-
i += 1
|
|
2800
|
-
end
|
|
2786
|
+
body_tokens, i = collect_block_body(tokens, start, "spaceless", "endspaceless")
|
|
2801
2787
|
|
|
2802
2788
|
rendered = render_tokens(body_tokens.dup, context)
|
|
2803
2789
|
rendered = rendered.gsub(SPACELESS_RE, "><")
|
|
@@ -2809,31 +2795,7 @@ module Tina4
|
|
|
2809
2795
|
mode_match = content.match(AUTOESCAPE_RE)
|
|
2810
2796
|
auto_escape_on = !(mode_match && mode_match[1] == "false")
|
|
2811
2797
|
|
|
2812
|
-
body_tokens =
|
|
2813
|
-
i = start + 1
|
|
2814
|
-
depth = 0
|
|
2815
|
-
while i < tokens.length
|
|
2816
|
-
if tokens[i][0] == BLOCK
|
|
2817
|
-
tc, _, _ = strip_tag(tokens[i][1])
|
|
2818
|
-
tag = tc.split[0] || ""
|
|
2819
|
-
if tag == "autoescape"
|
|
2820
|
-
depth += 1
|
|
2821
|
-
body_tokens << tokens[i]
|
|
2822
|
-
elsif tag == "endautoescape"
|
|
2823
|
-
if depth == 0
|
|
2824
|
-
i += 1
|
|
2825
|
-
break
|
|
2826
|
-
end
|
|
2827
|
-
depth -= 1
|
|
2828
|
-
body_tokens << tokens[i]
|
|
2829
|
-
else
|
|
2830
|
-
body_tokens << tokens[i]
|
|
2831
|
-
end
|
|
2832
|
-
else
|
|
2833
|
-
body_tokens << tokens[i]
|
|
2834
|
-
end
|
|
2835
|
-
i += 1
|
|
2836
|
-
end
|
|
2798
|
+
body_tokens, i = collect_block_body(tokens, start, "autoescape", "endautoescape")
|
|
2837
2799
|
|
|
2838
2800
|
if !auto_escape_on
|
|
2839
2801
|
old_auto_escape = @auto_escape
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
# A list of ORM models that ALSO carries the total rows matching the query.
|
|
5
|
+
#
|
|
6
|
+
# ModelCollection is what the ORM read queries (+where+ / +select+ / +find+
|
|
7
|
+
# (filter form) / +all+ / +with_trashed+) return. It IS an Array -- iterate it,
|
|
8
|
+
# index it, slice it, map it, count it, serialise it to JSON -- so every
|
|
9
|
+
# existing caller keeps working unchanged. It adds one thing: the TOTAL number
|
|
10
|
+
# of rows matching the query's filter, independent of +limit+ / +offset+.
|
|
11
|
+
#
|
|
12
|
+
# The total is free. Every one of those methods already runs +db.fetch+, which
|
|
13
|
+
# computes a +SELECT COUNT(*)+ probe and hands it back on +DatabaseResult#count+;
|
|
14
|
+
# the ORM used to hydrate the page of models and throw that count away. This
|
|
15
|
+
# class carries it instead, so a caller with 20 models on the page can still
|
|
16
|
+
# learn there are 250 rows in the set -- with ZERO extra queries.
|
|
17
|
+
#
|
|
18
|
+
# Uniform across all four Tina4 frameworks (ADR-0064). Same concept, language-
|
|
19
|
+
# idiomatic accessor name:
|
|
20
|
+
#
|
|
21
|
+
# Python / Ruby : get_total_records to_paginate
|
|
22
|
+
# PHP / Node : getTotalRecords toPaginate
|
|
23
|
+
#
|
|
24
|
+
# The accessor is a METHOD, not a +.total+ property, on purpose: +Array#count+
|
|
25
|
+
# already exists, so a +.count+ would shadow a built-in. +DatabaseResult+ keeps
|
|
26
|
+
# its +.count+ (it is not an Array); both expose the identical seven-key
|
|
27
|
+
# +to_paginate+ envelope (ADR-0043).
|
|
28
|
+
class ModelCollection < Array
|
|
29
|
+
# The SQL limit / offset that produced this page -- carried for to_paginate.
|
|
30
|
+
# The total is exposed only through get_total_records (the documented
|
|
31
|
+
# accessor), never a bare reader, so it reads the same in every framework.
|
|
32
|
+
attr_reader :limit, :offset
|
|
33
|
+
|
|
34
|
+
# @param items [Array] the page of hydrated model instances
|
|
35
|
+
# @param total [Integer] total rows matching the query's filter (ignores limit/offset)
|
|
36
|
+
# @param limit [Integer] the SQL limit that produced this page (0 = none applied)
|
|
37
|
+
# @param offset [Integer] the SQL offset that produced this page
|
|
38
|
+
def initialize(items = [], total: 0, limit: 0, offset: 0)
|
|
39
|
+
super()
|
|
40
|
+
concat(items || [])
|
|
41
|
+
@total = total.to_i
|
|
42
|
+
@limit = limit.to_i
|
|
43
|
+
@offset = offset.to_i
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Total rows matching the query's filter, ignoring limit / offset.
|
|
47
|
+
#
|
|
48
|
+
# This is the whole point of the collection: the page slice you are iterating
|
|
49
|
+
# is capped by +limit+, but this number is the full count of matching rows --
|
|
50
|
+
# what a pager needs to render "page 3 of 13". Sourced from the fetch COUNT
|
|
51
|
+
# probe (+DatabaseResult#count+); no second query is fired.
|
|
52
|
+
def get_total_records
|
|
53
|
+
@total
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The canonical pagination envelope -- seven snake_case keys, identical to
|
|
57
|
+
# +DatabaseResult#to_paginate+ (ADR-0043) and to the other three frameworks'
|
|
58
|
+
# +toPaginate+:
|
|
59
|
+
#
|
|
60
|
+
# records the page's rows as model hashes (never re-sliced)
|
|
61
|
+
# total get_total_records -- the true total for the filter
|
|
62
|
+
# page floor(offset / per_page) + 1
|
|
63
|
+
# per_page the query's limit
|
|
64
|
+
# total_pages ceil(total / per_page)
|
|
65
|
+
# limit the SQL limit actually applied
|
|
66
|
+
# offset the SQL offset actually applied
|
|
67
|
+
#
|
|
68
|
+
# +records+ are model hashes (via +#to_h+) so the JSON a client sees matches
|
|
69
|
+
# +DatabaseResult+ exactly -- the result is uniform whether the route returned
|
|
70
|
+
# a raw +db.fetch+ or an ORM query.
|
|
71
|
+
def to_paginate
|
|
72
|
+
per_page = @limit > 0 ? @limit : size
|
|
73
|
+
page = per_page > 0 ? (@offset / per_page) + 1 : 1
|
|
74
|
+
total = @total
|
|
75
|
+
total_pages = per_page > 0 ? [1, (total.to_f / per_page).ceil].max : 1
|
|
76
|
+
{
|
|
77
|
+
records: map { |model| model.respond_to?(:to_h) ? model.to_h : model },
|
|
78
|
+
total: total,
|
|
79
|
+
page: page,
|
|
80
|
+
per_page: per_page,
|
|
81
|
+
total_pages: total_pages,
|
|
82
|
+
limit: per_page,
|
|
83
|
+
offset: @offset
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/tina4/orm.rb
CHANGED
|
@@ -250,16 +250,25 @@ module Tina4
|
|
|
250
250
|
|
|
251
251
|
def find(id_or_filter = nil, filter = nil, **kwargs)
|
|
252
252
|
include_list = kwargs.delete(:include)
|
|
253
|
+
# find(filter, limit:, offset:, order_by:) honours pagination on the
|
|
254
|
+
# filter/all form, matching the Python master (find -> select with
|
|
255
|
+
# limit/offset/order_by). Ruby used to swallow these into **kwargs and
|
|
256
|
+
# drop them, so find({...}, limit: 10) returned the default 100-row cap
|
|
257
|
+
# instead of 10 -- a parity divergence fixed alongside the ADR-0064 port.
|
|
258
|
+
# The PK branch (find_by_id) stays single and ignores them.
|
|
259
|
+
limit = kwargs.delete(:limit) || 100
|
|
260
|
+
offset = kwargs.delete(:offset)
|
|
261
|
+
order_by = kwargs.delete(:order_by)
|
|
253
262
|
|
|
254
263
|
# find(id) — find by primary key
|
|
255
264
|
# find(filter_hash) — find by criteria
|
|
256
265
|
# find(name: "Alice") — keyword args as filter hash
|
|
257
266
|
result = if id_or_filter.is_a?(Hash)
|
|
258
|
-
find_by_filter(id_or_filter)
|
|
267
|
+
find_by_filter(id_or_filter, limit: limit, offset: offset, order_by: order_by)
|
|
259
268
|
elsif filter.is_a?(Hash)
|
|
260
|
-
find_by_filter(filter)
|
|
269
|
+
find_by_filter(filter, limit: limit, offset: offset, order_by: order_by)
|
|
261
270
|
elsif !kwargs.empty?
|
|
262
|
-
find_by_filter(kwargs)
|
|
271
|
+
find_by_filter(kwargs, limit: limit, offset: offset, order_by: order_by)
|
|
263
272
|
else
|
|
264
273
|
find_by_id(id_or_filter)
|
|
265
274
|
end
|
|
@@ -369,6 +378,23 @@ module Tina4
|
|
|
369
378
|
end
|
|
370
379
|
end
|
|
371
380
|
|
|
381
|
+
# Hydrate a fetch result into a ModelCollection (ADR-0064).
|
|
382
|
+
#
|
|
383
|
+
# The page of models AND the total for the query in one return: every read
|
|
384
|
+
# path already runs db.fetch, which computes a SELECT COUNT(*) probe and
|
|
385
|
+
# hands the TRUE total back on DatabaseResult#count. We carry it on the
|
|
386
|
+
# collection instead of throwing it away -- so get_total_records() /
|
|
387
|
+
# to_paginate() cost ZERO extra queries. limit/offset are what the fetch
|
|
388
|
+
# actually applied, so ModelCollection#to_paginate matches the same-query
|
|
389
|
+
# db.fetch(...).to_paginate exactly. The soft-delete filter lives in the SQL
|
|
390
|
+
# this fetch ran, so the probe already respects it.
|
|
391
|
+
def hydrate_collection(results, include: nil)
|
|
392
|
+
instances = results.map { |row| from_hash(row) }
|
|
393
|
+
eager_load(instances, include) if include
|
|
394
|
+
ModelCollection.new(instances, total: results.count,
|
|
395
|
+
limit: results.limit, offset: results.offset)
|
|
396
|
+
end
|
|
397
|
+
|
|
372
398
|
def where(conditions, params = [], limit: 100, offset: nil, order_by: nil, include: nil)
|
|
373
399
|
sql = "SELECT * FROM #{table_name}"
|
|
374
400
|
if soft_delete
|
|
@@ -377,10 +403,7 @@ module Tina4
|
|
|
377
403
|
sql += " WHERE #{conditions}"
|
|
378
404
|
end
|
|
379
405
|
sql += " ORDER BY #{order_by}" if order_by
|
|
380
|
-
|
|
381
|
-
instances = results.map { |row| from_hash(row) }
|
|
382
|
-
eager_load(instances, include) if include
|
|
383
|
-
instances
|
|
406
|
+
hydrate_collection(db.fetch(sql, params, limit: limit, offset: offset), include: include)
|
|
384
407
|
end
|
|
385
408
|
|
|
386
409
|
def all(limit: 100, offset: nil, order_by: nil, include: nil)
|
|
@@ -389,17 +412,11 @@ module Tina4
|
|
|
389
412
|
sql += " WHERE #{soft_delete_field} IS NULL OR #{soft_delete_field} = 0"
|
|
390
413
|
end
|
|
391
414
|
sql += " ORDER BY #{order_by}" if order_by
|
|
392
|
-
|
|
393
|
-
instances = results.map { |row| from_hash(row) }
|
|
394
|
-
eager_load(instances, include) if include
|
|
395
|
-
instances
|
|
415
|
+
hydrate_collection(db.fetch(sql, [], limit: limit, offset: offset), include: include)
|
|
396
416
|
end
|
|
397
417
|
|
|
398
418
|
def select(sql, params = [], limit: 100, offset: nil, include: nil)
|
|
399
|
-
|
|
400
|
-
instances = results.map { |row| from_hash(row) }
|
|
401
|
-
eager_load(instances, include) if include
|
|
402
|
-
instances
|
|
419
|
+
hydrate_collection(db.fetch(sql, params, limit: limit, offset: offset), include: include)
|
|
403
420
|
end
|
|
404
421
|
|
|
405
422
|
def select_one(sql, params = [], include: nil)
|
|
@@ -536,8 +553,7 @@ module Tina4
|
|
|
536
553
|
|
|
537
554
|
def with_trashed(conditions = "1=1", params = [], limit: 100, offset: 0)
|
|
538
555
|
sql = "SELECT * FROM #{table_name} WHERE #{conditions}"
|
|
539
|
-
|
|
540
|
-
results.map { |row| from_hash(row) }
|
|
556
|
+
hydrate_collection(db.fetch(sql, params, limit: limit, offset: offset))
|
|
541
557
|
end
|
|
542
558
|
|
|
543
559
|
def create_table
|
|
@@ -821,14 +837,17 @@ module Tina4
|
|
|
821
837
|
Tina4.bind_database(Tina4::Database.new(url, username: ENV.fetch("TINA4_DATABASE_USERNAME", ""), password: ENV.fetch("TINA4_DATABASE_PASSWORD", "")))
|
|
822
838
|
end
|
|
823
839
|
|
|
824
|
-
def find_by_filter(filter)
|
|
840
|
+
def find_by_filter(filter, limit: 100, offset: nil, order_by: nil)
|
|
825
841
|
where_parts = filter.keys.map { |k| "#{k} = ?" }
|
|
826
842
|
sql = "SELECT * FROM #{table_name} WHERE #{where_parts.join(' AND ')}"
|
|
827
843
|
if soft_delete
|
|
828
844
|
sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
|
|
829
845
|
end
|
|
830
|
-
|
|
831
|
-
|
|
846
|
+
sql += " ORDER BY #{order_by}" if order_by
|
|
847
|
+
# find(filter) returns a ModelCollection (ADR-0064); find(pk) stays single
|
|
848
|
+
# (find_by_id -> select_one -> .first). find() applies its own eager_load
|
|
849
|
+
# to the returned collection, which is an Array so nothing there changes.
|
|
850
|
+
hydrate_collection(db.fetch(sql, filter.values, limit: limit, offset: offset))
|
|
832
851
|
end
|
|
833
852
|
|
|
834
853
|
# Render a column DEFAULT literal for create_table.
|