summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorVictor Shepelev <zverok.offline@gmail.com>2022-09-30 12:23:19 +0300
committerGitHub <noreply@github.com>2022-09-30 18:23:19 +0900
commitad651925e365ca18645f05b5e9b2eca9cd5721bc (patch)
tree37536bfdd4a35061505d1364fc6a084140ba5a30
parente294e6f417acc27d85c6e6a0e67ebf7a07c83485 (diff)
Add Data class implementation: Simple immutable value object
Notes
Notes: Merged: https://github.com/ruby/ruby/pull/6353 Merged-By: nobu <nobu@ruby-lang.org>
-rw-r--r--NEWS.md6
-rw-r--r--array.c2
-rw-r--r--internal/array.h1
-rw-r--r--spec/ruby/core/data/constants_spec.rb14
-rw-r--r--struct.c529
-rw-r--r--test/ruby/test_data.rb170
6 files changed, 716 insertions, 6 deletions
diff --git a/NEWS.md b/NEWS.md
index 3cc1983a8c..0aa51bb032 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -102,6 +102,11 @@ Note that each entry is kept to a minimum, see links for details.
Note: We're only listing outstanding class updates.
+* Data
+ * New core class to represent simple immutable value object. The class is
+ similar to `Struct` and partially shares an implementation, but has more
+ lean and strict API. [[Feature #16122]]
+
* Encoding
* Encoding#replicate has been deprecated and will be removed in 3.3. [[Feature #18949]]
* The dummy `Encoding::UTF_16` and `Encoding::UTF_32` encodings no longer
@@ -323,3 +328,4 @@ The following deprecated APIs are removed.
[Feature #18949]: https://bugs.ruby-lang.org/issues/18949
[Feature #19008]: https://bugs.ruby-lang.org/issues/19008
[Feature #19026]: https://bugs.ruby-lang.org/issues/19026
+[Feature #16122]: https://bugs.ruby-lang.org/issues/16122
diff --git a/array.c b/array.c
index cacc549a24..73e8a3c9ce 100644
--- a/array.c
+++ b/array.c
@@ -5587,7 +5587,7 @@ ary_recycle_hash(VALUE hash)
* Related: Array#difference.
*/
-static VALUE
+VALUE
rb_ary_diff(VALUE ary1, VALUE ary2)
{
VALUE ary3;
diff --git a/internal/array.h b/internal/array.h
index 17d91a800b..a0d16dec3f 100644
--- a/internal/array.h
+++ b/internal/array.h
@@ -35,6 +35,7 @@ void rb_ary_cancel_sharing(VALUE ary);
size_t rb_ary_size_as_embedded(VALUE ary);
void rb_ary_make_embedded(VALUE ary);
bool rb_ary_embeddable_p(VALUE ary);
+VALUE rb_ary_diff(VALUE ary1, VALUE ary2);
static inline VALUE rb_ary_entry_internal(VALUE ary, long offset);
static inline bool ARY_PTR_USING_P(VALUE ary);
diff --git a/spec/ruby/core/data/constants_spec.rb b/spec/ruby/core/data/constants_spec.rb
index 1d469f9237..d9d55b50f9 100644
--- a/spec/ruby/core/data/constants_spec.rb
+++ b/spec/ruby/core/data/constants_spec.rb
@@ -14,10 +14,22 @@ ruby_version_is ''...'3.0' do
end
end
-ruby_version_is '3.0' do
+ruby_version_is '3.0'...'3.2' do
describe "Data" do
it "does not exist anymore" do
Object.should_not have_constant(:Data)
end
end
end
+
+ruby_version_is '3.2' do
+ describe "Data" do
+ it "is a new constant" do
+ Data.superclass.should == Object
+ end
+
+ it "is not deprecated" do
+ -> { Data }.should_not complain
+ end
+ end
+end
diff --git a/struct.c b/struct.c
index 1e7294eb5e..57d7cffc30 100644
--- a/struct.c
+++ b/struct.c
@@ -28,7 +28,11 @@ enum {
AREF_HASH_THRESHOLD = 10
};
+/* Note: Data is a stricter version of the Struct: no attr writers & no
+ hash-alike/array-alike behavior. It shares most of the implementation
+ on the C level, but is unrelated on the Ruby level. */
VALUE rb_cStruct;
+static VALUE rb_cData;
static ID id_members, id_back_members, id_keyword_init;
static VALUE struct_alloc(VALUE);
@@ -44,7 +48,7 @@ struct_ivar_get(VALUE c, ID id)
for (;;) {
c = rb_class_superclass(c);
- if (c == 0 || c == rb_cStruct)
+ if (c == 0 || c == rb_cStruct || c == rb_cData)
return Qnil;
RUBY_ASSERT(RB_TYPE_P(c, T_CLASS));
ivar = rb_attr_get(c, id);
@@ -297,6 +301,29 @@ rb_struct_s_inspect(VALUE klass)
return inspect;
}
+static VALUE
+rb_data_s_new(int argc, const VALUE *argv, VALUE klass)
+{
+ if (rb_keyword_given_p()) {
+ if (argc > 1 || !RB_TYPE_P(argv[0], T_HASH)) {
+ rb_error_arity(argc, 0, 0);
+ }
+ return rb_class_new_instance_pass_kw(argc, argv, klass);
+ }
+ else {
+ VALUE members = struct_ivar_get(klass, id_members);
+ int num_members = RARRAY_LENINT(members);
+
+ rb_check_arity(argc, 0, num_members);
+ VALUE arg_hash = rb_hash_new_with_size(argc);
+ for (long i=0; i<argc; i++) {
+ VALUE k = rb_ary_entry(members, i), v = argv[i];
+ rb_hash_aset(arg_hash, k, v);
+ }
+ return rb_class_new_instance_kw(1, &arg_hash, klass, RB_PASS_KEYWORDS);
+ }
+}
+
#if 0 /* for RDoc */
/*
@@ -349,6 +376,30 @@ setup_struct(VALUE nstr, VALUE members)
return nstr;
}
+static VALUE
+setup_data(VALUE subclass, VALUE members)
+{
+ long i, len;
+
+ members = struct_set_members(subclass, members);
+
+ rb_define_alloc_func(subclass, struct_alloc);
+ rb_define_singleton_method(subclass, "new", rb_data_s_new, -1);
+ rb_define_singleton_method(subclass, "[]", rb_data_s_new, -1);
+ rb_define_singleton_method(subclass, "members", rb_struct_s_members_m, 0);
+ rb_define_singleton_method(subclass, "inspect", rb_struct_s_inspect, 0); // FIXME: just a separate method?..
+
+ len = RARRAY_LEN(members);
+ for (i=0; i< len; i++) {
+ VALUE sym = RARRAY_AREF(members, i);
+ VALUE off = LONG2NUM(i);
+
+ define_aref_method(subclass, sym, off);
+ }
+
+ return subclass;
+}
+
VALUE
rb_struct_alloc_noinit(VALUE klass)
{
@@ -912,10 +963,11 @@ rb_struct_each_pair(VALUE s)
}
static VALUE
-inspect_struct(VALUE s, VALUE dummy, int recur)
+inspect_struct(VALUE s, VALUE prefix, int recur)
{
VALUE cname = rb_class_path(rb_obj_class(s));
- VALUE members, str = rb_str_new2("#<struct ");
+ VALUE members;
+ VALUE str = prefix;
long i, len;
char first = RSTRING_PTR(cname)[0];
@@ -972,7 +1024,7 @@ inspect_struct(VALUE s, VALUE dummy, int recur)
static VALUE
rb_struct_inspect(VALUE s)
{
- return rb_exec_recursive(inspect_struct, s, 0);
+ return rb_exec_recursive(inspect_struct, s, rb_str_new2("#<struct "));
}
/*
@@ -1520,6 +1572,448 @@ rb_struct_dig(int argc, VALUE *argv, VALUE self)
}
/*
+ * Document-class: Data
+ *
+ * \Class \Data provides a convenient way to define simple classes
+ * for value-alike objects.
+ *
+ * The simplest example of usage:
+ *
+ * Measure = Data.define(:amount, :unit)
+ *
+ * # Positional arguments constructor is provided
+ * distance = Measure.new(100, 'km')
+ * #=> #<data Measure amount=100, unit="km">
+ *
+ * # Keyword arguments constructor is provided
+ * weight = Measure.new(amount: 50, unit: 'kg')
+ * #=> #<data Measure amount=50, unit="kg">
+ *
+ * # Alternative form to construct an object:
+ * speed = Measure[10, 'mPh']
+ * #=> #<data Measure amount=10, unit="mPh">
+ *
+ * # Works with keyword arguments, too:
+ * area = Measure[amount: 1.5, unit: 'm^2']
+ * #=> #<data Measure amount=1.5, unit="m^2">
+ *
+ * # Argument accessors are provided:
+ * distance.amount #=> 100
+ * distance.unit #=> "km"
+ *
+ * Constructed object also has a reasonable definitions of #==
+ * operator, #to_h hash conversion, and #deconstruct/#deconstruct_keys
+ * to be used in pattern matching.
+ *
+ * ::define method accepts an optional block and evaluates it in
+ * the context of the newly defined class. That allows to define
+ * additional methods:
+ *
+ * Measure = Data.define(:amount, :unit) do
+ * def <=>(other)
+ * return unless other.is_a?(self.class) && other.unit == unit
+ * amount <=> other.amount
+ * end
+ *
+ * include Comparable
+ * end
+ *
+ * Measure[3, 'm'] < Measure[5, 'm'] #=> true
+ * Measure[3, 'm'] < Measure[5, 'kg']
+ * # comparison of Measure with Measure failed (ArgumentError)
+ *
+ * Data provides no member writers, or enumerators: it is meant
+ * to be a storage for immutable atomic values. But note that
+ * if some of data members is of a mutable class, Data does no additional
+ * immutability enforcement:
+ *
+ * Event = Data.define(:time, :weekdays)
+ * event = Event.new('18:00', %w[Tue Wed Fri])
+ * #=> #<data Event time="18:00", weekdays=["Tue", "Wed", "Fri"]>
+ *
+ * # There is no #time= or #weekdays= accessors, but changes are
+ * # still possible:
+ * event.weekdays << 'Sat'
+ * event
+ * #=> #<data Event time="18:00", weekdays=["Tue", "Wed", "Fri", "Sat"]>
+ *
+ * See also Struct, which is a similar concept, but has more
+ * container-alike API, allowing to change contents of the object
+ * and enumerate it.
+ */
+
+/*
+ * call-seq:
+ * define(name, *symbols) -> class
+ * define(*symbols) -> class
+ *
+ * Defines a new \Data class. If the first argument is a string, the class
+ * is stored in <tt>Data::<name></tt> constant.
+ *
+ * measure = Data.define(:amount, :unit)
+ * #=> #<Class:0x00007f70c6868498>
+ * measure.new(1, 'km')
+ * #=> #<data amount=1, unit="km">
+ *
+ * # It you store the new class in the constant, it will
+ * # affect #inspect and will be more natural to use:
+ * Measure = Data.define(:amount, :unit)
+ * #=> Measure
+ * Measure.new(1, 'km')
+ * #=> #<data Measure amount=1, unit="km">
+ *
+ *
+ * Note that member-less \Data is acceptable and might be a useful technique
+ * for defining several homogenous data classes, like
+ *
+ * class HTTPFetcher
+ * Response = Data.define(:body)
+ * NotFound = Data.define
+ * # ... implementation
+ * end
+ *
+ * Now, different kinds of responses from +HTTPFetcher+ would have consistent
+ * representation:
+ *
+ * #<data HTTPFetcher::Response body="<html...">
+ * #<data HTTPFetcher::NotFound>
+ *
+ * And are convenient to use in pattern matching:
+ *
+ * case fetcher.get(url)
+ * in HTTPFetcher::Response(body)
+ * # process body variable
+ * in HTTPFetcher::NotFound
+ * # handle not found case
+ * end
+ */
+
+static VALUE
+rb_data_s_def(int argc, VALUE *argv, VALUE klass)
+{
+ VALUE rest;
+ long i;
+ VALUE data_class;
+ st_table *tbl;
+
+ rest = rb_ident_hash_new();
+ RBASIC_CLEAR_CLASS(rest);
+ OBJ_WB_UNPROTECT(rest);
+ tbl = RHASH_TBL_RAW(rest);
+ for (i=0; i<argc; i++) {
+ VALUE mem = rb_to_symbol(argv[i]);
+ if (rb_is_attrset_sym(mem)) {
+ rb_raise(rb_eArgError, "invalid data member: %"PRIsVALUE, mem);
+ }
+ if (st_insert(tbl, mem, Qtrue)) {
+ rb_raise(rb_eArgError, "duplicate member: %"PRIsVALUE, mem);
+ }
+ }
+ rest = rb_hash_keys(rest);
+ st_clear(tbl);
+ RBASIC_CLEAR_CLASS(rest);
+ OBJ_FREEZE_RAW(rest);
+ data_class = anonymous_struct(klass);
+ setup_data(data_class, rest);
+ if (rb_block_given_p()) {
+ rb_mod_module_eval(0, 0, data_class);
+ }
+
+ return data_class;
+}
+
+/*
+ * call-seq:
+ * DataClass::members -> array_of_symbols
+ *
+ * Returns an array of member names of the data class:
+ *
+ * Measure = Data.define(:amount, :unit)
+ * Measure.members # => [:amount, :unit]
+ *
+ */
+
+#define rb_data_s_members_m rb_struct_s_members_m
+
+
+/*
+ * call-seq:
+ * new(*args) -> instance
+ * new(**kwargs) -> instance
+ * ::[](*args) -> instance
+ * ::[](**kwargs) -> instance
+ *
+ * Constructors for classes defined with ::define accept both positional and
+ * keyword arguments.
+ *
+ * Measure = Data.define(:amount, :unit)
+ *
+ * Measure.new(1, 'km')
+ * #=> #<data Measure amount=1, unit="km">
+ * Measure.new(amount: 1, unit: 'km')
+ * #=> #<data Measure amount=1, unit="km">
+ *
+ * # Alternative shorter intialization with []
+ * Measure[1, 'km']
+ * #=> #<data Measure amount=1, unit="km">
+ * Measure[amount: 1, unit: 'km']
+ * #=> #<data Measure amount=1, unit="km">
+ *
+ * All arguments are mandatory (unlike Struct), and converted to keyword arguments:
+ *
+ * Measure.new(amount: 1)
+ * # in `initialize': missing keyword: :unit (ArgumentError)
+ *
+ * Measure.new(1)
+ * # in `initialize': missing keyword: :unit (ArgumentError)
+ *
+ * Note that <tt>Measure#initialize</tt> always receives keyword arguments, and that
+ * mandatory arguments are checked in +initialize+, not in +new+. This can be
+ * important for redefining initialize in order to convert arguments or provide
+ * defaults:
+ *
+ * Measure = Data.define(:amount, :unit) do
+ * NONE = Data.define
+ *
+ * def initialize(amount:, unit: NONE.new)
+ * super(amount: Float(amount), unit:)
+ * end
+ * end
+ *
+ * Measure.new('10', 'km') # => #<data Measure amount=10.0, unit="km">
+ * Measure.new(10_000) # => #<data Measure amount=10000.0, unit=#<data NONE>>
+ *
+ */
+
+static VALUE
+rb_data_initialize_m(int argc, const VALUE *argv, VALUE self)
+{
+ VALUE klass = rb_obj_class(self);
+ rb_struct_modify(self);
+ VALUE members = struct_ivar_get(klass, id_members);
+ size_t num_members = RARRAY_LEN(members);
+
+ if (argc > 1 || !RB_TYPE_P(argv[0], T_HASH)) {
+ rb_raise(rb_eArgError, "wrong number of arguments (given %d, expected 0)", argc);
+ }
+
+ if (RHASH_SIZE(argv[0]) < num_members) {
+ VALUE missing = rb_ary_diff(members, rb_hash_keys(argv[0]));
+ rb_exc_raise(rb_keyword_error_new("missing", missing));
+ }
+
+ struct struct_hash_set_arg arg;
+ rb_mem_clear((VALUE *)RSTRUCT_CONST_PTR(self), num_members);
+ arg.self = self;
+ arg.unknown_keywords = Qnil;
+ rb_hash_foreach(argv[0], struct_hash_set_i, (VALUE)&arg);
+ if (arg.unknown_keywords != Qnil) {
+ rb_exc_raise(rb_keyword_error_new("unknown", arg.unknown_keywords));
+ }
+ return Qnil;
+}
+
+/*
+ * call-seq:
+ * inspect -> string
+ * to_s -> string
+ *
+ * Returns a string representation of +self+:
+ *
+ * Measure = Data.define(:amount, :unit)
+ *
+ * distance = Measure[10, 'km']
+ *
+ * p distance # uses #inspect underneath
+ * #<data Measure amount=10, unit="km">
+ *
+ * puts distance # uses #to_s underneath, same representation
+ * #<data Measure amount=10, unit="km">
+ *
+ */
+
+static VALUE
+rb_data_inspect(VALUE s)
+{
+ return rb_exec_recursive(inspect_struct, s, rb_str_new2("#<data "));
+}
+
+/*
+ * call-seq:
+ * self == other -> true or false
+ *
+ * Returns +true+ if +other+ is the same class as +self+, and all members are
+ * equal.
+ *
+ * Examples:
+ *
+ * Measure = Data.new(:amount, :unit)
+ *
+ * Measure[1, 'km'] == Measure[1, 'km'] #=> true
+ * Measure[1, 'km'] == Measure[2, 'km'] #=> false
+ * Measure[1, 'km'] == Measure[1, 'm'] #=> false
+ *
+ * Measurement = Data.new(:amount, :unit)
+ * # Even though Measurement and Measure have the same "shape"
+ * # their instances are never equal
+ * Measure[1, 'km'] == Measurement[1, 'km'] #=> false
+ */
+
+#define rb_data_equal rb_struct_equal
+
+/*
+ * call-seq:
+ * self.eql?(other) -> true or false
+ *
+ * Equality check that is used when two items of data are keys of a Hash.
+ *
+ * The subtle difference with #== is that members are also compared with their
+ * #eql? method, which might be important in some cases:
+ *
+ * Measure = Data.new(:amount, :unit)
+ *
+ * Measure[1, 'km'] == Measure[1.0, 'km'] #=> true, they are equal as values
+ * # ...but...
+ * Measure[1, 'km'].eql? Measure[1.0, 'km'] #=> false, they represent different hash keys
+ *
+ * See also Object#eql? for further explanations of the method usage.
+ */
+
+#define rb_data_eql rb_struct_eql
+
+/*
+ * call-seq:
+ * hash -> integer
+ *
+ * Redefines Object#hash (used to distinguish objects as Hash keys) so that
+ * data objects of the same class with same content would have the same +hash+
+ * value, and represented the same Hash key.
+ *
+ * Measure = Data.define(:amount, :unit)
+ *
+ * Measure[1, 'km'].hash == Measure[1, 'km'].hash #=> true
+ * Measure[1, 'km'].hash == Measure[10, 'km'].hash #=> false
+ * Measure[1, 'km'].hash == Measure[1, 'm'].hash #=> false
+ * Measure[1, 'km'].hash == Measure[1.0, 'km'].hash #=> false
+ *
+ * # Structurally similar data class, but shouldn't be considered
+ * # the same hash key
+ * Measurement = Data.define(:amount, :unit)
+ *
+ * Measure[1, 'km'].hash == Measurement[1, 'km'].hash #=> false
+ */
+
+#define rb_data_hash rb_struct_hash
+
+/*
+ * call-seq:
+ * to_h -> hash
+ * to_h {|name, value| ... } -> hash
+ *
+ * Returns Hash representation of the data object.
+ *
+ * Measure = Data.define(:amount, :unit)
+ * distance = Measure[10, 'km']
+ *
+ * distance.to_h
+ * #=> {:amount=>10, :unit=>"km"}
+ *
+ * Like Enumerable#to_h, if the block is provided, it is expected to
+ * produce key-value pairs to construct a hash:
+ *
+ *
+ * distance.to_h { |name, val| [name.to_s, val.to_s] }
+ * #=> {"amount"=>"10", "unit"=>"km"}
+ *
+ * Note that there is a useful symmetry between #to_h and #initialize:
+ *
+ * distance2 = Measure.new(**distance.to_h)
+ * #=> #<data Measure amount=10, unit="km">
+ * distance2 == distance
+ * #=> true
+ */
+
+#define rb_data_to_h rb_struct_to_h
+
+/*
+ * call-seq:
+ * members -> array_of_symbols
+ *
+ * Returns the member names from +self+ as an array:
+ *
+ * Measure = Data.define(:amount, :unit)
+ * distance = Measure[10, 'km']
+ *
+ * distance.members #=> [:amount, :unit]
+ *
+ */
+
+#define rb_data_members_m rb_struct_members_m
+
+/*
+ * call-seq:
+ * deconstruct -> array
+ *
+ * Returns the values in +self+ as an array, to use in pattern matching:
+ *
+ * Measure = Data.define(:amount, :unit)
+ *
+ * distance = Measure[10, 'km']
+ * distance.deconstruct #=> [10, "km"]
+ *
+ * # usage
+ * case distance
+ * in n, 'km' # calls #deconstruct underneath
+ * puts "It is #{n} kilometers away"
+ * else
+ * puts "Don't know how to handle it"
+ * end
+ * # prints "It is 10 kilometers away"
+ *
+ * Or, with checking the class, too:
+ *
+ * case distance
+ * in Measure(n, 'km')
+ * puts "It is #{n} kilometers away"
+ * # ...
+ * end
+ */
+
+#define rb_data_deconstruct rb_struct_to_a
+
+/*
+ * call-seq:
+ * deconstruct_keys(array_of_names_or_nil) -> hash
+ *
+ * Returns a hash of the name/value pairs, to use in pattern matching.
+ *
+ * Measure = Data.define(:amount, :unit)
+ *
+ * distance = Measure[10, 'km']
+ * distance.deconstruct_keys(nil) #=> {:amount=>10, :unit=>"km"}
+ * distance.deconstruct_keys([:amount]) #=> {:amount=>10}
+ *
+ * # usage
+ * case distance
+ * in amount:, unit: 'km' # calls #deconstruct_keys underneath
+ * puts "It is #{amount} kilometers away"
+ * else
+ * puts "Don't know how to handle it"
+ * end
+ * # prints "It is 10 kilometers away"
+ *
+ * Or, with checking the class, too:
+ *
+ * case distance
+ * in Measure(amount:, unit: 'km')
+ * puts "It is #{amount} kilometers away"
+ * # ...
+ * end
+ */
+
+#define rb_data_deconstruct_keys rb_struct_deconstruct_keys
+
+/*
* Document-class: Struct
*
* \Class \Struct provides a convenient way to create a simple class
@@ -1568,6 +2062,9 @@ rb_struct_dig(int argc, VALUE *argv, VALUE self)
* - Includes {module Enumerable}[rdoc-ref:Enumerable@What-27s+Here],
* which provides dozens of additional methods.
*
+ * See also Data, which is a somewhat similar, but stricter concept for defining immutable
+ * value objects.
+ *
* Here, class \Struct provides methods that are useful for:
*
* - {Creating a Struct Subclass}[rdoc-ref:Struct@Methods+for+Creating+a+Struct+Subclass]
@@ -1663,6 +2160,30 @@ InitVM_Struct(void)
rb_define_method(rb_cStruct, "deconstruct", rb_struct_to_a, 0);
rb_define_method(rb_cStruct, "deconstruct_keys", rb_struct_deconstruct_keys, 1);
+
+ rb_cData = rb_define_class("Data", rb_cObject);
+
+ rb_undef_method(CLASS_OF(rb_cData), "new");
+ rb_undef_alloc_func(rb_cData);
+ rb_define_singleton_method(rb_cData, "define", rb_data_s_def, -1);
+
+ rb_define_singleton_method(rb_cData, "members", rb_data_s_members_m, 0);
+
+ rb_define_method(rb_cData, "initialize", rb_data_initialize_m, -1);
+ rb_define_method(rb_cData, "initialize_copy", rb_struct_init_copy, 1);
+
+ rb_define_method(rb_cData, "==", rb_data_equal, 1);
+ rb_define_method(rb_cData, "eql?", rb_data_eql, 1);
+ rb_define_method(rb_cData, "hash", rb_data_hash, 0);
+
+ rb_define_method(rb_cData, "inspect", rb_data_inspect, 0);
+ rb_define_alias(rb_cData, "to_s", "inspect");
+ rb_define_method(rb_cData, "to_h", rb_data_to_h, 0);
+
+ rb_define_method(rb_cData, "members", rb_data_members_m, 0);
+
+ rb_define_method(rb_cData, "deconstruct", rb_data_deconstruct, 0);
+ rb_define_method(rb_cData, "deconstruct_keys", rb_data_deconstruct_keys, 1);
}
#undef rb_intern
diff --git a/test/ruby/test_data.rb b/test/ruby/test_data.rb
new file mode 100644
index 0000000000..0117cd22c5
--- /dev/null
+++ b/test/ruby/test_data.rb
@@ -0,0 +1,170 @@
+# -*- coding: us-ascii -*-
+# frozen_string_literal: false
+require 'test/unit'
+require 'timeout'
+
+class TestData < Test::Unit::TestCase
+ def test_define
+ klass = Data.define(:foo, :bar)
+ assert_kind_of(Class, klass)
+ assert_equal(%i[foo bar], klass.members)
+
+ assert_raise(NoMethodError) { Data.new(:foo) }
+ assert_raise(TypeError) { Data.define(0) }
+
+ # Because some code is shared with Struct, check we don't share unnecessary functionality
+ assert_raise(TypeError) { Data.define(:foo, keyword_init: true) }
+ end
+
+ def test_define_edge_cases
+ # non-ascii
+ klass = Data.define(:"r\u{e9}sum\u{e9}")
+ o = klass.new(1)
+ assert_equal(1, o.send(:"r\u{e9}sum\u{e9}"))
+
+ # junk string
+ klass = Data.define(:"a\000")
+ o = klass.new(1)
+ assert_equal(1, o.send(:"a\000"))
+
+ # special characters in attribute names
+ klass = Data.define(:a, :b?)
+ x = Object.new
+ o = klass.new("test", x)
+ assert_same(x, o.b?)
+
+ klass = Data.define(:a, :b!)
+ x = Object.new
+ o = klass.new("test", x)
+ assert_same(x, o.b!)
+
+ assert_raise(ArgumentError) { Data.define(:x=) }
+ assert_raise(ArgumentError, /duplicate member/) { Data.define(:x, :x) }
+ end
+
+ def test_define_with_block
+ klass = Data.define(:a, :b) do
+ def c
+ a + b
+ end
+ end
+
+ assert_equal(3, klass.new(1, 2).c)
+ end
+
+ def test_initialize
+ klass = Data.define(:foo, :bar)
+
+ # Regular
+ test = klass.new(1, 2)
+ assert_equal(1, test.foo)
+ assert_equal(2, test.bar)
+ assert_equal(test, klass.new(1, 2))
+
+ # Keywords
+ test_kw = klass.new(foo: 1, bar: 2)
+ assert_equal(1, test_kw.foo)
+ assert_equal(2, test_kw.bar)
+ assert_equal(test_kw, klass.new(foo: 1, bar: 2))
+ assert_equal(test_kw, test)
+
+ # Wrong protocol
+ assert_raise(ArgumentError) { klass.new(1) }
+ assert_raise(ArgumentError) { klass.new(1, 2, 3) }
+ assert_raise(ArgumentError) { klass.new(foo: 1) }
+ assert_raise(ArgumentError) { klass.new(foo: 1, bar: 2, baz: 3) }
+ # Could be converted to foo: 1, bar: 2, but too smart is confusing
+ assert_raise(ArgumentError) { klass.new(1, bar: 2) }
+ end
+
+ def test_initialize_redefine
+ klass = Data.define(:foo, :bar) do
+ attr_reader :passed
+
+ def initialize(*args, **kwargs)
+ @passed = [args, kwargs]
+ super(foo: 1, bar: 2) # so we can experiment with passing wrong numbers of args
+ end
+ end
+
+ assert_equal([[], {foo: 1, bar: 2}], klass.new(foo: 1, bar: 2).passed)
+
+ # Positional arguments are converted to keyword ones
+ assert_equal([[], {foo: 1, bar: 2}], klass.new(1, 2).passed)
+
+ # Missing arguments can be fixed in initialize
+ assert_equal([[], {foo: 1}], klass.new(foo: 1).passed)
+
+ # Extra keyword arguments can be dropped in initialize
+ assert_equal([[], {foo: 1, bar: 2, baz: 3}], klass.new(foo: 1, bar: 2, baz: 3).passed)
+ end
+
+ def test_instance_behavior
+ klass = Data.define(:foo, :bar)
+
+ test = klass.new(1, 2)
+ assert_equal(1, test.foo)
+ assert_equal(2, test.bar)
+ assert_equal(%i[foo bar], test.members)
+ assert_equal(1, test.public_send(:foo))
+ assert_equal(0, test.method(:foo).arity)
+ assert_equal([], test.method(:foo).parameters)
+
+ assert_equal({foo: 1, bar: 2}, test.to_h)
+ assert_equal({"foo"=>"1", "bar"=>"2"}, test.to_h { [_1.to_s, _2.to_s] })
+
+ assert_equal({foo: 1, bar: 2}, test.deconstruct_keys(nil))
+ assert_equal({foo: 1}, test.deconstruct_keys(%i[foo]))
+ assert_equal({foo: 1}, test.deconstruct_keys(%i[foo baz]))
+ assert_raise(TypeError) { test.deconstruct_keys(0) }
+
+ assert_kind_of(Integer, test.hash)
+ end
+
+ def test_inspect
+ klass = Data.define(:a)
+ o = klass.new(1)
+ assert_equal("#<data a=1>", o.inspect)
+
+ Object.const_set(:Foo, klass)
+ assert_equal("#<data Foo a=1>", o.inspect)
+ Object.instance_eval { remove_const(:Foo) }
+
+ klass = Data.define(:@a)
+ o = klass.new(1)
+ assert_equal("#<data :@a=1>", o.inspect)
+ end
+
+ def test_equal
+ klass1 = Data.define(:a)
+ klass2 = Data.define(:a)
+ o1 = klass1.new(1)
+ o2 = klass1.new(1)
+ o3 = klass2.new(1)
+ assert_equal(o1, o2)
+ assert_not_equal(o1, o3)
+ end
+
+ def test_eql
+ klass1 = Data.define(:a)
+ klass2 = Data.define(:a)
+ o1 = klass1.new(1)
+ o2 = klass1.new(1)
+ o3 = klass2.new(1)
+ assert_operator(o1, :eql?, o2)
+ assert_not_operator(o1, :eql?, o3)
+ end
+
+ def test_memberless
+ klass = Data.define
+
+ test = klass.new
+
+ assert_equal(klass.new, test)
+ assert_not_equal(Data.define.new, test)
+
+ assert_equal('#<data >', test.inspect)
+ assert_equal([], test.members)
+ assert_equal({}, test.to_h)
+ end
+end