Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Decouple attribute alias from type definition #512

Merged
merged 13 commits into from
Apr 18, 2019
Merged

Conversation

waiting-for-dev
Copy link
Collaborator

This PR is created because of this thread in the forums:

https://discourse.rom-rb.org/t/ability-to-alias-an-attribute-but-keeping-type-inferred/289

Basically, it decouples an attribute alias from its type, so it will be
possible to define an alias but still make use of schema inference.

This is still a WIP, but I'm looking for some feedback in order not to do extra work.

Some considerations I'd like to have your thoughts:

  • The DSL is still not adapted. I haven't tested it, but I guess it won't work
    defining an attribute without specifying its type, because the main purpose
    of this PR is to be able to do a simple:

    attribute :foo, alias: :bar

  • The option to define an alias is called alias:. This is because as: can't
    be freely used because it is already a method name and dry-initializer
    creates methods from option names. We could use method renaming feature in
    dry-initializer for that, but then we have to juggle with the renaming in
    the rest of code, like in aliased and meta methods, and it is a bit ugly.
    The handicap is that in other places of the API as: is used for similar
    purposes, like in association aliasing.

  • alias method has been removed because it is defined out of the box by
    dry-initializer.

  • The attribute ast will now always return alias: nil when an alias is not
    defined. This is different from before, where alias key was not present in
    those situations. In my opinion, it makes more sense to always return all the keys.

  • The test for returning aliased attribute identified by canonincal name has
    been removed, because I think that, being now an option and not part of the
    type, it makes no sense.

  • I have redefined ROM::SQL::Function#name method in the repository
    integration tests. This should be changed in rom-sql project. How would yo
    go with it?

  • I guess that it would also make sense to move wrapped from the meta to an
    option. Would you like to change it also in this PR?

  • What do you think about primary_key meta? Should we consider it part of the
    type (meta) or also an option? Even if it related with the value (the value
    being unique), it is so only considering the rest of values. It is like
    dynamic typing. Anyway, it would be more convenient to have it as option, I
    guess.

Thanks!

@solnic
Copy link
Member

solnic commented Dec 20, 2018

The test for returning aliased attribute identified by canonincal name has
been removed, because I think that, being now an option and not part of the
type, it makes no sense.

Could you elaborate a bit more here?

@waiting-for-dev
Copy link
Collaborator Author

The test for returning aliased attribute identified by canonincal name has
been removed, because I think that, being now an option and not part of the
type, it makes no sense.

Could you elaborate a bit more here?

Well, I guess this test existed in order to be sure than aliasing an attribute didn't change the name in the meta of the type. But now they are two different concepts. One thing is the type of an attribute and another thing are their options (like alias:). So, I think it makes no sense to test that the change in the type doesn't affect the options... why should it? I think that keeping the test just reflects the development history (the fact that the alias was part of the type before). Furthermore, rename seems very well tested in rename_spec file.

But maybe I'm missing something 🙂

@solnic
Copy link
Member

solnic commented Dec 30, 2018

Well, I guess this test existed in order to be sure than aliasing an attribute didn't change the name in the meta of the type.

This test exists because it checks we can still access attributes using canonical names, and rename_spec doesn't cover this scenario, so it seems like we should not remove the test.

@waiting-for-dev
Copy link
Collaborator Author

Makes sense. I readded the test, fixed rubocop issues and forced the push rebasing from master.

There is still more to review, however. Besides the points I mentioned in the first comment, we should also decide about the #inspect output for Attribute now that it has more options.

@solnic
Copy link
Member

solnic commented Dec 31, 2018

Besides the points I mentioned in the first comment, we should also decide about the #inspect output for Attribute now that it has more options.

We could do:

