tina4ruby 3.13.131 → 3.13.133

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