diff --git a/docs/pipeline/runtime.md b/docs/pipeline/runtime.md index 67643ca77..615a4b778 100644 --- a/docs/pipeline/runtime.md +++ b/docs/pipeline/runtime.md @@ -207,6 +207,23 @@ undocumented one reads as intent to the next session precisely because it is applied consistently, and the emit gives no signal that anyone weighed it. +### Spinel `Date` is a bounded runtime value + +The Spinel target defines a small `Date` class in +`runtime/spinel/date.rb` for Rails date columns. It stores a Gregorian +year, month, and day; database storage and JSON use `YYYY-MM-DD`, with +no clock or zone. Its ISO parser accepts only that exact format and +validates the calendar date. + +This is not Ruby's stdlib `date` package. `DateTime`, Julian/Italian +calendar modes, natural-language and non-ISO parsing, schema date +defaults, ActiveSupport date extensions, date picker helpers, and date +predicates are not included. `strftime` implements the date directives +used by the admitted runtime contract and raises on other directives. +The compiler continues diagnosing those unsupported paths. JRuby and +other targets keep their existing Date boundary until they have their +own runtime. + ### `id` is `0` before save, not `nil` (`""` for a string key) Each model's own `initialize` seeds `self.id = attrs[:id] || 0` — `|| diff --git a/runtime/ruby/active_record/base.rb b/runtime/ruby/active_record/base.rb index b12096733..4a5067ae7 100644 --- a/runtime/ruby/active_record/base.rb +++ b/runtime/ruby/active_record/base.rb @@ -307,6 +307,10 @@ def self.schema_time_columns [] end + def self.schema_date_columns + [] + end + def self.instantiate(_row) raise NotImplementedError, "#{name}.instantiate must be overridden" end diff --git a/runtime/ruby/active_record/base.rbs b/runtime/ruby/active_record/base.rbs index f45821cca..b5a0b1008 100644 --- a/runtime/ruby/active_record/base.rbs +++ b/runtime/ruby/active_record/base.rbs @@ -115,6 +115,7 @@ module ActiveRecord def self._string_primary_key: () -> bool def self.schema_columns: () -> Array[Symbol] def self.schema_time_columns: () -> Array[Symbol] + def self.schema_date_columns: () -> Array[Symbol] def self.instantiate: (Hash[String, untyped] _row) -> Base # Eager-load hook consumed by `Relation#to_a`; the Ruby emit path diff --git a/runtime/ruby/active_record/connection.rb b/runtime/ruby/active_record/connection.rb index f14104786..6de012309 100644 --- a/runtime/ruby/active_record/connection.rb +++ b/runtime/ruby/active_record/connection.rb @@ -311,9 +311,12 @@ def _as_json_only(only) h = {} columns = self.class.schema_columns time_columns = self.class.schema_time_columns + date_columns = self.class.schema_date_columns only.each do |k| next unless columns.include?(k) - h[k.to_s] = if time_columns.include?(k) + h[k.to_s] = if date_columns.include?(k) + ActiveSupport.format_db_date(self[k]) + elsif time_columns.include?(k) ActiveSupport.json_time(self[k]) else self[k] diff --git a/runtime/spinel/active_record_date_serialization.rb b/runtime/spinel/active_record_date_serialization.rb new file mode 100644 index 000000000..c29abaab7 --- /dev/null +++ b/runtime/spinel/active_record_date_serialization.rb @@ -0,0 +1,12 @@ +# Default ActiveRecord JSON shape for the Spinel runtime. This is kept +# beside the bounded Date rather than in the shared runtime because the +# Ruby-family overlay supplies its own reflection-aware implementation. +module ActiveRecord + class Base + def as_json(options = {}) + only = options && options[:only] + only ||= self.class.schema_columns + _as_json_only(only) + end + end +end diff --git a/runtime/spinel/active_support_time_parsing.rb b/runtime/spinel/active_support_time_parsing.rb index 5137bd4da..5a20f10bf 100644 --- a/runtime/spinel/active_support_time_parsing.rb +++ b/runtime/spinel/active_support_time_parsing.rb @@ -20,6 +20,20 @@ # corpus are second-granularity, and JSON serializes from the raw # string (`_raw`), not the parsed Time. module ActiveSupport + # SQL DATE has no clock or zone. The empty string is the SQLite + # adapter's nil representation for a nullable column. + def self.parse_db_date(value) + return nil if value.nil? || value == "" + Date.iso8601(value) + end + + def self.format_db_date(value) + return nil if value.nil? + return value.iso8601 if value.is_a?(Date) + return Date.iso8601(value).iso8601 if value.is_a?(String) + raise TypeError, "expected Date, String, or nil" + end + # Rails zone name → IANA identifier. TWIN of the constant in the # CRuby/JRuby overlay's sibling file, which shadows this whole file on # those trees — the two must agree, so extend both together. Names not diff --git a/runtime/spinel/active_support_time_parsing.rbs b/runtime/spinel/active_support_time_parsing.rbs index ae6790f0f..a63df0215 100644 --- a/runtime/spinel/active_support_time_parsing.rbs +++ b/runtime/spinel/active_support_time_parsing.rbs @@ -2,6 +2,8 @@ # raw TEXT column value the synthesized reader passes — nil/"" for # absent storage (the nil-guard narrows before the positional parse). module ActiveSupport + def self.parse_db_date: (String? value) -> Date? + def self.format_db_date: (untyped value) -> String? def self.parse_db_time: (String? str) -> Time? def self.json_time: (String? str) -> String? def self.db_now: () -> String diff --git a/runtime/spinel/date.rb b/runtime/spinel/date.rb new file mode 100644 index 000000000..d8d4ba511 --- /dev/null +++ b/runtime/spinel/date.rb @@ -0,0 +1,237 @@ +# A deliberately small, program-defined Date for the Spinel target. +# This is not Ruby's stdlib `date` package. The supported API is the +# surface analyzed for Rails date columns; unsupported formats fail +# loudly instead of being interpreted as timestamps or guessed dates. +class Date + class Error < ArgumentError + end + + MONTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + SHORT_WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] + SHORT_MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + + def initialize(year, month, day) + unless year >= 1 && year <= 9999 && month >= 1 && month <= 12 && day >= 1 && day <= Date.month_length(year, month) + raise Date::Error, "invalid date" + end + @year = year + @month = month + @day = day + end + + def self.civil(year, month = 1, day = 1) + Date.new(year, month, day) + end + + def self.leap_year?(year) + year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) + end + + def self.month_length(year, month) + return 29 if month == 2 && Date.leap_year?(year) + MONTHS[month - 1] + end + + def self.iso8601(value) + unless value.is_a?(String) && value.length == 10 && value[4, 1] == "-" && value[7, 1] == "-" + raise Date::Error, "invalid date: #{value}" + end + year = value[0, 4].to_i + month = value[5, 2].to_i + day = value[8, 2].to_i + date = Date.new(year, month, day) + raise Date::Error, "invalid date: #{value}" unless date.iso8601 == value + date + rescue ArgumentError + raise Date::Error, "invalid date: #{value}" + end + + def self.parse(value, _comp = true) + Date.iso8601(value) + end + + def self.strptime(value, format) + raise Date::Error, "unsupported date format" unless format == "%Y-%m-%d" + Date.iso8601(value) + end + + def self.today + now = Time.now + Date.new(now.year, now.month, now.day) + end + + def year + @year + end + + def month + @month + end + + def mon + @month + end + + def day + @day + end + + def mday + @day + end + + def leap? + Date.leap_year?(@year) + end + + def wday + offsets = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4] + year = @year + year -= 1 if @month < 3 + (year + year / 4 - year / 100 + year / 400 + offsets[@month - 1] + @day) % 7 + end + + def yday + total = @day + month = 1 + while month < @month + total += Date.month_length(@year, month) + month += 1 + end + total + end + + def sunday? + wday == 0 + end + + def monday? + wday == 1 + end + + def tuesday? + wday == 2 + end + + def wednesday? + wday == 3 + end + + def thursday? + wday == 4 + end + + def friday? + wday == 5 + end + + def saturday? + wday == 6 + end + + def >>(months) + shift_months(months) + end + + def <<(months) + shift_months(-months) + end + + def shift_months(amount) + index = @year * 12 + @month - 1 + amount + year = index / 12 + month = index % 12 + 1 + year -= 1 if month == 0 + month = 12 if month == 0 + day = @day + last = Date.month_length(year, month) + day = last if day > last + Date.new(year, month, day) + end + + def to_date + self + end + + def to_time + Time.local(@year, @month, @day, 0, 0, 0) + end + + def iso8601 + format("%04d-%02d-%02d", @year, @month, @day) + end + + def to_s + iso8601 + end + + def as_json(_options = {}) + iso8601 + end + + def strftime(pattern) + result = "" + index = 0 + while index < pattern.length + char = pattern[index, 1] + if char == "%" + index += 1 + code = pattern[index, 1] + value = case code + when "%" then "%" + when "Y" then format("%04d", @year) + when "m" then format("%02d", @month) + when "d" then format("%02d", @day) + when "e" then format("%2d", @day) + when "F" then iso8601 + when "j" then format("%03d", yday) + when "w" then wday.to_s + when "u" then (wday == 0 ? 7 : wday).to_s + when "a" then SHORT_WEEKDAYS[wday] + when "A" then WEEKDAYS[wday] + when "b", "h" then SHORT_MONTH_NAMES[@month - 1] + when "B" then MONTH_NAMES[@month - 1] + else raise Date::Error, "unsupported strftime directive: %#{code}" + end + result += value + else + result += char + end + index += 1 + end + result + end + + def <=>(other) + return nil unless other.is_a?(Date) + return -1 if @year < other.year + return 1 if @year > other.year + return -1 if @month < other.month + return 1 if @month > other.month + return -1 if @day < other.day + return 1 if @day > other.day + 0 + end + + def ==(other) + (self <=> other) == 0 + end + + def <(other) + (self <=> other) == -1 + end + + def <=(other) + (self <=> other) <= 0 + end + + def >(other) + (self <=> other) == 1 + end + + def >=(other) + (self <=> other) >= 0 + end +end diff --git a/runtime/spinel/date.rbs b/runtime/spinel/date.rbs new file mode 100644 index 000000000..6185d29a3 --- /dev/null +++ b/runtime/spinel/date.rbs @@ -0,0 +1,51 @@ +# Bounded Date value used by the Spinel runtime for Rails date columns. +class Date + class Error < ArgumentError + end + + def initialize: (Integer year, Integer month, Integer day) -> void + def self.civil: (Integer year, ?Integer month, ?Integer day) -> Date + def self.iso8601: (String value) -> Date + def self.parse: (String value, ?bool comp) -> Date + def self.strptime: (String value, String format) -> Date + def self.today: () -> Date + def self.leap_year?: (Integer year) -> bool + def self.month_length: (Integer year, Integer month) -> Integer + + def year: () -> Integer + def month: () -> Integer + def mon: () -> Integer + def day: () -> Integer + def mday: () -> Integer + def leap?: () -> bool + def wday: () -> Integer + def yday: () -> Integer + def sunday?: () -> bool + def monday?: () -> bool + def tuesday?: () -> bool + def wednesday?: () -> bool + def thursday?: () -> bool + def friday?: () -> bool + def saturday?: () -> bool + def >>: (Integer months) -> Date + def <<: (Integer months) -> Date + def shift_months: (Integer amount) -> Date + def to_date: () -> Date + def to_time: () -> Time + def iso8601: () -> String + def to_s: () -> String + def as_json: (?Hash[untyped, untyped] options) -> String + def strftime: (String pattern) -> String + def <=>: (Date? other) -> Integer? + def ==: (Date? other) -> bool + def <: (Date? other) -> bool + def <=: (Date? other) -> bool + def >: (Date? other) -> bool + def >=: (Date? other) -> bool +end + +module ActiveRecord + class Base + def as_json: (?Hash[Symbol, untyped] options) -> Hash[String, untyped] + end +end diff --git a/runtime/spinel/scaffold/boot.rb b/runtime/spinel/scaffold/boot.rb index c20a6fdcf..49f9796a1 100644 --- a/runtime/spinel/scaffold/boot.rb +++ b/runtime/spinel/scaffold/boot.rb @@ -20,6 +20,9 @@ # Required before active_record so Base.rb's default `_adapter_*` # helpers and per-model overrides find `Db` at constant-resolution # time. See project_arel_compile_time_first.md. +# Program-defined Date is not the unavailable Spinel stdlib package. +# Load it before Db requires the ActiveSupport temporal seam. +require_relative "runtime/date" require_relative "runtime/db" # Base64 + JSON + Importmap shims. All required before any framework # Ruby file that references them so spinel-AOT's static resolver @@ -97,6 +100,9 @@ Rails.application.config_time_zone, Rails.application.config_time_zone ) require_relative "runtime/active_record" +# Date-aware default ActiveRecord JSON path — the CRuby/JRuby overlay +# has its own reflection-aware serializer, while Spinel uses schema facts. +require_relative "runtime/active_record_date_serialization" # Record equality (same class + same persisted id) — a reopen of # ActiveRecord::Base; the CRuby overlay's twin is active_record_bang.rb. require_relative "runtime/active_record_equality_spinel" diff --git a/scripts/ci-plan.py b/scripts/ci-plan.py index 18efa6c95..222c3943a 100755 --- a/scripts/ci-plan.py +++ b/scripts/ci-plan.py @@ -38,6 +38,7 @@ PUBLICATION = [*BASE, "compare", "browser-smoke-typescript"] CORE = ["build-spinel", "toolchain-spinel", "compare-spinel"] SPINEL_TESTS = [ + "date_columns_spinel", "framework_tests_spinel", "spinel_web_push_crypto", "spinel_db_lease", @@ -121,6 +122,8 @@ def native_coverage(path): owned_tests.update(("spinel_db_lease", "param_binds")) if any(word in name for word in ("param", "multipart", "request")): owned_tests.add("spinel_param_builder") + if name in {"date.rb", "date.rbs"}: + owned_tests.add("date_columns_spinel") if ( path.startswith("runtime/spinel/") and not path.startswith("runtime/spinel/scaffold/") diff --git a/src/lower/model_to_library/schema.rs b/src/lower/model_to_library/schema.rs index 9ecb204c6..7412ca77f 100644 --- a/src/lower/model_to_library/schema.rs +++ b/src/lower/model_to_library/schema.rs @@ -367,6 +367,43 @@ pub(super) fn push_schema_methods( block_param: None, }); + // def self.schema_date_columns — date-only columns need their own + // JSON conversion: their stored value is ISO calendar text, never a + // timestamp. Kept separate from schema_time_columns so the runtime + // can preserve the two domains without inspecting values. + let date_column_array = with_ty( + Expr::new( + Span::synthetic(), + ExprNode::Array { + elements: table + .columns + .iter() + .filter(|c| matches!(c.col_type, crate::schema::ColumnType::Date)) + .map(|c| lit_sym(c.name.clone())) + .collect(), + style: ArrayStyle::Brackets, + }, + ), + Ty::Array { elem: Box::new(Ty::Sym) }, + ); + methods.push(MethodDef { + visibility: crate::dialect::MethodVisibility::Public, + unsupported_formals: None, + has_anonymous_block: false, + name_span: crate::span::Span::synthetic(), + name: Symbol::from("schema_date_columns"), + receiver: MethodReceiver::Class, + params: Vec::new(), + body: date_column_array, + signature: Some(fn_sig(vec![], Ty::Array { elem: Box::new(Ty::Sym) })), + effects: EffectSet::default(), + enclosing_class: Some(owner.0.clone()), + kind: AccessorKind::Method, + is_async: false, + mutates_self: false, + block_param: None, + }); + // def self.instantiate(row); instance = from_row(Row.from_raw(row)); instance.mark_persisted!; instance; end // // The adapter shim returns Hash[Symbol, untyped]; the framework Ruby diff --git a/src/project.rs b/src/project.rs index d41952a2a..ab10aedee 100644 --- a/src/project.rs +++ b/src/project.rs @@ -726,7 +726,7 @@ fn report_unsupported_keys(app: &App, target: BuildTarget) { /// dynamic backends may never render a type, so a type-position check /// alone would silently emit a String/Time or call an absent intrinsic. fn reject_unsupported_dates(app: &App, target: BuildTarget) -> Result<(), String> { - if matches!(target, BuildTarget::Blog | BuildTarget::Ruby) { + if matches!(target, BuildTarget::Blog | BuildTarget::Ruby | BuildTarget::Spinel) { return Ok(()); } fn expr_has_date(e: &crate::expr::Expr) -> bool { @@ -3766,6 +3766,14 @@ fn spinel_files(app: &App, fixture: &Path) -> Result<(Vec<(String, String)>, Vec )); } + // Program-defined Date for the Spinel target; the flat walk picks + // up its implementation and this sidecar carries its typed surface. + { + let rbs = crate::runtime_files::read_to_string("runtime/spinel/date.rbs") + .map_err(|e| format!("read runtime/spinel/date.rbs: {e}"))?; + files.push(("sig/runtime/date.rbs".to_string(), rbs)); + } + // Schema-less json/jsonb column seam. The flat walk emits the Ruby // implementation; this sidecar preserves its gradual logical value // while keeping the database slot String-typed. diff --git a/tests/cli_allow_unsupported_hint.rs b/tests/cli_allow_unsupported_hint.rs index 5fbc65468..a2c94d647 100644 --- a/tests/cli_allow_unsupported_hint.rs +++ b/tests/cli_allow_unsupported_hint.rs @@ -1,8 +1,7 @@ //! The transpile gate's "rerun with --allow-unsupported" hint at the //! process boundary (#303). The flag downgrades diagnostics, but a -//! project-boundary refusal (`target_files` returning `Err`, as the -//! Date gate does for spinel) fails with or without it, so the hint -//! must only be printed when the flag would actually write the output. +//! project-boundary refusals fail with or without it, so the hint must +//! only be printed when the flag would actually write the output. use std::path::{Path, PathBuf}; use std::process::Command; @@ -62,19 +61,14 @@ fn transpile(target: &str, input: &Path, allow_unsupported: bool) -> (i32, Strin } #[test] -fn a_date_boundary_refusal_does_not_suggest_allow_unsupported() { +fn spinel_date_columns_pass_the_old_boundary_refusal() { let root = app("date", "", " t.date \"published_on\"\n"); let (code, err) = transpile("spinel", &root, false); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("Date not supported (spinel)"), "{err}"); - assert!(err.contains("Date-only values are not supported"), "{err}"); - assert!(!err.contains(HINT), "the flag cannot override this refusal: {err}"); - - // The reason the hint is wrong: the flag still fails the run. - let (code, err) = transpile("spinel", &root, true); - assert_eq!(code, 1, "{err}"); - assert!(!root.join("out").exists(), "{err}"); + assert_eq!(code, 0, "{err}"); + assert!(!err.contains("Date not supported (spinel)"), "{err}"); + assert!(!err.contains(HINT), "{err}"); + assert!(root.join("out").is_dir(), "{err}"); std::fs::remove_dir_all(root).expect("remove temporary app"); } diff --git a/tests/date_columns.rs b/tests/date_columns.rs index e5666363e..534bafb29 100644 --- a/tests/date_columns.rs +++ b/tests/date_columns.rs @@ -106,7 +106,6 @@ fn typo_and_cross_domain_operations_remain_errors() { } const UNSUPPORTED: &[BuildTarget] = &[ - BuildTarget::Spinel, BuildTarget::Jruby, BuildTarget::Roda, BuildTarget::Crystal, @@ -160,9 +159,37 @@ fn date_target_boundary_rejects_before_reading_or_emitting_files() { assert!(model.1.contains("ActiveSupport.parse_db_date(@due_on_raw)")); assert!(model.1.contains("ActiveSupport.format_db_date")); assert!(!model.1.contains("present_db(@__t_due_on")); + assert!(model.1.contains("schema_date_columns")); } } +#[test] +fn spinel_emits_date_runtime_and_keeps_date_as_a_date() { + let mut app = app_with(SCHEMA, include_str!("date_columns_model.rb")); + assert!(errors(&mut app).is_empty()); + let (result, diagnostics) = + scope(|| target_files(&app, roundhouse::fixtures::real_blog(), BuildTarget::Spinel)); + assert!(result.is_ok(), "{diagnostics:?}"); + let files = result.unwrap(); + let runtime = files.iter().find(|(path, _)| path == "runtime/date.rb").unwrap(); + assert!(runtime.1.contains("class Date")); + assert!( + files.iter().any(|(path, _)| path == "runtime/date.rbs"), + "signature files: {:?}", + files.iter().map(|(path, _)| path).filter(|path| path.ends_with(".rbs")).collect::>() + ); + let model = files + .iter() + .find(|(path, _)| path == "app/models/calendar_entry.rb") + .unwrap(); + assert!(model.1.contains("ActiveSupport.parse_db_date(@due_on_raw)")); + assert!(model.1.contains("ActiveSupport.format_db_date")); + assert!(!diagnostics.iter().any(|d| matches!( + &d.kind, + DiagnosticKind::Unsupported { construct, .. } if construct.as_str() == "Date" + ))); +} + #[test] fn non_column_dates_are_also_rejected_on_unimplemented_targets() { let mut app = app_with( diff --git a/tests/date_columns_spinel.rs b/tests/date_columns_spinel.rs new file mode 100644 index 000000000..dce050bb4 --- /dev/null +++ b/tests/date_columns_spinel.rs @@ -0,0 +1,73 @@ +#[path = "support/emit_and_run.rs"] +mod emit_and_run; + +#[test] +#[ignore = "requires the Spinel toolchain"] +fn spinel_gate_date_column_crud_and_month_shifts_run() { + let overlay = emit_and_run::real_blog() + .edit( + "db/schema.rb", + " create_table \"articles\"", + " create_table \"calendar_entries\" do |t|\n t.date \"due_on\"\n end\n\n create_table \"articles\"", + ) + .write("app/models/calendar_entry.rb", include_str!("date_columns_model.rb")); + let run = overlay.run_spinel(r#" +Db.configure(":memory:") +Schema.statements.each { |sql| Db.exec(sql) } +ActiveRecord.adapter = SqliteAdapter + +entry = CalendarEntry.create!(due_on: Date.new(2024, 1, 31)) +entry.reload +raise entry.due_on.inspect unless entry.due_on.iso8601 == "2024-01-31" +raise entry.due_on_raw.inspect unless entry.due_on_raw == "2024-01-31" +raise entry.shifted(1).iso8601 unless entry.shifted(1).iso8601 == "2024-02-29" +raise entry.shifted(2).iso8601 unless entry.shifted(2).iso8601 == "2024-03-31" +raise entry.shifted(-1).iso8601 unless entry.shifted(-1).iso8601 == "2023-12-31" +raise entry.due_on.iso8601 unless entry.due_on.iso8601 == "2024-01-31" +puts "before selected date JSON" +raise entry.as_json(only: [:due_on]).inspect unless entry.as_json(only: [:due_on]) == {"due_on" => "2024-01-31"} +puts "before default date JSON" +raise entry.as_json.inspect unless entry.as_json["due_on"] == "2024-01-31" +raise Date.new(2024, 1, 31).as_json.inspect unless Date.new(2024, 1, 31).as_json == "2024-01-31" +raise Date.new(2024, 1, 31).wday.to_s unless Date.new(2024, 1, 31).wday == 3 +raise Date.new(2024, 1, 31).yday.to_s unless Date.new(2024, 1, 31).yday == 31 +raise Date.new(2024, 1, 31).monday?.to_s unless Date.new(2024, 1, 31).wednesday? +raise Date.new(2024, 1, 31).year.to_s unless Date.new(2024, 1, 31).year == 2024 +raise Date.new(2024, 1, 31).month.to_s unless Date.new(2024, 1, 31).mon == 1 +raise Date.new(2024, 1, 31).day.to_s unless Date.new(2024, 1, 31).mday == 31 +raise Date.new(2024, 1, 31).strftime("%A %Y-%m-%d").inspect unless Date.new(2024, 1, 31).strftime("%A %Y-%m-%d") == "Wednesday 2024-01-31" +raise (Date.new(2024, 1, 31) >> 2).iso8601 unless (Date.new(2024, 1, 31) >> 2).iso8601 == "2024-03-31" +raise (Date.new(2024, 1, 31) << 1).iso8601 unless (Date.new(2024, 1, 31) << 1).iso8601 == "2023-12-31" +raise Date.iso8601("2024-01-31").iso8601 unless Date.iso8601("2024-01-31").iso8601 == "2024-01-31" +raise Date.civil(2024, 2, 29).iso8601 unless Date.civil(2024, 2, 29).iso8601 == "2024-02-29" +raise Date.parse("2024-02-29", false).iso8601 unless Date.parse("2024-02-29", false).iso8601 == "2024-02-29" +raise Date.strptime("2024-02-29", "%Y-%m-%d").iso8601 unless Date.strptime("2024-02-29", "%Y-%m-%d").iso8601 == "2024-02-29" +raise "leap year" unless Date.new(2024, 2, 29).leap? +raise "date ordering" unless Date.new(2024, 1, 31) < Date.new(2024, 2, 1) +raise "date equality" unless Date.new(2024, 1, 31) == Date.civil(2024, 1, 31) +raise Date.new(2024, 1, 31).to_date.iso8601 unless Date.new(2024, 1, 31).to_date.iso8601 == "2024-01-31" +raise Date.new(2024, 1, 31).to_time.strftime("%Y-%m-%d %H:%M:%S") unless Date.new(2024, 1, 31).to_time.strftime("%Y-%m-%d %H:%M:%S") == "2024-01-31 00:00:00" +begin + Date.iso8601("2023-02-29") + raise "invalid date was accepted" +rescue Date::Error +end +entry.update!(due_on: nil) +entry.reload +raise entry.due_on.inspect unless entry.due_on.nil? +raise entry.as_json(only: [:due_on]).inspect unless entry.as_json(only: [:due_on]) == {"due_on" => nil} +raise ActiveSupport.format_db_date(nil).inspect unless ActiveSupport.format_db_date(nil).nil? +raise ActiveSupport.parse_db_date("").inspect unless ActiveSupport.parse_db_date("").nil? +raise ActiveSupport.parse_db_date(nil).inspect unless ActiveSupport.parse_db_date(nil).nil? +raise ActiveSupport.format_db_date(Date.new(2024, 2, 29)).inspect unless ActiveSupport.format_db_date(Date.new(2024, 2, 29)) == "2024-02-29" +raise ActiveSupport.format_db_date(nil).inspect unless ActiveSupport.format_db_date(nil).nil? +begin + ActiveSupport.format_db_date(42) + raise "invalid DB date value was accepted" +rescue TypeError +end +puts "Spinel Date column contract passed" +"#); + run.assert_passes(); + assert!(run.stdout.contains("Spinel Date column contract passed")); +} diff --git a/tests/inference_on_spinel_blog_runtime_with_rbs.rs b/tests/inference_on_spinel_blog_runtime_with_rbs.rs index 4fb8872fd..b69ff82c7 100644 --- a/tests/inference_on_spinel_blog_runtime_with_rbs.rs +++ b/tests/inference_on_spinel_blog_runtime_with_rbs.rs @@ -935,7 +935,12 @@ fn untyped_subexpressions_with_rbs_baseline() { // runtime_src_integration counts ONE site (`Time.now + expires_in`). // What it buys: the Rails 8 authentication generator's password // reset flow, in Rails' own token format. - const CEILING: usize = 1209; + // 2026-10-04: 1209 -> 1216, +7 for the date-column branch in + // `_as_json_only` (schema_date_columns and format_db_date). It + // preserves the Date JSON form on the Spinel tree while leaving the + // DateTime path on json_time; all seven new residuals are in that + // shared runtime method's gradual storage boundary. + const CEILING: usize = 1216; assert!( all_untyped.len() <= CEILING, diff --git a/tests/model_lowerer.rs b/tests/model_lowerer.rs index 01cd1955a..b4d25ee59 100644 --- a/tests/model_lowerer.rs +++ b/tests/model_lowerer.rs @@ -133,13 +133,14 @@ fn article_lowers_with_schema_methods() { ); // The non-attr scaffold: table_name, schema_columns, - // schema_time_columns, instantiate, initialize, attributes, [], []=, - // update. + // schema_time_columns, schema_date_columns, instantiate, initialize, + // attributes, [], []=, update. for expected in [ "table_name", "_table_sql", "schema_columns", "schema_time_columns", + "schema_date_columns", "instantiate", "initialize", "attributes", @@ -161,6 +162,7 @@ fn article_lowers_with_schema_methods() { "_table_sql", "schema_columns", "schema_time_columns", + "schema_date_columns", "instantiate", "from_row", "from_stmt", diff --git a/tests/runtime_src_integration.rs b/tests/runtime_src_integration.rs index 2393be465..15c7c230a 100644 --- a/tests/runtime_src_integration.rs +++ b/tests/runtime_src_integration.rs @@ -1539,7 +1539,9 @@ fn every_runtime_method_body_concretely_typed() { // `iso8601_ms`'s `::Time` parameter. What it bought: // `has_secure_password`'s password-reset token, which the Rails 8 // authentication generator's PasswordsController and mailer use. - const CEILING: usize = 521; + // 521 -> 522: Date JSON conversion in ActiveRecord::Base#_as_json_only + // adds one dynamic seam read through ActiveSupport.format_db_date. + const CEILING: usize = 522; assert!( total_gradual <= CEILING, "{total_gradual} Ty::Untyped sites exceeds ceiling of {CEILING}",