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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +90 -0
- data/lib/tina4/dev_admin.rb +255 -363
- 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
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.
|