def inspect
  %(#<#{self.class}[#{type.name}] #{meta.merge(options).map { |k, v| "#{k}=#{v.inspect}" }.join(' ')}>)
end

core/lib/rom/attribute.rb Show resolved Hide resolved
core/lib/rom/attribute.rb Outdated Show resolved Hide resolved
core/lib/rom/attribute.rb Outdated Show resolved Hide resolved
core/lib/rom/attribute.rb Outdated Show resolved Hide resolved
@waiting-for-dev
Copy link
Collaborator Author

I added a commit for each change. We can squash them later if needed.

Besides the points I mentioned in the first comment, we should also decide about the #inspect output for Attribute now that it has more options.

We could do:

def inspect
  %(#<#{self.class}[#{type.name}] #{meta.merge(options).map { |k, v| "#{k}=#{v.inspect}" }.join(' ')}>)
end

As with attribute ast, inspect will always include alias: key even when the value is nil. Personally I prefer being explicit in these situations, but, anyway, I guess we must be consistent with the behaviour for meta keys (if there is still any at the end of the work in this PR).

@solnic
Copy link
Member

solnic commented Jan 2, 2019

I have redefined ROM::SQL::Function#name method in the repository
integration tests. This should be changed in rom-sql project. How would yo
go with it?

Please open a PR in rom-sql which changes this, preferably with tests. You could temporary target this branch in the rom-sql's Gemfile, once it's merged we can go back to depending on master.

@waiting-for-dev
Copy link
Collaborator Author

waiting-for-dev commented Jan 3, 2019

Please open a PR in rom-sql which changes this, preferably with tests. You could temporary target this branch in the rom-sql's Gemfile, once it's merged we can go back to depending on master.

Before I needed to push here the update to the schema DSL. I added some comments to the code to make it clear some intentions.

core/lib/rom/schema.rb Outdated Show resolved Hide resolved
@waiting-for-dev waiting-for-dev changed the title WIP: Decouple attribute alias from type definition [WIP] Decouple attribute alias from type definition Jan 3, 2019
@waiting-for-dev
Copy link
Collaborator Author

I'm stuck with the last change needed in order to have the new feature available.

We need the following to work:

schema(infer: true) do
  attribute :first_name, alias: :name
end

Attribute inferrer checks whether an attribute has been already defined by the user comparing names. However, :name is an option in meta, which is something related with the type, and with this new DSL feature we would be defining the attribute options but not the type. Looks like we should also move :name to be an attribute option?

@solnic
Copy link
Member

solnic commented Jan 4, 2019

Looks like we should also move :name to be an attribute option?

Yes, this makes perfect sense. My understanding is that first and foremost we're moving attribute-related options to the new options hash, instead of type meta. So :name should be moved there too.

@waiting-for-dev
Copy link
Collaborator Author

rom/core/lib/rom/schema.rb

Lines 140 to 148 in a9a9f2d

def self.attributes(attributes, attr_class)
attributes.map do |attr|
if attr.is_a?(Hash)
attr_class.new(attr[:type], attr.fetch(:options, EMPTY_HASH))
else
attr_class.new(attr)
end
end
end

Do you think that backward compatibility issues with plugins could arise? Changing :name to be an option would make the attribute information extracted from the DSL to be always a Hash.

@solnic
Copy link
Member

solnic commented Jan 4, 2019

Changing :name to be an option would make the attribute information extracted from the DSL to be always a Hash.

Not sure if I follow, wdym by the attribute information extracted from the DSL?

@waiting-for-dev
Copy link
Collaborator Author

Not sure if I follow, wdym by the attribute information extracted from the DSL?

I pushed a commit with the changes. I'll try to explain it in a review, alongside other comments.

My understanding is that first and foremost we're moving attribute-related options to the new options hash, instead of type meta.

So, do you think we should move everything from meta to options? :primary_key, :source...

changeset/spec/spec_helper.rb Show resolved Hide resolved
core/lib/rom/attribute.rb Show resolved Hide resolved
core/lib/rom/attribute.rb Show resolved Hide resolved
core/lib/rom/schema.rb Show resolved Hide resolved
# @return [Hash] A hash with `:type` and `:options` keys.
#
# @api private
def self.build_attribute_info(type, options)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defining this method here, because it seems that the attribute class is decoupled from the attribute representation (Schema.define accepts the attribute class as argument). Otherwise, maybe we could work with Attribute#ast, which would be neater.


if type
if args.empty?
::ROM::SQL::Function.new(type, name: :not_used, schema: schema)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest, not sure what is happening here. But seems that name: is not needed at all.

Copy link
Collaborator Author

@waiting-for-dev waiting-for-dev Jan 7, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm specially interested in this line. I'm trying to adapt rom-sql to the new changes, and I see that attributes without a name are created in a lot of places. So, should we make :name optional?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:name is required, in case of functions it's just added later, because you create a function first, then you give it a name via #as, ie str::upper(:name).as(:first_name). This is not pretty from a design pov of course, because we should probably introduce an intermediate object that represents a wip-function, and its #as method would return the actual function. This is a refactoring that could be done later if we decide it's a good idea.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. I guess that in the meantime, before facing that refactor, the only possibility is to have :name as optional in its dry-initializer definition.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now let's do this:

# FIXME: refactor so that it returns an intermediate function representation which
#              needs to a name
::ROM::SQL::Function.new(type, name: :__anonymous__, schema: schema)

I'll make sure this gets addressed before the release.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@solnic I was now re-adapting rom-sql to new changes, and I was adding name: :__anonymous everywhere a function is created without name. The problem is that AS "__anonymous__" gets added to a lot of resulting SQL queries.

Maybe we should tackle that refactor first... or just stick with nil names in the meanwhile.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh damn :/ My bad, apologies, I didn't think this through. Could you try redefining option :name, optional: true, type: Types::Symbol and see if that works?

@waiting-for-dev
Copy link
Collaborator Author

The previous commit added the end goal feature of being able to do:

schema(infer: true) do
  attribute :name, alias: :username
end

Besides any change required to the API or the implementation, it would just remain to be done moving other meta's to options, if we think it is a good idea.

core/lib/rom/attribute.rb Show resolved Hide resolved

if type
if args.empty?
::ROM::SQL::Function.new(type, name: :not_used, schema: schema)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:name is required, in case of functions it's just added later, because you create a function first, then you give it a name via #as, ie str::upper(:name).as(:first_name). This is not pretty from a design pov of course, because we should probably introduce an intermediate object that represents a wip-function, and its #as method would return the actual function. This is a refactoring that could be done later if we decide it's a good idea.

@solnic
Copy link
Member

solnic commented Jan 8, 2019

Besides any change required to the API or the implementation, it would just remain to be done moving other meta's to options, if we think it is a good idea.

Let's move the rest in a separate PR. This is getting big enough already.

waiting-for-dev added a commit to waiting-for-dev/rom-sql that referenced this pull request Jan 13, 2019
@waiting-for-dev
Copy link
Collaborator Author

Ok, I re-adapted rom-sql to work with :name being an (optional) option (pending refactor here about functions being created without a name).

Tell me if you want some other codeclimate offense to be addressed. About the merge_attributes method, I tried to optimize its performance so it is a bit obscure, but I think it pays off. However, one option to simplify it would be moving the type_lookup method as a helper class method in Attribute or Schema.

Also in this method and in general for all rom and rom-sql, I think that a very nice refactor would be to create an equivalent method to Attribute#with but working with params (type in this case). A lot of errors re-adapting for the changes aroused from calling #new to Attribute without copying both params and options. Even better, I think that #with and the new method should be created in dry-initializer.

Besides any other more than welcome code review, I think this PR is ready to go 😄

@solnic solnic added this to the v5.0.0 milestone Mar 27, 2019
@@ -25,7 +25,7 @@

it 'supports custom types' do
schema_dsl.use :timestamps, type: ROM::Types::Date
dt_attribute = -> name { ROM::Attribute.new(ROM::Types::Date.meta(name: name, source: relation)) }
dt_attribute = -> name { ROM::Attribute.new(ROM::Types::Date.meta(source: relation), name: name) }

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [102/100]

@@ -8,7 +8,7 @@
subject(:schema) { schema_dsl.call }

it 'adds timestamp attributes' do
ts_attribute = -> name { ROM::Attribute.new(ROM::Types::Time.meta(name: name, source: relation)) }
ts_attribute = -> name { ROM::Attribute.new(ROM::Types::Time.meta(source: relation), name: name) }

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [102/100]


types.each do |type|
specify do
expect(to_attr.(type).to_ast).to eql([:attribute, [:id, type.to_ast, {}]])
expect(to_attr.(type).to_ast).to eql([:attribute, [:id, type.to_ast, {alias: nil}]])

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Layout/SpaceInsideHashLiteralBraces: Space inside { missing.
Layout/SpaceInsideHashLiteralBraces: Space inside } missing.

type: define_type(id, source: source),
options: { name: name }
}
end)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Layout/MultilineMethodCallBraceLayout: Closing method call brace must be on the line after the last argument when opening brace is on a separate line from the first argument.

