tina4ruby 3.13.97 → 3.13.99
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 +84 -0
- data/lib/tina4/ai.rb +32 -3
- data/lib/tina4/api.rb +5 -0
- data/lib/tina4/auto_crud.rb +62 -4
- data/lib/tina4/background.rb +112 -31
- data/lib/tina4/cache.rb +3 -2
- data/lib/tina4/cli.rb +55 -67
- data/lib/tina4/database.rb +97 -49
- data/lib/tina4/database_adapter.rb +169 -15
- data/lib/tina4/dev_admin.rb +137 -9
- data/lib/tina4/dispatch_pipeline.rb +145 -4
- data/lib/tina4/drivers/firebird_driver.rb +59 -12
- data/lib/tina4/drivers/mongodb_driver.rb +98 -14
- data/lib/tina4/drivers/mssql_driver.rb +39 -2
- data/lib/tina4/drivers/mysql_driver.rb +43 -3
- data/lib/tina4/drivers/odbc_driver.rb +36 -2
- data/lib/tina4/drivers/postgres_driver.rb +5 -0
- data/lib/tina4/drivers/sqlite_driver.rb +11 -1
- data/lib/tina4/env.rb +1 -1
- data/lib/tina4/error_overlay.rb +43 -49
- data/lib/tina4/field_types.rb +33 -16
- data/lib/tina4/frond.rb +24 -2
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +1 -1
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +1 -1
- data/lib/tina4/graphql.rb +2 -2
- data/lib/tina4/log.rb +652 -485
- data/lib/tina4/mcp.rb +9 -1
- data/lib/tina4/messenger.rb +25 -0
- data/lib/tina4/middleware.rb +189 -76
- data/lib/tina4/migration.rb +47 -15
- data/lib/tina4/orm.rb +280 -59
- data/lib/tina4/port_takeover.rb +202 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +23 -19
- data/lib/tina4/rack_app.rb +201 -59
- data/lib/tina4/realtime.rb +6 -1
- data/lib/tina4/request.rb +259 -51
- data/lib/tina4/router.rb +20 -2
- data/lib/tina4/seeder.rb +68 -19
- data/lib/tina4/shutdown.rb +4 -0
- data/lib/tina4/sql_translator.rb +115 -86
- data/lib/tina4/swagger.rb +19 -3
- data/lib/tina4/template.rb +61 -6
- data/lib/tina4/test_client.rb +49 -3
- data/lib/tina4/testing.rb +16 -11
- data/lib/tina4/validator.rb +7 -1
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -40
- data/lib/tina4.rb +12 -1
- metadata +3 -19
- data/lib/tina4/scss/tina4css/_alerts.scss +0 -34
- data/lib/tina4/scss/tina4css/_badges.scss +0 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +0 -69
- data/lib/tina4/scss/tina4css/_cards.scss +0 -49
- data/lib/tina4/scss/tina4css/_forms.scss +0 -156
- data/lib/tina4/scss/tina4css/_grid.scss +0 -81
- data/lib/tina4/scss/tina4css/_modals.scss +0 -84
- data/lib/tina4/scss/tina4css/_nav.scss +0 -149
- data/lib/tina4/scss/tina4css/_pagination.scss +0 -63
- data/lib/tina4/scss/tina4css/_reset.scss +0 -94
- data/lib/tina4/scss/tina4css/_tables.scss +0 -54
- data/lib/tina4/scss/tina4css/_typography.scss +0 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +0 -208
- data/lib/tina4/scss/tina4css/_variables.scss +0 -117
- data/lib/tina4/scss/tina4css/base.scss +0 -1
- data/lib/tina4/scss/tina4css/colors.scss +0 -48
- data/lib/tina4/scss/tina4css/tina4.scss +0 -18
data/lib/tina4/orm.rb
CHANGED
|
@@ -33,9 +33,40 @@ module Tina4
|
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# Pluralize a singular noun -- the inverse of {singularize}, and the SAME
|
|
37
|
+
# smart-inflection rules the hand-written has_many relies on.
|
|
38
|
+
#
|
|
39
|
+
# "post" -> "posts", "category" -> "categories", "box" -> "boxes",
|
|
40
|
+
# "class" -> "classes", "day" -> "days". foreign_key_field's auto
|
|
41
|
+
# related_name used a naive downcase + "s" ("Category" -> "categorys" -- a
|
|
42
|
+
# broken accessor name); it now routes through here so the FK-wired has_many
|
|
43
|
+
# matches the hand-written one.
|
|
44
|
+
def self.pluralize(word)
|
|
45
|
+
s = word.to_s
|
|
46
|
+
if s =~ /[^aeiou]y\z/i
|
|
47
|
+
s.sub(/y\z/i, "ies")
|
|
48
|
+
elsif s =~ /(s|ss|sh|ch|x|z)\z/i
|
|
49
|
+
"#{s}es"
|
|
50
|
+
else
|
|
51
|
+
"#{s}s"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
36
55
|
class ORM
|
|
37
56
|
include Tina4::FieldTypes
|
|
38
57
|
|
|
58
|
+
# REL-EAGER-UNBOUNDED: max parent PKs per eager "WHERE fk IN (...)" query, so
|
|
59
|
+
# a very large parent set never yields an unbounded IN list (a query-size /
|
|
60
|
+
# driver parameter-limit risk). Each chunk is one query, each fetched a page
|
|
61
|
+
# at a time so no relation is ever silently truncated.
|
|
62
|
+
#
|
|
63
|
+
# REL-DEC-01: relationships are READ-SIDE-ONLY -- foreign_key_field wires up
|
|
64
|
+
# traversal accessors but emits NO DB-level FK / ON DELETE clause (consistent
|
|
65
|
+
# with the no-foreign-key Firebird rule), so deleting a parent does not
|
|
66
|
+
# cascade to children at the engine level; integrity is the migration's job.
|
|
67
|
+
EAGER_IN_CHUNK = 500
|
|
68
|
+
EAGER_PAGE_SIZE = 1000
|
|
69
|
+
|
|
39
70
|
# When a new model class is defined, resolve any deferred ForeignKeyField
|
|
40
71
|
# wiring that targets it. The string / forward-reference form of
|
|
41
72
|
# `foreign_key_field` (e.g. `references: "Author"`) records the has_many
|
|
@@ -267,10 +298,24 @@ module Tina4
|
|
|
267
298
|
pk_values = instances.map { |inst| inst.__send__(pk) }.compact.uniq
|
|
268
299
|
next if pk_values.empty?
|
|
269
300
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
301
|
+
# REL-SOFTDELETE-TRAVERSAL: a soft-deleted child must not surface
|
|
302
|
+
# through eager traversal (parity with lazy + the finders).
|
|
303
|
+
soft = klass.soft_delete ? " AND (#{klass.soft_delete_field} IS NULL OR #{klass.soft_delete_field} = 0)" : ""
|
|
304
|
+
order_col = klass.primary_key_field || :id
|
|
305
|
+
# REL-EAGER-UNBOUNDED: chunk the parent PKs so the IN list stays
|
|
306
|
+
# bounded, and page each chunk so no relation is truncated.
|
|
307
|
+
related_records = []
|
|
308
|
+
pk_values.each_slice(EAGER_IN_CHUNK) do |chunk|
|
|
309
|
+
placeholders = chunk.map { "?" }.join(",")
|
|
310
|
+
sql = "SELECT * FROM #{klass.table_name} WHERE #{fk} IN (#{placeholders})#{soft} ORDER BY #{order_col}"
|
|
311
|
+
offset = 0
|
|
312
|
+
loop do
|
|
313
|
+
batch = klass.db.fetch(sql, chunk, limit: EAGER_PAGE_SIZE, offset: offset).to_a
|
|
314
|
+
related_records.concat(batch.map { |row| klass.from_hash(row) })
|
|
315
|
+
break if batch.length < EAGER_PAGE_SIZE
|
|
316
|
+
offset += EAGER_PAGE_SIZE
|
|
317
|
+
end
|
|
318
|
+
end
|
|
274
319
|
|
|
275
320
|
# Eager load nested
|
|
276
321
|
klass.eager_load(related_records, nested) unless nested.empty?
|
|
@@ -300,10 +345,16 @@ module Tina4
|
|
|
300
345
|
next if fk_values.empty?
|
|
301
346
|
|
|
302
347
|
related_pk = klass.primary_key_field || :id
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
related_records =
|
|
348
|
+
# REL-SOFTDELETE-TRAVERSAL: exclude a soft-deleted parent (parity with
|
|
349
|
+
# find). REL-EAGER-UNBOUNDED: chunk the FK values.
|
|
350
|
+
soft = klass.soft_delete ? " AND (#{klass.soft_delete_field} IS NULL OR #{klass.soft_delete_field} = 0)" : ""
|
|
351
|
+
related_records = []
|
|
352
|
+
fk_values.each_slice(EAGER_IN_CHUNK) do |chunk|
|
|
353
|
+
placeholders = chunk.map { "?" }.join(",")
|
|
354
|
+
sql = "SELECT * FROM #{klass.table_name} WHERE #{related_pk} IN (#{placeholders})#{soft}"
|
|
355
|
+
# One row per distinct PK, so limit == chunk size (default 100 would truncate a full chunk).
|
|
356
|
+
related_records.concat(klass.db.fetch(sql, chunk, limit: chunk.length).to_a.map { |row| klass.from_hash(row) })
|
|
357
|
+
end
|
|
307
358
|
|
|
308
359
|
klass.eager_load(related_records, nested) unless nested.empty?
|
|
309
360
|
|
|
@@ -370,15 +421,49 @@ module Tina4
|
|
|
370
421
|
ORM.instance_variable_set(:@query_cache, QueryCache.new(default_ttl: 0, max_size: 500))
|
|
371
422
|
end
|
|
372
423
|
|
|
424
|
+
# Table names a query reads FROM / JOINs -- lowercased, schema-stripped.
|
|
425
|
+
#
|
|
426
|
+
# Best-effort: for each FROM/JOIN keyword it takes the following
|
|
427
|
+
# identifier, drops any quoting (backticks, double quotes, square
|
|
428
|
+
# brackets) and schema prefix (public.users -> users), and ignores the
|
|
429
|
+
# alias. A cached query is tagged with these tables so a write to any one
|
|
430
|
+
# of them invalidates it (CACHE-DEC-01).
|
|
431
|
+
def tables_in_sql(sql)
|
|
432
|
+
tables = {}
|
|
433
|
+
(sql || "").scan(
|
|
434
|
+
%r{\b(?:FROM|JOIN)\s+([`"\[]?[A-Za-z_][\w$]*[`"\]]?(?:\.[`"\[]?[A-Za-z_][\w$]*[`"\]]?)?)}i
|
|
435
|
+
).each do |match|
|
|
436
|
+
name = match[0].gsub(/[`"\[\]]/, "")
|
|
437
|
+
name = name.split(".").last if name.include?(".")
|
|
438
|
+
tables[name.downcase] = true unless name.empty?
|
|
439
|
+
end
|
|
440
|
+
tables.keys
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Every table a cached query touches: this model's table plus every
|
|
444
|
+
# FROM/JOIN table in `sql`. A write to any of these busts the entry.
|
|
445
|
+
def cache_tags(sql)
|
|
446
|
+
tags = [table_name.to_s.downcase]
|
|
447
|
+
tables_in_sql(sql).each { |table| tags << table unless tags.include?(table) }
|
|
448
|
+
tags
|
|
449
|
+
end
|
|
450
|
+
|
|
373
451
|
# SQL query with result caching. Returns an array of ORM instances.
|
|
374
452
|
#
|
|
375
|
-
# Parity with the Python master's `cached
|
|
376
|
-
#
|
|
377
|
-
#
|
|
453
|
+
# Parity with the Python master's `cached`: same key shape, and a miss
|
|
454
|
+
# delegates to `select` so eager loading and the row cap behave
|
|
455
|
+
# identically to an uncached read.
|
|
378
456
|
#
|
|
379
|
-
#
|
|
380
|
-
# this model's
|
|
457
|
+
# Invalidation (CACHE-DEC-01): the entry is tagged by every table the query
|
|
458
|
+
# touches (this model's table plus any FROM/JOIN tables), so a write through
|
|
459
|
+
# the ORM (save/delete/force_delete/restore) to ANY of those tables busts
|
|
460
|
+
# it. `ttl <= 0` means NO-CACHE -- the query runs and the rows are returned
|
|
461
|
+
# but nothing is stored, so every read hits the database (it is NOT an
|
|
462
|
+
# infinite-lived entry).
|
|
381
463
|
def cached(sql, params = [], ttl: 60, limit: 100, offset: nil, include: nil)
|
|
464
|
+
# ttl <= 0 is NO-CACHE: run it live, store nothing, read nothing.
|
|
465
|
+
return select(sql, params, limit: limit, offset: offset, include: include) if ttl <= 0
|
|
466
|
+
|
|
382
467
|
key = "#{name}:#{QueryCache.query_key(sql, params)}:#{limit}:#{offset || 0}"
|
|
383
468
|
|
|
384
469
|
# nil-check, NOT a truthiness check: a query that legitimately returns no
|
|
@@ -389,15 +474,19 @@ module Tina4
|
|
|
389
474
|
return hit unless hit.nil?
|
|
390
475
|
|
|
391
476
|
result = select(sql, params, limit: limit, offset: offset, include: include)
|
|
392
|
-
query_cache.set(key, result, ttl: ttl, tags:
|
|
477
|
+
query_cache.set(key, result, ttl: ttl, tags: cache_tags(sql))
|
|
393
478
|
result
|
|
394
479
|
end
|
|
395
480
|
|
|
396
|
-
# Invalidate every cached query
|
|
397
|
-
#
|
|
398
|
-
#
|
|
481
|
+
# Invalidate every cached query that touches this model's table.
|
|
482
|
+
#
|
|
483
|
+
# Tag-scoped, NOT a wholesale flush: a cached JOIN on another model that
|
|
484
|
+
# reads this table is busted too (it carries this table's tag), while a
|
|
485
|
+
# query that never touches this table is left intact. Called after every
|
|
486
|
+
# ORM write (save/delete/force_delete/restore) so a read-after-write never
|
|
487
|
+
# serves a stale/deleted row (CACHE-DEC-01).
|
|
399
488
|
def clear_cache
|
|
400
|
-
query_cache.clear_tag(
|
|
489
|
+
query_cache.clear_tag(table_name.to_s.downcase)
|
|
401
490
|
nil
|
|
402
491
|
end
|
|
403
492
|
|
|
@@ -500,7 +589,6 @@ module Tina4
|
|
|
500
589
|
string: "VARCHAR(255)",
|
|
501
590
|
text: "TEXT",
|
|
502
591
|
float: "REAL",
|
|
503
|
-
decimal: "REAL",
|
|
504
592
|
boolean: bool_sql,
|
|
505
593
|
date: "DATE",
|
|
506
594
|
datetime: datetime_sql,
|
|
@@ -514,6 +602,14 @@ module Tina4
|
|
|
514
602
|
sql_type = type_map[opts[:type]] || "TEXT"
|
|
515
603
|
if opts[:type] == :string && opts[:length]
|
|
516
604
|
sql_type = "VARCHAR(#{opts[:length]})"
|
|
605
|
+
elsif opts[:type] == :decimal
|
|
606
|
+
# decimal_field stores precision/scale -- emit a real DECIMAL(p, s)
|
|
607
|
+
# (identical on PG/MySQL/MSSQL/Firebird/SQLite) instead of the old
|
|
608
|
+
# REAL, which silently DROPPED the declared scale. float_field /
|
|
609
|
+
# numeric_field stay REAL (the documented float default).
|
|
610
|
+
precision = opts[:precision] || 10
|
|
611
|
+
scale = opts[:scale] || 2
|
|
612
|
+
sql_type = "DECIMAL(#{precision},#{scale})"
|
|
517
613
|
end
|
|
518
614
|
|
|
519
615
|
parts = ["#{name} #{sql_type}"]
|
|
@@ -539,13 +635,33 @@ module Tina4
|
|
|
539
635
|
col_defs << parts.join(" ")
|
|
540
636
|
end
|
|
541
637
|
|
|
638
|
+
# SOFTDEL-DEC-02: a soft_delete model needs its flag column, but
|
|
639
|
+
# create_table only knew about DECLARED fields -- so a soft_delete model
|
|
640
|
+
# that never declared the flag built a table with NO such column, and
|
|
641
|
+
# every soft-delete read/write then errored on the missing column. Inject
|
|
642
|
+
# it here (INTEGER 0/1, default 0), honouring the CONFIGURABLE
|
|
643
|
+
# soft_delete_field (default :is_deleted -- NOT a hard-coded is_deleted),
|
|
644
|
+
# unless the model already declares it, so the generated schema always
|
|
645
|
+
# matches the soft-delete behaviour.
|
|
646
|
+
if soft_delete
|
|
647
|
+
sd_field = soft_delete_field.to_s
|
|
648
|
+
declared = field_definitions.keys.map(&:to_s)
|
|
649
|
+
col_defs << "#{soft_delete_field} INTEGER DEFAULT 0" unless declared.include?(sd_field)
|
|
650
|
+
end
|
|
651
|
+
|
|
542
652
|
# A COMPOSITE key is declared ONCE, at table level; the per-column inline
|
|
543
653
|
# form above is suppressed for it.
|
|
544
654
|
if primary_key_fields.length > 1
|
|
545
655
|
col_defs << "PRIMARY KEY (#{primary_key_fields.join(', ')})"
|
|
546
656
|
end
|
|
547
657
|
|
|
548
|
-
|
|
658
|
+
# MSSQL and Firebird reject `IF NOT EXISTS` on CREATE TABLE (a syntax
|
|
659
|
+
# error). The db.table_exists?(table_name) guard at the top of
|
|
660
|
+
# create_table already returns early when the table is present, so
|
|
661
|
+
# `IF NOT EXISTS` is pure redundancy on every engine and is simply
|
|
662
|
+
# omitted where it does not parse.
|
|
663
|
+
if_not_exists = %w[mssql sqlserver firebird].include?(engine) ? "" : "IF NOT EXISTS "
|
|
664
|
+
sql = "CREATE TABLE #{if_not_exists}#{table_name} (#{col_defs.join(', ')})"
|
|
549
665
|
|
|
550
666
|
# Translate AUTOINCREMENT to the engine's auto-increment syntax
|
|
551
667
|
# (INTEGER PRIMARY KEY AUTOINCREMENT -> SERIAL PRIMARY KEY on PG, etc.).
|
|
@@ -561,7 +677,16 @@ module Tina4
|
|
|
561
677
|
# still see a clean failure instead of a thrown error.
|
|
562
678
|
begin
|
|
563
679
|
db.execute(sql)
|
|
564
|
-
|
|
680
|
+
begin
|
|
681
|
+
db.commit
|
|
682
|
+
rescue StandardError => ce
|
|
683
|
+
# execute() auto-commits a standalone DDL. A bare commit then flushes
|
|
684
|
+
# any implicit transaction, but some drivers (MSSQL/tiny_tds) raise
|
|
685
|
+
# "COMMIT ... has no corresponding BEGIN TRANSACTION" because there is
|
|
686
|
+
# no open transaction to commit. The DDL already succeeded, so that
|
|
687
|
+
# ONE case is benign - re-raise anything else.
|
|
688
|
+
raise ce unless ce.message.to_s =~ /no corresponding begin/i
|
|
689
|
+
end
|
|
565
690
|
true
|
|
566
691
|
rescue => e
|
|
567
692
|
Tina4::Log.error("create_table failed for #{table_name}: #{db.get_error || e.message}", { sql: sql })
|
|
@@ -930,6 +1055,8 @@ module Tina4
|
|
|
930
1055
|
|
|
931
1056
|
@persisted = true
|
|
932
1057
|
@last_error = nil
|
|
1058
|
+
# Bust cached reads of any table this write touched (CACHE-DEC-01).
|
|
1059
|
+
self.class.clear_cache
|
|
933
1060
|
self
|
|
934
1061
|
end
|
|
935
1062
|
|
|
@@ -958,6 +1085,8 @@ module Tina4
|
|
|
958
1085
|
end
|
|
959
1086
|
end
|
|
960
1087
|
@persisted = false
|
|
1088
|
+
# Bust cached reads of any table this write touched (CACHE-DEC-01).
|
|
1089
|
+
self.class.clear_cache
|
|
961
1090
|
true
|
|
962
1091
|
end
|
|
963
1092
|
|
|
@@ -970,6 +1099,8 @@ module Tina4
|
|
|
970
1099
|
db.delete(self.class.table_name, pk_filter)
|
|
971
1100
|
end
|
|
972
1101
|
@persisted = false
|
|
1102
|
+
# Bust cached reads of any table this write touched (CACHE-DEC-01).
|
|
1103
|
+
self.class.clear_cache
|
|
973
1104
|
true
|
|
974
1105
|
end
|
|
975
1106
|
|
|
@@ -988,15 +1119,68 @@ module Tina4
|
|
|
988
1119
|
)
|
|
989
1120
|
end
|
|
990
1121
|
__send__("#{self.class.soft_delete_field}=", 0) if respond_to?("#{self.class.soft_delete_field}=")
|
|
1122
|
+
# Bust cached reads of any table this write touched (CACHE-DEC-01).
|
|
1123
|
+
self.class.clear_cache
|
|
991
1124
|
true
|
|
992
1125
|
end
|
|
993
1126
|
|
|
1127
|
+
# Validate all declared fields; returns a list of error messages (empty =
|
|
1128
|
+
# valid). ENFORCED on save() -- an invalid model never reaches the driver.
|
|
1129
|
+
#
|
|
1130
|
+
# Feature 19 (VALID-RUBY-NULLONLY + VALID-TWO-MESSAGES): Ruby's validate used
|
|
1131
|
+
# to be NULL-ONLY, so an over-length or wrong-format value the other three
|
|
1132
|
+
# frameworks reject was written silently. It now enforces the SHARED richness
|
|
1133
|
+
# -- required, string length, numeric range, format (pattern) and numeric type
|
|
1134
|
+
# -- and emits the CANONICAL request-Validator wording ("<field> is required",
|
|
1135
|
+
# "<field> must be at most N characters", "<field> does not match the required
|
|
1136
|
+
# format") so the ORM validator and the request-body Validator speak ONE
|
|
1137
|
+
# vocabulary. `length:` stays a DDL sizing hint and is never validated.
|
|
994
1138
|
def validate
|
|
995
1139
|
errors = []
|
|
996
1140
|
self.class.field_definitions.each do |name, opts|
|
|
997
1141
|
value = __send__(name)
|
|
998
|
-
|
|
999
|
-
|
|
1142
|
+
|
|
1143
|
+
# required: an explicit required: true fails on nil OR blank (user input);
|
|
1144
|
+
# a NOT NULL column (nullable: false, no default, not auto-increment)
|
|
1145
|
+
# fails on nil (the column constraint). required short-circuits -- no
|
|
1146
|
+
# other rule adds signal on a missing value.
|
|
1147
|
+
blank = value.nil? || (value.is_a?(String) && value.strip.empty?)
|
|
1148
|
+
column_not_null = !opts[:nullable] && !opts[:auto_increment] && !opts[:default]
|
|
1149
|
+
if (opts[:required] && blank) || (column_not_null && value.nil?)
|
|
1150
|
+
errors << "#{name} is required"
|
|
1151
|
+
next
|
|
1152
|
+
end
|
|
1153
|
+
next if value.nil?
|
|
1154
|
+
|
|
1155
|
+
# length + format apply to string values (a non-string is a type concern).
|
|
1156
|
+
if value.is_a?(String)
|
|
1157
|
+
if opts[:min_length] && value.length < opts[:min_length]
|
|
1158
|
+
errors << "#{name} must be at least #{opts[:min_length]} characters"
|
|
1159
|
+
end
|
|
1160
|
+
if opts[:max_length] && value.length > opts[:max_length]
|
|
1161
|
+
errors << "#{name} must be at most #{opts[:max_length]} characters"
|
|
1162
|
+
end
|
|
1163
|
+
if opts[:pattern]
|
|
1164
|
+
regexp = opts[:pattern].is_a?(Regexp) ? opts[:pattern] : Regexp.new(opts[:pattern])
|
|
1165
|
+
errors << "#{name} does not match the required format" unless value.match?(regexp)
|
|
1166
|
+
end
|
|
1167
|
+
end
|
|
1168
|
+
|
|
1169
|
+
# a declared numeric field carrying a non-numeric value is a type error;
|
|
1170
|
+
# a numeric string (a form value like "42") is coerced and range-checked.
|
|
1171
|
+
if %i[integer float decimal].include?(opts[:type]) && !value.is_a?(Numeric)
|
|
1172
|
+
coerced = Float(value, exception: false)
|
|
1173
|
+
if coerced.nil?
|
|
1174
|
+
errors << "#{name} must be a number"
|
|
1175
|
+
next
|
|
1176
|
+
end
|
|
1177
|
+
value = coerced
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
# numeric range applies to numeric values.
|
|
1181
|
+
if value.is_a?(Numeric)
|
|
1182
|
+
errors << "#{name} must be at least #{opts[:min]}" if opts[:min] && value < opts[:min]
|
|
1183
|
+
errors << "#{name} must be at most #{opts[:max]}" if opts[:max] && value > opts[:max]
|
|
1000
1184
|
end
|
|
1001
1185
|
end
|
|
1002
1186
|
errors
|
|
@@ -1004,36 +1188,64 @@ module Tina4
|
|
|
1004
1188
|
|
|
1005
1189
|
# load — populate this instance from the database.
|
|
1006
1190
|
#
|
|
1007
|
-
#
|
|
1008
|
-
#
|
|
1009
|
-
#
|
|
1010
|
-
#
|
|
1191
|
+
# Signature aligned with Python's model.load(filter, params, include) and
|
|
1192
|
+
# PHP's load(?string $filter, array $params, ?array $include) --
|
|
1193
|
+
# LOAD-RUBY-SIGNATURE (feature 26, 3.13.99). filter is nil or a SQL
|
|
1194
|
+
# WHERE-fragment String, never a bare scalar.
|
|
1195
|
+
#
|
|
1196
|
+
# user.load # reload by primary key from instance
|
|
1197
|
+
# user.load("email = ?", ["a@b.c"]) # load by filter SQL + params
|
|
1198
|
+
# user.load("id = ?", [1], include: [:posts]) # eager-load relations too
|
|
1199
|
+
#
|
|
1200
|
+
# BREAKING: the old load(id) scalar-primary-key shortcut is REMOVED -- it
|
|
1201
|
+
# built the malformed fragment "WHERE <id>" for any other filter argument
|
|
1202
|
+
# style, which is valid-but-wrong SQL (a constant truthy WHERE matches the
|
|
1203
|
+
# table's first row, not the requested id) rather than a clean error. Set
|
|
1204
|
+
# the primary key attribute then call load with no args, or pass an
|
|
1205
|
+
# explicit "pk = ?" filter.
|
|
1206
|
+
#
|
|
1207
|
+
# LOAD-DEC-01/LOAD-RUBY-ASYMMETRY: hydrates via from_hash -- the SAME
|
|
1208
|
+
# coercion every finder uses (a JSON column parses to a native Hash/Array)
|
|
1209
|
+
# -- instead of feeding the raw driver row straight to the setters. Before
|
|
1210
|
+
# this fix, load() left a JSON column as a raw String while
|
|
1211
|
+
# Model.find(id).same_column returned a parsed Hash/Array: the same row,
|
|
1212
|
+
# a different type, purely by which read path you called. ONE hydration
|
|
1213
|
+
# path now (from_hash), not two.
|
|
1011
1214
|
#
|
|
1012
1215
|
# Returns true on hit, false on miss. Always clears the relationship cache.
|
|
1013
|
-
def load(
|
|
1216
|
+
def load(filter = nil, params = [], include: nil)
|
|
1217
|
+
if !filter.nil? && !filter.is_a?(String)
|
|
1218
|
+
raise ArgumentError,
|
|
1219
|
+
"#{self.class.name}#load expects a filter String or no argument (got #{filter.inspect}). " \
|
|
1220
|
+
"The old load(id) primary-key shortcut was removed (LOAD-RUBY-SIGNATURE, 3.13.99) -- " \
|
|
1221
|
+
"set the primary key attribute then call load with no args, or pass an explicit filter: " \
|
|
1222
|
+
"load(\"id = ?\", [id])."
|
|
1223
|
+
end
|
|
1224
|
+
|
|
1014
1225
|
@relationship_cache = {} # Clear relationship cache on reload
|
|
1015
1226
|
pk = self.class.primary_key_field || :id
|
|
1016
1227
|
|
|
1017
|
-
if
|
|
1018
|
-
#
|
|
1019
|
-
|
|
1020
|
-
result = self.class.db.fetch_one(sql, params || [])
|
|
1021
|
-
else
|
|
1022
|
-
# Primary-key form: user.load OR user.load(123)
|
|
1023
|
-
id = arg || __send__(pk)
|
|
1228
|
+
if filter.nil?
|
|
1229
|
+
# No args — reload by the primary key value already set on this instance
|
|
1230
|
+
id = __send__(pk)
|
|
1024
1231
|
return false unless id
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
)
|
|
1232
|
+
pk_column = self.class.get_db_column(pk)
|
|
1233
|
+
sql = "SELECT * FROM #{self.class.table_name} WHERE #{pk_column} = ?"
|
|
1234
|
+
result = self.class.db.fetch_one(sql, [id])
|
|
1235
|
+
else
|
|
1236
|
+
# Filter-SQL form: user.load("email = ?", ["a@b.c"])
|
|
1237
|
+
sql = "SELECT * FROM #{self.class.table_name} WHERE #{filter} LIMIT 1"
|
|
1238
|
+
result = self.class.db.fetch_one(sql, params)
|
|
1028
1239
|
end
|
|
1029
1240
|
return false unless result
|
|
1030
1241
|
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
setter = "#{attr_name}="
|
|
1035
|
-
__send__(setter, value) if respond_to?(setter)
|
|
1242
|
+
hydrated = self.class.from_hash(result)
|
|
1243
|
+
self.class.field_definitions.each_key do |name|
|
|
1244
|
+
__send__("#{name}=", hydrated.__send__(name))
|
|
1036
1245
|
end
|
|
1246
|
+
|
|
1247
|
+
self.class.eager_load([self], include) if include
|
|
1248
|
+
|
|
1037
1249
|
@persisted = true
|
|
1038
1250
|
true
|
|
1039
1251
|
end
|
|
@@ -1194,15 +1406,6 @@ module Tina4
|
|
|
1194
1406
|
hash
|
|
1195
1407
|
end
|
|
1196
1408
|
|
|
1197
|
-
def validate_fields
|
|
1198
|
-
self.class.field_definitions.each do |name, opts|
|
|
1199
|
-
value = __send__(name)
|
|
1200
|
-
if !opts[:nullable] && value.nil? && !opts[:auto_increment] && !opts[:default]
|
|
1201
|
-
@errors << "#{name} cannot be null"
|
|
1202
|
-
end
|
|
1203
|
-
end
|
|
1204
|
-
end
|
|
1205
|
-
|
|
1206
1409
|
def load_has_one(name)
|
|
1207
1410
|
return @relationship_cache[name] if @relationship_cache.key?(name)
|
|
1208
1411
|
rel = self.class.relationship_definitions[name]
|
|
@@ -1214,9 +1417,12 @@ module Tina4
|
|
|
1214
1417
|
pk_value = __send__(pk)
|
|
1215
1418
|
return nil unless pk_value
|
|
1216
1419
|
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1420
|
+
where = "#{fk} = ?"
|
|
1421
|
+
# REL-SOFTDELETE-TRAVERSAL: exclude a soft-deleted related row.
|
|
1422
|
+
if klass.soft_delete
|
|
1423
|
+
where += " AND (#{klass.soft_delete_field} IS NULL OR #{klass.soft_delete_field} = 0)"
|
|
1424
|
+
end
|
|
1425
|
+
result = klass.db.fetch_one("SELECT * FROM #{klass.table_name} WHERE #{where}", [pk_value])
|
|
1220
1426
|
@relationship_cache[name] = result ? klass.from_hash(result) : nil
|
|
1221
1427
|
end
|
|
1222
1428
|
|
|
@@ -1231,10 +1437,25 @@ module Tina4
|
|
|
1231
1437
|
pk_value = __send__(pk)
|
|
1232
1438
|
return [] unless pk_value
|
|
1233
1439
|
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1440
|
+
where = "#{fk} = ?"
|
|
1441
|
+
# REL-SOFTDELETE-TRAVERSAL: a soft-deleted child must not surface through
|
|
1442
|
+
# parent.children, consistent with the finders' default exclusion.
|
|
1443
|
+
if klass.soft_delete
|
|
1444
|
+
where += " AND (#{klass.soft_delete_field} IS NULL OR #{klass.soft_delete_field} = 0)"
|
|
1445
|
+
end
|
|
1446
|
+
order_col = klass.primary_key_field || :id
|
|
1447
|
+
sql = "SELECT * FROM #{klass.table_name} WHERE #{where} ORDER BY #{order_col}"
|
|
1448
|
+
# REL-EAGER-UNBOUNDED: page through ALL children rather than silently capping
|
|
1449
|
+
# at the default fetch limit (was 100), so the tail is never lost.
|
|
1450
|
+
records = []
|
|
1451
|
+
offset = 0
|
|
1452
|
+
loop do
|
|
1453
|
+
batch = klass.db.fetch(sql, [pk_value], limit: EAGER_PAGE_SIZE, offset: offset).to_a
|
|
1454
|
+
records.concat(batch)
|
|
1455
|
+
break if batch.length < EAGER_PAGE_SIZE
|
|
1456
|
+
offset += EAGER_PAGE_SIZE
|
|
1457
|
+
end
|
|
1458
|
+
@relationship_cache[name] = records.map { |row| klass.from_hash(row) }
|
|
1238
1459
|
end
|
|
1239
1460
|
|
|
1240
1461
|
def load_belongs_to(name)
|