tina4ruby 3.11.35 → 3.12.0

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/env.rb CHANGED
@@ -2,14 +2,85 @@
2
2
  require "digest"
3
3
 
4
4
  module Tina4
5
+ # Legacy env var names that v3.12 has retired. If any of these are set in
6
+ # the environment we refuse to boot — silently ignoring them would cause
7
+ # auth/db/mail to fall back to defaults with no warning. Each maps to its
8
+ # new TINA4_-prefixed canonical name.
9
+ LEGACY_ENV_VARS = {
10
+ "DATABASE_URL" => "TINA4_DATABASE_URL",
11
+ "DATABASE_USERNAME" => "TINA4_DATABASE_USERNAME",
12
+ "DATABASE_PASSWORD" => "TINA4_DATABASE_PASSWORD",
13
+ "DB_URL" => "TINA4_DATABASE_URL",
14
+ "SECRET" => "TINA4_SECRET",
15
+ "API_KEY" => "TINA4_API_KEY",
16
+ "JWT_ALGORITHM" => "TINA4_JWT_ALGORITHM",
17
+ "SMTP_HOST" => "TINA4_MAIL_HOST",
18
+ "SMTP_PORT" => "TINA4_MAIL_PORT",
19
+ "SMTP_USERNAME" => "TINA4_MAIL_USERNAME",
20
+ "SMTP_PASSWORD" => "TINA4_MAIL_PASSWORD",
21
+ "SMTP_FROM" => "TINA4_MAIL_FROM",
22
+ "SMTP_FROM_NAME" => "TINA4_MAIL_FROM_NAME",
23
+ "IMAP_HOST" => "TINA4_MAIL_IMAP_HOST",
24
+ "IMAP_PORT" => "TINA4_MAIL_IMAP_PORT",
25
+ "IMAP_USER" => "TINA4_MAIL_IMAP_USERNAME",
26
+ "IMAP_PASS" => "TINA4_MAIL_IMAP_PASSWORD",
27
+ "HOST_NAME" => "TINA4_HOST_NAME",
28
+ "SWAGGER_TITLE" => "TINA4_SWAGGER_TITLE",
29
+ "SWAGGER_DESCRIPTION" => "TINA4_SWAGGER_DESCRIPTION",
30
+ "SWAGGER_VERSION" => "TINA4_SWAGGER_VERSION",
31
+ "ORM_PLURAL_TABLE_NAMES" => "TINA4_ORM_PLURAL_TABLE_NAMES"
32
+ }.freeze
33
+
34
+ # Raised by check_legacy_env_vars! when the caller opts out of process exit.
35
+ class LegacyEnvError < StandardError; end
36
+
37
+ # Refuse to boot if pre-3.12 un-prefixed env vars are still set.
38
+ #
39
+ # Tina4 v3.12 hard-renamed every framework-specific env var to use the
40
+ # TINA4_ prefix. Booting silently with a legacy DATABASE_URL or SECRET
41
+ # would let auth, DB, or mail fall back to insecure defaults while the
42
+ # user thought their config was being read. Better to die loudly with a
43
+ # list of names to fix.
44
+ #
45
+ # Bypass with TINA4_ALLOW_LEGACY_ENV=true in CI / migration scripts that
46
+ # genuinely need both names set during a transition window.
47
+ def self.check_legacy_env_vars!(io: $stderr, exit_on_error: true)
48
+ bypass = ENV["TINA4_ALLOW_LEGACY_ENV"].to_s.downcase
49
+ return if %w[true 1 yes].include?(bypass)
50
+
51
+ found = LEGACY_ENV_VARS.keys.select { |name| ENV.key?(name) }.sort
52
+ return if found.empty?
53
+
54
+ sep = "─" * 72
55
+ lines = ["", sep,
56
+ "Tina4 v3.12 requires TINA4_ prefix on all framework env vars.",
57
+ "Your environment still has these legacy names:",
58
+ ""]
59
+ found.each do |old|
60
+ new_name = LEGACY_ENV_VARS[old]
61
+ lines << format(" %-28s → %s", old, new_name)
62
+ end
63
+ lines.concat([
64
+ "",
65
+ "Run `tina4 env-migrate` to rewrite your .env automatically,",
66
+ "or rename manually. See https://tina4.com/release/3.12.0",
67
+ "Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
68
+ sep, ""
69
+ ])
70
+ io.puts lines.join("\n")
71
+ raise LegacyEnvError, "Legacy env vars present: #{found.join(', ')}" unless exit_on_error
72
+
73
+ exit(2)
74
+ end
75
+
5
76
  module Env