@@ -27,7 +27,7 @@
insert(id: 2, name: 'Joe')
insert(id: 1, name: 'Jane')

order(*rel_klass.schema.primary_key.map { |t| t.meta[:name] })
order(*rel_klass.schema.primary_key.map { |t| t.name })

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/SymbolProc: Pass &:name as an argument to map instead of a block.

@@ -76,4 +78,4 @@ def warn(str)
end

config.include(MapperRegistry)
end
end

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Layout/TrailingBlankLines: Final newline missing.

{
user_id: define_attr_info(:Integer, { name: :user_id }, primary_key: true),
group_id: define_attr_info(:Integer, { name: :group_id }, primary_key: true),
name_id: define_attr_info(:String, name: :name ),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Layout/SpaceInsideParens: Space inside parentheses detected.
Style/TrailingCommaInHashLiteral: Avoid comma after the last item of a hash.

[[:attribute, [:id, [:nominal, [Integer, {}]], {}]],
[:attribute, [:name, [:nominal, [String, {}]], {}]]]]])
[[:attribute, [:id, [:nominal, [Integer, {}]], {alias: nil}]],
[:attribute, [:name, [:nominal, [String, {}]], {alias: nil}]]]]])

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Layout/SpaceInsideHashLiteralBraces: Space inside { missing.
Layout/SpaceInsideHashLiteralBraces: Space inside } missing.
Layout/MultilineArrayBraceLayout: Closing array brace must be on the line after the last array element when opening brace is on a separate line from the first array element.

