diff --git a/CHANGELOG.md b/CHANGELOG.md index 43cd06f..a168df7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.21.4 - 2024-03-08 + +* Add `cross_product` (aliased to `cross_join`) as a shortcut for `join` + with no join attributes (`r.join(right)`). Checks that there are no + shared attributes between the two relations, and raises an error if + there are. + ## 0.21.3 - 2024-01-18 * Add more output references to CSV and XLSX writers. It is now possible diff --git a/README.md b/README.md index 6c2ecbf..c915978 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,7 @@ r.allbut([:a, :b, ...]) # remove specified attributes r.autowrap(split: '_') # structure a flat relation, split: '_' is the default r.autosummarize([:a, :b, ...], x: :sum) # (experimental) usual summarizers supported r.constants(x: 12, ...) # add constant attributes (sometimes useful in unions) +r.cross_product(right) # cross product, raises error if left and right have any common attributes (alias `cross_join`) r.extend(x: ->(t){ ... }, ...) # add computed attributes r.extend(x: :y) # shortcut for r.extend(x: ->(t){ t[:y] }) r.exclude(predicate) # shortcut for restrict(!predicate) diff --git a/lib/bmg/algebra/shortcuts.rb b/lib/bmg/algebra/shortcuts.rb index c7c5378..6a5ecaa 100644 --- a/lib/bmg/algebra/shortcuts.rb +++ b/lib/bmg/algebra/shortcuts.rb @@ -57,6 +57,15 @@ def left_join(right, on = [], *args) self.left_join(right.rename(renaming), on.keys, *args) end + def cross_product(right) + duplicates = self.type.attrlist & right.type.attrlist + unless duplicates.empty? + raise TypeError, "Cross product incompatible — duplicate attribute(s): #{duplicates.join(', ')}" + end + + return join(right) + end + def matching(right, on = []) return super unless on.is_a?(Hash) renaming = Hash[on.map{|k,v| [v,k] }] diff --git a/spec/unit/algebra/shortcuts/test_cross_product.rb b/spec/unit/algebra/shortcuts/test_cross_product.rb new file mode 100644 index 0000000..8c75989 --- /dev/null +++ b/spec/unit/algebra/shortcuts/test_cross_product.rb @@ -0,0 +1,56 @@ +require 'spec_helper' +module Bmg + module Algebra + describe Shortcuts, "cross_product" do + + let(:left) { + Relation.new([ + { a: "foo", xyz: 1 }, + { a: "bar", xyz: 2 } + ], Type::ANY.with_attrlist([:xyz, :b])) + } + + let(:right) { + Relation.new([ + { c: "baz", d: 3 }, + { c: "zap", d: 4 } + ], Type::ANY.with_attrlist([:c, :d])) + } + + subject { + left.cross_product(right) + } + + it 'compiles as expected' do + expect(subject).to be_a(Operator::Join) + expect(left_operand(subject)).to be(left) + expect(right_operand(subject)).to be(right) + expect(subject.send(:on)).to eql([]) + end + + it 'works as expected' do + expect(subject.to_set).to eql([ + { a: "bar", xyz: 2, c: "zap", d: 4 }, + { a: "foo", xyz: 1, c: "baz", d: 3 }, + { a: "foo", xyz: 1, c: "zap", d: 4 }, + { a: "bar", xyz: 2, c: "baz", d: 3 }, + ].to_set) + end + + context 'with duplicate attributes' do + let(:right) { + Relation.new([ + { xyz: "baz", d: 3 }, + { xyz: "zap", d: 4 } + ], Type::ANY.with_attrlist([:xyz, :d])) + } + + it 'raises an error' do + expect{ + left.cross_product(right) + }.to raise_error(Bmg::TypeError, /Cross product incompatible.*xyz/) + end + end + end + end +end diff --git a/spec/unit/operator/test_join.rb b/spec/unit/operator/test_join.rb index 621bd48..312851b 100644 --- a/spec/unit/operator/test_join.rb +++ b/spec/unit/operator/test_join.rb @@ -47,7 +47,63 @@ module Operator :variant => :left, :default_right_tuple => { c: 10 } } - expect(op.to_a). to eql(expected) + expect(op.to_a).to eql(expected) + end + + context 'without join list an no overlapping attributes' do + let(:left) { + [ + { a: 1, b: 2, x: "a" }, + { a: 3, b: 4, x: "b" }, + ] + } + + let(:right) { + [ + { q: 10, p: "80" }, + { q: 20, p: "90" }, + ] + } + + it 'does a cross join' do + expected = [ + { a: 1, b: 2, x: "a", q: 10, p: "80"}, + { a: 1, b: 2, x: "a", q: 20, p: "90"}, + { a: 3, b: 4, x: "b", q: 10, p: "80"}, + { a: 3, b: 4, x: "b", q: 20, p: "90"}, + ].to_set + + op = Join.new Type::ANY, left, right, [] + expect(op.to_set).to eql(expected) + end + end + + context 'without join list and shared attributes' do + let(:left) { + [ + { a: 1, b: 2, x: "a" }, + { a: 3, b: 4, x: "b" }, + ] + } + + let(:right) { + [ + { a: 1, q: 10, p: "80" }, + { a: 5, q: 20, p: "90" }, + ] + } + + it 'does a cross join, discaring the shared attribute from right' do + expected = [ + { a: 1, b: 2, x: "a", q: 10, p: "80"}, + { a: 1, b: 2, x: "a", q: 20, p: "90"}, + { a: 3, b: 4, x: "b", q: 10, p: "80"}, + { a: 3, b: 4, x: "b", q: 20, p: "90"}, + ].to_set + + op = Join.new Type::ANY, left, right, [] + expect(op.to_set).to eql(expected) + end end end