6
77
  DEFAULT_ENV = {
7
78
  "PROJECT_NAME" => "Tina4 Ruby Project",
8
- "VERSION" => "1.0.0",
79
+ "TINA4_SWAGGER_VERSION" => "1.0.0",
9
80
  "TINA4_LOCALE" => "en",
10
81
  "TINA4_DEBUG" => "true",
11
82
  "TINA4_LOG_LEVEL" => "[TINA4_LOG_ALL]",
12
- "SECRET" => "tina4-secret-change-me"
83
+ "TINA4_SECRET" => "tina4-secret-change-me"
13
84
  }.freeze
14
85
 
15
86
  # Check if a value is truthy for env boolean checks.
@@ -72,7 +143,7 @@ module Tina4
72
143
  def create_default_env(path)
73
144
  api_key = Digest::MD5.hexdigest(Time.now.to_s)
74
145
  content = DEFAULT_ENV.map { |k, v| "#{k}=\"#{v}\"" }.join("\n")
75
- content += "\nAPI_KEY=\"#{api_key}\"\n"
146
+ content += "\nTINA4_API_KEY=\"#{api_key}\"\n"
76
147
  File.write(path, content)
77
148
  end
78
149
 
@@ -21,7 +21,7 @@ module Tina4
21
21
  else
22
22
  base = self.name.split("::").last.downcase
23
23
  # Pluralize by default (add "s") unless ORM_PLURAL_TABLE_NAMES is explicitly disabled
24
- unless ENV.fetch("ORM_PLURAL_TABLE_NAMES", "").match?(/\A(false|0|no)\z/i)
24
+ unless ENV.fetch("TINA4_ORM_PLURAL_TABLE_NAMES", "").match?(/\A(false|0|no)\z/i)
25
25
  base += "s" unless base.end_with?("s")
26
26
  end
27
27
  @table_name || base
data/lib/tina4/frond.rb CHANGED
@@ -548,6 +548,20 @@ module Tina4
548
548
  value = eval_expr(var_name, context)
549
549
  filters.each do |fname, args|
550
550
  next if fname == "raw" || fname == "safe"
551
+
552
+ # Filter + property-access chain: `first.groupSummary` — apply
553
+ # the filter, then traverse the path on the result using a
554
+ # synthetic context so eval_expr's dotted resolution does the
555
+ # work. Parity with tina4-python + tina4-php.
556
+ real_fname, tail_path = split_filter_name_and_path(fname)
557
+ if !tail_path.empty? && @filters[real_fname]
558
+ evaluated_args = args.map { |a| eval_filter_arg(a, context) }
559
+ value = @filters[real_fname].call(value, *evaluated_args)
560
+ value = eval_expr("__frond_filter_tmp.#{tail_path}",
561
+ { "__frond_filter_tmp" => value })
562
+ next
563
+ end
564
+
551
565
  fn = @filters[fname]
552
566
  if fn
553
567
  evaluated_args = args.map { |a| eval_filter_arg(a, context) }
@@ -611,6 +625,19 @@ module Tina4
611
625
  next
612
626
  end
613
627
 
628
+ # Filter + property-access chain: `first.groupSummary` — apply
629
+ # the filter, then traverse the path on the result. Done BEFORE
630
+ # the inline fast-path so cases like `items|first.name` work
631
+ # regardless of whether `first` is an inline filter too.
632
+ real_fname, tail_path = split_filter_name_and_path(fname)
633
+ if !tail_path.empty? && @filters[real_fname]
634
+ evaluated_args = args.map { |a| eval_filter_arg(a, context) }
635
+ value = @filters[real_fname].call(value, *evaluated_args)
636
+ value = eval_expr("__frond_filter_tmp.#{tail_path}",
637
+ { "__frond_filter_tmp" => value })
638
+ next
639
+ end
640
+
614
641
  # Inline common no-arg filters for speed (skip generic dispatch)