schema = ROM::Schema.define(:name, attributes: attrs.values)

expect(schema.to_ast).
to eql([:schema, [
:name,
[[:attribute, [:id, [:nominal, [Integer, {}]], {}]],
[:attribute, [:name, [:nominal, [String, {}]], {}]]]]])
[[:attribute, [:id, [:nominal, [Integer, {}]], {alias: nil}]],

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Layout/SpaceInsideHashLiteralBraces: Space inside { missing.
Layout/SpaceInsideHashLiteralBraces: Space inside } missing.

schema = ROM::Schema.define(:name, attributes: attrs.values)

expect(schema.to_h).to eql(attrs)
expect(schema.to_h).to eql({ id: ROM::Attribute.new(ROM::Types::Integer, name: :id),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/BracesAroundHashParameters: Redundant curly braces around a hash parameter.

@solnic solnic changed the title [WIP] Decouple attribute alias from type definition Decouple attribute alias from type definition Apr 17, 2019
@@ -11,7 +11,7 @@

it 'returns projected schema with renamed attributes using provided prefix' do
expect(wrapped.map(&:alias)).to eql(%i[users_id users_name])
expect(wrapped.map { |attr| attr.meta[:name] }).to eql(%i[id name])
expect(wrapped.map { |attr| attr.name }).to eql(%i[id name])

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/SymbolProc: Pass &:name as an argument to map instead of a block.

@@ -11,6 +11,6 @@

it 'returns projected schema with renamed attributes using provided prefix' do
expect(prefixed.map(&:alias)).to eql(%i[user_id user_name])
expect(prefixed.map { |attr| attr.meta[:name] }).to eql(%i[id name])
expect(prefixed.map { |attr| attr.name }).to eql(%i[id name])

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/SymbolProc: Pass &:name as an argument to map instead of a block.

@@ -30,12 +30,13 @@
end

let(:attributes_inferrer) do
proc { [ [define_attribute(:name, :String)], %i(id age) ] }
proc { [ [define_attribute(:String, name: :name)], %i(id age) ] }

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Layout/SpaceInsideArrayLiteralBrackets: Do not use space inside array brackets.
Style/PercentLiteralDelimiters: %i-literals should be delimited by [ and ].

end

specify do
expect(attr.inspect).to eql("#<ROM::Attribute[TrueClass | FalseClass] name=:admin alias=:adm>")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/StringLiterals: Prefer single-quoted strings when you don't need string interpolation or special symbols.
Metrics/LineLength: Line is too long. [103/100]

end

specify do
expect(attr.inspect).to eql("#<ROM::Attribute[TrueClass | FalseClass] name=:admin alias=nil>")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/StringLiterals: Prefer single-quoted strings when you don't need string interpolation or special symbols.
Metrics/LineLength: Line is too long. [102/100]

end
end

schema = Test::Users.schema_proc.call

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Layout/IndentationConsistency: Inconsistent indentation detected.

schema do
attribute :name, alias: :username
end
end

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Layout/EndAlignment: end at 106, 4 is not aligned with class at 102, 5.


it 'allows setting attribute options while still leaving type undefined' do
class Test::Users < ROM::Relation[:memory]
schema do

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Layout/IndentationWidth: Use 2 (not 1) spaces for indentation.

end

it 'allows setting attribute options while still leaving type undefined' do
class Test::Users < ROM::Relation[:memory]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Layout/IndentationWidth: Use 2 (not 3) spaces for indentation.
Style/Documentation: Missing top-level class documentation comment.
Style/ClassAndModuleChildren: Use nested module/class definitions instead of compact style.

@@ -83,6 +86,31 @@ class Test::Posts < ROM::Relation[:memory]
expect(schema.foreign_key(:users)).to be(schema[:author_id])
end

it 'allows setting attribute options' do
class Test::Users < ROM::Relation[:memory]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/Documentation: Missing top-level class documentation comment.
Style/ClassAndModuleChildren: Use nested module/class definitions instead of compact style.

@solnic
Copy link
Member

solnic commented Apr 17, 2019

@waiting-for-dev thanks again for working on this. I rebased it and cleaned up commits. I'll merge once CI is green.

@solnic solnic merged commit 867c95d into master Apr 18, 2019
@solnic solnic deleted the mb_alias_option branch April 18, 2019 10:21
@waiting-for-dev
Copy link
Collaborator Author

waiting-for-dev commented Apr 18, 2019

Hey @solnic I was going to tackle CI offenses right now, but it seems you just merged it at the same exact time?

@solnic
Copy link
Member

solnic commented Apr 18, 2019

@waiting-for-dev feel free to push such fixes directly to master

@waiting-for-dev
Copy link
Collaborator Author

Ok @solnic , thanks for your feedback and your support with this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants