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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb72d4eef1764922872e50ac7f826e90348f10f7bf069013d6bc266cad69c141
4
- data.tar.gz: 97d04e7d94c9322784259fff6a3db3336da73f2c825232a1fd7933eac819e2a0
3
+ metadata.gz: bf58fba1e80ebbfcdeccfdf5ecab50bf0ce08754d834eaffe29e9e7e37ff4363
4
+ data.tar.gz: f59a4a709438114acd05275ee4220e032ebe539b3d27547b377de492f2583038
5
5
  SHA512:
6
- metadata.gz: d5a470035bb0c47a676b1d5e56c1ecdc0d072a38dfc99d7aeea2df67b29d637a6b3ac74d21d7c7962faecb2248b1b1f0da1472b16ff6002915d560e5fbdad0fa
7
- data.tar.gz: 7090764cf7dc0ea9fca18ca7e526e63e1b3618498efd5c8cf92cebdc8256458e1e6eaf9fbd87eb78b1b0c168abbd678b8ce1311f2764d15f63d0e2c114e4fbc2
6
+ metadata.gz: 3b4c3b693f8686a0119ed679627ab646439c4e7e4b75649e55e70e5207556582b06bd3413fe4560cbc60183f6f66f6732f4fe11e4c87b7358b2b7327976f5841
7
+ data.tar.gz: 93b12a0719367dc9c8e4f1a95339d43ae170ea14e4ea52c347d9419044ad508ff894c08ba11a1c930920365a779ca5e0ab95a7e442a8fe60383d35d5fdeac045
@@ -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
- results = db.fetch(sql, params, limit: limit, offset: offset)
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
- results = db.fetch(sql, [], limit: limit, offset: offset)
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
- results = db.fetch(sql, params, limit: limit, offset: offset)
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
- results = db.fetch(sql, params, limit: limit, offset: offset)
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
- results = db.fetch(sql, filter.values)
831
- results.map { |row| from_hash(row) }
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.