615
642
  if args.empty? && INLINE_FILTERS.include?(fname)
616
643
  value = case fname
@@ -756,6 +783,41 @@ module Tina4
756
783
  # Filter chain parser
757
784
  # -----------------------------------------------------------------------
758
785
 
786
+ # Split "first.groupSummary" into ["first", "groupSummary"] so a
787
+ # filter segment followed by property access — `{{ x | first.name }}`
788
+ # — applies the filter then traverses the path on the result.
789
+ # Returns [fname, ""] when no structural dot is present.
790
+ #
791
+ # The split point must sit outside parens/brackets/braces and quotes
792
+ # so filter args like `round(1.5)` or `date("Y.m.d")` don't false-
793
+ # trigger. Parity with tina4-python and tina4-php.
794
+ def split_filter_name_and_path(fname)
795
+ depth = 0
796
+ in_q = nil
797
+ i = 0
798
+ n = fname.length
799
+ while i < n
800
+ ch = fname[i]
801
+ if in_q
802
+ in_q = nil if ch == in_q && (i.zero? || fname[i - 1] != "\\")
803
+ i += 1
804
+ next
805
+ end
806
+ case ch
807
+ when '"', "'"
808
+ in_q = ch
809
+ when "(", "[", "{"
810
+ depth += 1
811
+ when ")", "]", "}"
812
+ depth -= 1
813
+ when "."
814
+ return [fname[0...i], fname[(i + 1)..]] if depth.zero?
815
+ end
816
+ i += 1
817
+ end
818
+ [fname, ""]
819
+ end
820
+
759
821
  def parse_filter_chain(expr)
760
822
  cached = @filter_chain_cache[expr]
761
823
  return cached if cached
data/lib/tina4/mcp.rb CHANGED
@@ -132,7 +132,7 @@ module Tina4
132
132
 
133
133
  # Check if the server is running on localhost.
134
134
  def self.is_localhost?
135
- host = ENV.fetch("HOST_NAME", "localhost:7145").split(":").first
135
+ host = ENV.fetch("TINA4_HOST_NAME", "localhost:7145").split(":").first
136
136
  ["localhost", "127.0.0.1", "0.0.0.0", "::1", ""].include?(host)
137
137
  end
138
138
 
@@ -680,6 +680,196 @@ module Tina4
680
680
  end
681
681
  }, "Seed a table with fake data")
682
682
 
683
+ # ── File patch ────────────────────────────────────
684
+ server.register_tool("file_patch", lambda { |path:, old_string:, new_string:, count: 1|
685
+ p = safe_path.call(path)
686
+ return { "error" => "File not found: #{path}" } unless File.file?(p)
687
+ original = File.read(p, encoding: "utf-8")
688
+ occurrences = original.scan(old_string).size
689
+ return { "error" => "old_string not found in #{path}" } if occurrences.zero?
690
+ if occurrences != count.to_i
691
+ return { "error" => "old_string appears #{occurrences} times, expected #{count}. Expand old_string to make it unique, or set count explicitly." }
692
+ end
693
+ updated = original.sub(old_string, new_string)
694
+ # Ruby String#sub replaces first; if count > 1, do N replacements
695
+ if count.to_i > 1
696
+ updated = original.dup
697
+ count.to_i.times { updated.sub!(old_string, new_string) }
698
+ end
699
+ File.write(p, updated, encoding: "utf-8")
700
+ rel = p.sub("#{project_root}/", "")
701
+ Tina4::Plan.record_action("patched", rel) if defined?(Tina4::Plan)
702
+ { "patched" => rel, "replacements" => count.to_i, "bytes" => updated.bytesize }
703
+ }, "Targeted edit: replace old_string with new_string in a file")
704
+
705
+ # ── Docs tools ────────────────────────────────────
706
+ framework_doc_paths = lambda do
707
+ gem_root = File.expand_path("..", File.dirname(__FILE__))
708
+ candidates = [
709
+ File.join(gem_root, "..", "CLAUDE.md"),
710
+ File.join(gem_root, "..", "AGENTS.md"),
711
+ File.join(gem_root, "..", "CONVENTIONS.md"),
712
+ File.join(gem_root, "..", "README.md"),
713
+ File.join(Dir.pwd, "CLAUDE.md")
714
+ ]
715
+ candidates.map { |p| File.expand_path(p) }.uniq.select { |p| File.file?(p) }
716
+ end
717
+
718
+ server.register_tool("docs_list", lambda {
719
+ framework_doc_paths.call.map { |p| { "name" => File.basename(p), "bytes" => File.size(p) } }
720
+ }, "List framework documentation files")
721
+
722
+ server.register_tool("docs_search", lambda { |query:, limit: 5, context_lines: 4|
723
+ return { "error" => "query must be at least 2 characters" } if query.to_s.length < 2
724
+ needle = query.to_s.downcase
725
+ hits = []
726
+ framework_doc_paths.call.each do |p|
727
+ begin
728
+ lines = File.read(p, encoding: "utf-8", invalid: :replace, undef: :replace).split("\n")
729
+ rescue StandardError
730
+ next
731
+ end
732
+ lines.each_with_index do |line, i|
733
+ next unless line.downcase.include?(needle)
734
+ start_i = [0, i - context_lines.to_i].max
735
+ end_i = [lines.size, i + context_lines.to_i + 1].min
736
+ score = 1
737
+ score += 1 if line.include?(query.to_s)
738
+ score += 2 if line.lstrip.start_with?("#")
739
+ hits << {
740
+ "file" => File.basename(p),
741
+ "line" => i + 1,
742
+ "score" => score,
743
+ "snippet" => lines[start_i...end_i].join("\n")
744
+ }
745
+ end
746
+ end
747
+ hits.sort_by! { |h| -h["score"] }
748
+ hits.first([1, limit.to_i].max)
749
+ }, "Search Tina4 framework docs for a query string")
750
+
751
+ server.register_tool("docs_section", lambda { |file:, heading:|
752
+ match = framework_doc_paths.call.find { |p| File.basename(p) == file }
753
+ return { "error" => "Unknown doc file: #{file}. Try docs_list() first." } unless match
754
+ text = File.read(match, encoding: "utf-8", invalid: :replace, undef: :replace)
755
+ lines = text.split("\n")
756
+ heading_lc = heading.to_s.downcase.strip
757
+ start_i = -1
758
+ start_level = 0
759
+ lines.each_with_index do |line, i|
760
+ stripped = line.lstrip
761
+ next unless stripped.start_with?("#")
762
+ level = stripped.length - stripped.sub(/\A#+/, "").length
763
+ title = stripped[level..].to_s.strip.downcase
764
+ if title.include?(heading_lc)
765
+ start_i = i
766
+ start_level = level
767
+ break
768
+ end
769
+ end
770
+ return { "error" => "Heading '#{heading}' not found in #{file}" } if start_i < 0
771
+ end_i = lines.size
772
+ (start_i + 1).upto(lines.size - 1) do |j|
773
+ stripped = lines[j].lstrip
774
+ next unless stripped.start_with?("#")
775
+ level = stripped.length - stripped.sub(/\A#+/, "").length
776
+ if level <= start_level
777
+ end_i = j
778
+ break
779
+ end
780
+ end
781
+ { "file" => file, "heading" => lines[start_i].strip, "body" => lines[start_i...end_i].join("\n") }
782
+ }, "Return a full markdown section from a framework doc file")
783
+
784
+ # ── Git / deps / project ──────────────────────────
785
+ server.register_tool("git_status", lambda {
786
+ Tina4::DevAdmin.send(:git_status_payload)
787
+ }, "Show git branch, modified/untracked files, recent commits")
788
+
789
+ server.register_tool("deps_list", lambda {
790
+ gemfile = File.join(Dir.pwd, "Gemfile")
791
+ return { "error" => "No Gemfile at project root" } unless File.file?(gemfile)
792
+ deps = File.read(gemfile).scan(/^\s*gem\s+["']([^"']+)["']/).flatten
793
+ { "name" => File.basename(Dir.pwd), "dependencies" => deps }
794
+ }, "List this project's declared Ruby dependencies")
795
+
796
+ server.register_tool("project_overview", lambda {
797
+ { "system" => { "framework" => "tina4-ruby", "version" => (defined?(Tina4::VERSION) ? Tina4::VERSION : "unknown"), "ruby" => RUBY_DESCRIPTION, "cwd" => project_root } }
798
+ }, "One-shot snapshot: system + project info")
799
+
800
+ # ── Project index ─────────────────────────────────
801
+ server.register_tool("index_rebuild", lambda {
802
+ Tina4::ProjectIndex.refresh
803
+ }, "Refresh the persistent project index (lazy, mtime-based)")
804
+
805
+ server.register_tool("index_search", lambda { |query:, limit: 20|
806
+ Tina4::ProjectIndex.search(query, limit.to_i)
807
+ }, "Find files by path, symbol, route, or summary")
808
+
809
+ server.register_tool("index_file", lambda { |path:|
810
+ Tina4::ProjectIndex.file_entry(path)
811
+ }, "Full index entry for one file")
812
+
813
+ server.register_tool("index_overview", lambda {
814
+ Tina4::ProjectIndex.overview
815
+ }, "Project shape: files by language, routes, models, recent edits")
816
+
817
+ # ── Plan management ───────────────────────────────
818
+ server.register_tool("plan_current", lambda {
819
+ Tina4::Plan.current
820
+ }, "The active plan: title, steps (done/not), next step, progress")
821
+
822
+ server.register_tool("plan_list", lambda {
823
+ Tina4::Plan.list_plans
824
+ }, "All plans in plan/ with progress and which one is active")
825
+
826
+ server.register_tool("plan_create", lambda { |title:, goal: "", steps: nil, make_current: true|
827
+ Tina4::Plan.create(title, goal: goal, steps: steps, make_current: make_current)
828
+ }, "Create a new markdown plan in plan/ and make it active")
829
+
830
+ server.register_tool("plan_switch_to", lambda { |name:|
831
+ Tina4::Plan.set_current(name)
832
+ }, "Make a different plan the active one")
833
+
834
+ server.register_tool("plan_complete_step", lambda { |index:|
835
+ Tina4::Plan.complete_step(index.to_i)
836
+ }, "Tick a step as done (call the moment the step finishes)")
837
+
838
+ server.register_tool("plan_add_step", lambda { |text:|
839
+ Tina4::Plan.add_step(text)
840
+ }, "Append a new unchecked step to the current plan")
841
+
842
+ server.register_tool("plan_note", lambda { |text:|
843
+ Tina4::Plan.append_note(text)
844
+ }, "Append a timestamped note/breadcrumb to the current plan")
845
+
846
+ server.register_tool("plan_archive", lambda { |name: ""|
847
+ Tina4::Plan.archive(name)
848
+ }, "Move a finished plan to plan/done/")
849
+
850
+ server.register_tool("plan_read", lambda { |name:|
851
+ Tina4::Plan.read(name)
852
+ }, "Full structured view of any plan by filename")
853
+
854
+ server.register_tool("plan_flesh", lambda { |name: "", prompt: ""|
855
+ Tina4::Plan.flesh(name, prompt)
856
+ }, "Auto-generate concrete build steps via AI and append them to an existing plan")
857
+
858
+ # ── Live API Docs (Live API RAG) ──────────────────
859
+ server.register_tool("api_search", lambda { |query:, k: 5, source: "all", include_private: false|
860
+ Tina4::Docs.cached(project_root).search(
861
+ query.to_s, k: k.to_i, source: source.to_s, include_private: include_private == true || include_private.to_s == "true"
862
+ )
863
+ }, "Search the live API index (framework + user code) for ranked hits")
864
+
865
+ server.register_tool("api_class", lambda { |name:|
866
+ Tina4::Docs.cached(project_root).class_spec(name.to_s)
867
+ }, "Full class reflection (methods, file, line) from the live API index")
868
+
869
+ server.register_tool("api_method", lambda { |class_name:, name:|
870
+ Tina4::Docs.cached(project_root).method_spec(class_name.to_s, name.to_s)
871
+ }, "Single method spec (signature, summary, file, line) from the live API index")
872
+
683
873
  # ── System Tools ──────────────────────────────────
684
874
  server.register_tool("system_info", lambda {
685
875
  {
@@ -18,7 +18,7 @@ module Tina4
18
18
  # Tina4 Messenger — Email sending (SMTP) and reading (IMAP).
19
19
  #
20
20
  # Unified .env-driven configuration with constructor override.
21
- # Priority: constructor params > .env (TINA4_MAIL_* with SMTP_* fallback) > sensible defaults
21
+ # Priority: constructor params > .env (TINA4_MAIL_*) > sensible defaults
22
22
  #
23
23
  # # .env
24
24
  # TINA4_MAIL_HOST=smtp.gmail.com
@@ -39,19 +39,19 @@ module Tina4
39
39
  :imap_host, :imap_port, :use_tls, :encryption
40
40
 
41
41
  # Initialize with SMTP config.
42
- # Priority: constructor params > ENV (TINA4_MAIL_* with SMTP_* fallback) > sensible defaults
42
+ # Priority: constructor params > ENV (TINA4_MAIL_*) > sensible defaults
43
43
  def initialize(host: nil, port: nil, username: nil, password: nil,
44
44
  from_address: nil, from_name: nil, encryption: nil, use_tls: nil,
45
45
  imap_host: nil, imap_port: nil)
46
- @host = host || ENV["TINA4_MAIL_HOST"] || ENV["SMTP_HOST"] || "localhost"
47
- @port = (port || ENV["TINA4_MAIL_PORT"] || ENV["SMTP_PORT"] || 587).to_i
48
- @username = username || ENV["TINA4_MAIL_USERNAME"] || ENV["SMTP_USERNAME"]
49
- @password = password || ENV["TINA4_MAIL_PASSWORD"] || ENV["SMTP_PASSWORD"]
46
+ @host = host || ENV["TINA4_MAIL_HOST"] || "localhost"
47
+ @port = (port || ENV["TINA4_MAIL_PORT"] || 587).to_i
48
+ @username = username || ENV["TINA4_MAIL_USERNAME"]
49
+ @password = password || ENV["TINA4_MAIL_PASSWORD"]
50
50
 
51
- resolved_from = from_address || ENV["TINA4_MAIL_FROM"] || ENV["SMTP_FROM"]
51
+ resolved_from = from_address || ENV["TINA4_MAIL_FROM"]
52
52
  @from_address = resolved_from || @username || "noreply@localhost"
53
53
 
54
- @from_name = from_name || ENV["TINA4_MAIL_FROM_NAME"] || ENV["SMTP_FROM_NAME"] || ""
54
+ @from_name = from_name || ENV["TINA4_MAIL_FROM_NAME"] || ""
55
55
 
56
56
  # Encryption: constructor > .env > backward-compat use_tls > default "tls"
57
57
  env_encryption = encryption || ENV["TINA4_MAIL_ENCRYPTION"]
@@ -64,8 +64,8 @@ module Tina4
64
64
  end
65
65
  @use_tls = %w[tls starttls].include?(@encryption)
66
66
 
67
- @imap_host = imap_host || ENV["TINA4_MAIL_IMAP_HOST"] || ENV["IMAP_HOST"] || @host
68
- @imap_port = (imap_port || ENV["TINA4_MAIL_IMAP_PORT"] || ENV["IMAP_PORT"] || 993).to_i
67
+ @imap_host = imap_host || ENV["TINA4_MAIL_IMAP_HOST"] || @host
68
+ @imap_port = (imap_port || ENV["TINA4_MAIL_IMAP_PORT"] || 993).to_i
69
69
  end
70
70
 
71
71
  # Send email using Ruby's Net::SMTP
@@ -542,8 +542,7 @@ module Tina4
542
542
  def self.create_messenger(**options)
543
543
  dev_mode = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
544
544
 
545
- smtp_configured = (ENV["TINA4_MAIL_HOST"] && !ENV["TINA4_MAIL_HOST"].empty?) ||
546
- (ENV["SMTP_HOST"] && !ENV["SMTP_HOST"].empty?)
545
+ smtp_configured = ENV["TINA4_MAIL_HOST"] && !ENV["TINA4_MAIL_HOST"].empty?
547
546
 
548
547
  if dev_mode && !smtp_configured
549
548
  mailbox_dir = options.delete(:mailbox_dir) || ENV["TINA4_MAILBOX_DIR"]
@@ -560,8 +559,8 @@ module Tina4
560
559
 
561
560
  def initialize(mailbox, **options)
562
561
  @mailbox = mailbox
563
- @from_address = options[:from_address] || ENV["TINA4_MAIL_FROM"] || ENV["SMTP_FROM"] || "dev@localhost"
564
- @from_name = options[:from_name] || ENV["TINA4_MAIL_FROM_NAME"] || ENV["SMTP_FROM_NAME"] || "Dev Mailer"
562
+ @from_address = options[:from_address] || ENV["TINA4_MAIL_FROM"] || "dev@localhost"
563
+ @from_name = options[:from_name] || ENV["TINA4_MAIL_FROM_NAME"] || "Dev Mailer"
565
564
  end
566
565
 
567
566
  def send(to:, subject:, body:, html: false, cc: [], bcc: [],
data/lib/tina4/orm.rb CHANGED
@@ -52,15 +52,31 @@ module Tina4
52
52
  @field_mapping = map
53
53
  end
54
54
 
55
- # Auto-map flag (no-op in Ruby since snake_case is native)
55
+ # Auto-map flag defaults to TRUE for cross-framework parity (Python's
56
+ # ORM has auto_map=True by default). The instance variable is treated
57
+ # as "unset" when nil; only an explicit `false` disables it.
56
58
  def auto_map
57
- @auto_map || false
59
+ defined?(@auto_map) && !@auto_map.nil? ? @auto_map : true
58
60
  end
59
61
 
60
62
  def auto_map=(val)
61
63
  @auto_map = val
62
64
  end
63
65
 
66
+ # auto_crud flag — when set to true, the class registers itself with
67
+ # Tina4::AutoCrud which auto-generates REST endpoints from the model.
68
+ # Defaults to false. Cross-framework parity with Python's autoCrud.
69
+ def auto_crud
70
+ defined?(@auto_crud) && !@auto_crud.nil? ? @auto_crud : false
71
+ end
72
+
73
+ def auto_crud=(val)
74
+ @auto_crud = val
75
+ if val && defined?(::Tina4::AutoCrud)
76
+ ::Tina4::AutoCrud.models << self unless ::Tina4::AutoCrud.models.include?(self)
77
+ end
78
+ end
79
+
64
80
  # Relationship definitions
65
81
  def relationship_definitions
66
82
  @relationship_definitions ||= {}
@@ -343,8 +359,10 @@ module Tina4
343
359
  instance
344
360
  end
345
361
 
346
- private
347
-
362
+ # find_by_id is PUBLIC — cross-framework parity with Python's
363
+ # MyModel.find_by_id(pk_value) and PHP's User::find($id). Spec at
364
+ # spec/orm_spec.rb:78 verifies public access. find_by_filter stays
365
+ # public for the same reason; both are part of the documented API.
348
366
  def find_by_id(id)
349
367
  pk = primary_key_field || :id
350
368
  sql = "SELECT * FROM #{table_name} WHERE #{pk} = ?"
@@ -354,6 +372,34 @@ module Tina4
354
372
  select_one(sql, [id])
355
373
  end
356
374
 
375
+ # Clear the relationship cache on all loaded instances (class-level helper).
376
+ # Useful after bulk operations when you want to force relationship re-loads.
377
+ def clear_rel_cache # -> nil
378
+ @_rel_cache = {}
379
+ nil
380
+ end
381
+
382
+ # Return the database connection used by this model.
383
+ def get_db # -> Database
384
+ db
385
+ end
386
+
387
+ # Map a Ruby property name to its database column name using field_mapping.
388
+ # Returns the column name as a symbol.
389
+ def get_db_column(property) # -> Symbol
390
+ col = field_mapping[property.to_s] || property
391
+ col.to_sym
392
+ end
393
+
394
+ private
395
+
396
+ def auto_discover_db
397
+ url = ENV["TINA4_DATABASE_URL"]
398
+ return nil unless url
399
+ Tina4.database = Tina4::Database.new(url, username: ENV.fetch("TINA4_DATABASE_USERNAME", ""), password: ENV.fetch("TINA4_DATABASE_PASSWORD", ""))
400
+ Tina4.database
401
+ end
402
+
357
403
  def find_by_filter(filter)
358
404
  where_parts = filter.keys.map { |k| "#{k} = ?" }
359
405
  sql = "SELECT * FROM #{table_name} WHERE #{where_parts.join(' AND ')}"
@@ -474,15 +520,30 @@ module Tina4
474
520
  errors
475
521
  end
476
522
 
477
- def load(id = nil)
478
- pk = self.class.primary_key_field || :id
479
- id ||= __send__(pk)
480
- return false unless id
523
+ # load populate this instance from the database.
524
+ #
525
+ # Three forms (parity with Python's model.load(sql, params, include)):
526
+ # user.load # reload by primary key from instance
527
+ # user.load(123) # load by primary key value
528
+ # user.load("email = ?", ["a@b.c"]) # load by filter SQL + params (selectOne)
529
+ #
530
+ # Returns true on hit, false on miss. Always clears the relationship cache.
531
+ def load(arg = nil, params = nil)
481
532
  @relationship_cache = {} # Clear relationship cache on reload
533
+ pk = self.class.primary_key_field || :id
482
534
 
483
- result = self.class.db.fetch_one(
484
- "SELECT * FROM #{self.class.table_name} WHERE #{pk} = ?", [id]
485
- )
535
+ if arg.is_a?(String)
536
+ # Filter-SQL form: user.load("email = ?", ["a@b.c"])
537
+ sql = "SELECT * FROM #{self.class.table_name} WHERE #{arg} LIMIT 1"
538
+ result = self.class.db.fetch_one(sql, params || [])
539
+ else
540
+ # Primary-key form: user.load OR user.load(123)
541
+ id = arg || __send__(pk)
542
+ return false unless id
543
+ result = self.class.db.fetch_one(
544
+ "SELECT * FROM #{self.class.table_name} WHERE #{pk} = ?", [id]
545
+ )
546
+ end
486
547
  return false unless result
487
548
 
488
549
  mapping_reverse = self.class.field_mapping.invert
@@ -505,7 +566,10 @@ module Tina4
505
566
 
506
567
  # Convert to hash using Ruby attribute names.
507
568
  # Optionally include relationships via the include keyword.
508
- def to_h(include: nil)
569
+ # case: "camel" converts snake_case keys to camelCase (parity with
570
+ # Python's to_dict(case='camel')). Default keeps native snake_case.
571
+ def to_h(include: nil, case: nil)
572
+ key_case = binding.local_variable_get(:case) # :case is a reserved word
509
573
  hash = {}
510
574
  self.class.field_definitions.each_key do |name|
511
575
  hash[name] = __send__(name)
@@ -534,6 +598,15 @@ module Tina4
534
598
  end
535
599
  end
536
600
 
601
+ if key_case == "camel" || key_case == :camel
602
+ # snake_case → camelCase: split on _, capitalize all but the first
603
+ hash = hash.each_with_object({}) do |(k, v), out|
604
+ parts = k.to_s.split("_")
605
+ camel = parts[0] + parts[1..].map(&:capitalize).join
606
+ out[camel.to_sym] = v
607
+ end
608
+ end
609
+
537
610
  hash
538
611
  end
539
612