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

Add Serializable #6

Merged
merged 6 commits into from
Feb 25, 2025
Merged

Add Serializable #6

merged 6 commits into from
Feb 25, 2025

Conversation

danini-the-panini
Copy link
Owner

resolves #2

@danini-the-panini danini-the-panini added the enhancement New feature or request label Jan 31, 2025
@danini-the-panini danini-the-panini self-assigned this Jan 31, 2025
@danini-the-panini
Copy link
Owner Author

@BigBoyBarney ok I've got an initial working solution, do you mind playing around with it and let me know if it works? One limitation is to_kdl returns a single node with the name of the class, not sure how else to turn it into a document

@BigBoyBarney
Copy link
Contributor

BigBoyBarney commented Feb 22, 2025

@danini-the-panini thank you for your hard work!

I've been playing around with it for a bit, and got it working with varying degrees of success.

I think the underlying API is pretty reasonable, very creative usage of annotations! I left a few comments on the code!

@BigBoyBarney
Copy link
Contributor

BigBoyBarney commented Feb 22, 2025

The following document

doc = KDL.parse <<-KDL
    Collected-Pokemon {
      Snorlax "The Sleepy" status=asleep pokemon-level=10
      Jigglypuff "The Cute" status=awake pokemon-level=1
    }

    Trainers {
      trainer Sylphrena
      trainer DaniniThePanini
    }
    KDL

can be parsed with

class Info
  include KDL::Serializable

  @[KDL::Child(name: "Collected-Pokemon")]
  property collected_pokemon : Pokemon

  @[KDL::Child(name: "Trainers")]
  property trainers : Trainers
end

class Pokemon
  include KDL::Serializable

  @[KDL::Child(name: "Snorlax")]
  property snorlax : PokemonInfo

  @[KDL::Child(name: "Jigglypuff")]
  property jigglypuff : PokemonInfo
end

class PokemonInfo
  include KDL::Serializable

  @[KDL::Argument]
  property nick : String

  @[KDL::Property]
  property status : String

  @[KDL::Property(name: "pokemon-level")]
  property level : Int32
end

class Trainers
  include KDL::Serializable

  @[KDL::Children(name: "trainer")]
  property trainers : Array(Trainer)
end

class Trainer
  include KDL::Serializable

  @[KDL::Argument]
  property name : String
end

obj = Info.from_kdl doc

Which is really cool! However, as you can see, to get the actual array of trainers, one would need to call obj.trainers.trainers. Is it possible to somehow unwrap a child node and its children, so that I could do

  @[KDL::Child(name: "Trainers", unwrap: "children", children_name: "trainer")]
  property trainers : Array(String)

Or something along these lines. :D

Comment on lines 49 to 50
def initialize(@first, @second, @numbers, @foo, @bar, @map, @arg, @args, @props, @norf, @things)
end
Copy link
Contributor

Choose a reason for hiding this comment

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

This is technically not necessary and can be omitted altogether :D

{% elsif value[:unwrap] == "properties" %}
%var{name} = node.child({{name.stringify}}).properties.transform_values { |v, _| convert(v.value, {{ value[:type].type_vars[1] }}) }
{% else %}
%var{name} = {{value[:type]}}.from_kdl(node.child({{name.stringify}}))
Copy link
Contributor

@BigBoyBarney BigBoyBarney Feb 22, 2025

Choose a reason for hiding this comment

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

- %var{name} = {{value[:type]}}.from_kdl(node.child({{name.stringify}}))
+ %var{name} = {{value[:type]}}.from_kdl(node.child({{value[:name]}}))

We have to use value[:name] otherwise the name property will have no effect in certain cases.

nilable: ivar.type.nilable?,
type: ivar.type
}
all_properties[ivar.id] = other_children[ivar.id]
Copy link
Contributor

@BigBoyBarney BigBoyBarney Feb 22, 2025

Choose a reason for hiding this comment

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

other_children doesn't exist, so if the code gets here, it will throw a compile time error 100% of the time (which happens whenever a property has no annotation)

@BigBoyBarney
Copy link
Contributor

BigBoyBarney commented Feb 22, 2025

I just discovered that including KDL::Serializable makes it impossible to new up an instance, even if it has default values.

class DB
  include KDL::Serializable

  @[KDL::Argument(name: "Version")]
  property version = 1

  @[KDL::Argument(name: "Path")]
  property path = "~/.local/share/anime_database.db"
end


 43 | property db = DB.new
                       ^--
Error: wrong number of arguments for 'Shisho::Util::DB.new' (given 0, expected 1)

Overloads are:
 - Shisho::Util::DB.new(node : ::KDL::Node)
 - Shisho::Util::DB.new(doc : ::KDL::Document)

This can be circumvented by defining an empty initialize method.

{% for name, value in child_annos %}
# child
{% if value[:unwrap] == "argument" %}
%var{name} = convert(node.arg({{name.stringify}}), {{ value[:type] }})
Copy link
Contributor

Choose a reason for hiding this comment

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

Same as below

{% if value[:unwrap] == "argument" %}
%var{name} = convert(node.arg({{name.stringify}}), {{ value[:type] }})
{% elsif value[:unwrap] == "arguments" %}
%var{name} = node.args({{name.stringify}}).map { |v| convert(v, {{ value[:type].type_vars[0] }}) }
Copy link
Contributor

Choose a reason for hiding this comment

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

Same as below

{% elsif value[:unwrap] == "arguments" %}
%var{name} = node.args({{name.stringify}}).map { |v| convert(v, {{ value[:type].type_vars[0] }}) }
{% elsif value[:unwrap] == "properties" %}
%var{name} = node.child({{name.stringify}}).properties.transform_values { |v, _| convert(v.value, {{ value[:type].type_vars[1] }}) }
Copy link
Contributor

Choose a reason for hiding this comment

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

Same as below

Comment on lines +79 to +81
obj.things[0].value.should eq "foo"
obj.things[1].value.should eq "bar"
obj.things[2].value.should eq "baz"
Copy link
Contributor

Choose a reason for hiding this comment

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

This syntax is quite common, e.g.:

some-paths {
  path "path/to/something"
  path "path/to/something/else"
}

Having to call #value on it is a bit cumbersome. Directly newing up Array(String) for such cases would be nicer. This is related to my other comment about arrays in serializable.cr :D

Comment on lines 300 to 302
def Object.from_kdl(doc)
new doc
end
Copy link
Contributor

Choose a reason for hiding this comment

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

This should not be global, but should be in the module instead, as we can only call #from_kdl on Types that include ::KDL::Serializable

Comment on lines 304 to 308
def Array.from_kdl(node : ::KDL::Node)
node.arguments.map do |arg|
arg.value
end
end
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think this is needed?

type: ivar.type,
converter: ann[:converter],
presence: ann[:presence]
}
Copy link
Contributor

Choose a reason for hiding this comment

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

unwrap: ann[:unwrap]

together with the change further down at #children enables child array unwrapping

Comment on lines 237 to 238
%var{name} = node.children.select { |n| n.name == {{value[:name]}} }.map { |n| {{value[:type].type_vars[0]}}.from_kdl(n) }
%found{name} = true
Copy link
Contributor

Choose a reason for hiding this comment

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

replace with

# children
{% if value[:unwrap] == "argument" %}
  %var{name} = node.children.select { |n| n.name == {{value[:name]}} }.map { |n| convert(n.arguments[0].value, {{value[:type].type_vars[0]}}) }
{% else %}
 %var{name} = node.children.select { |n| n.name == {{value[:name]}} }.map { |n| {{value[:type].type_vars[0]}}.from_kdl(n) }
{% end %}
%found{name} = true

To allow argument unwrapping for example. Can be extended to allow properties and multiples arguments too

@BigBoyBarney
Copy link
Contributor

Code comments are getting a bit out of hand, especially without permission to propose changes, so I propose that you merge whatever you feel like merging, and I'll PR the suggested changes :D

@danini-the-panini
Copy link
Owner Author

danini-the-panini commented Feb 24, 2025

@BigBoyBarney I've added your suggestions, and I've add a new unwrap: "dash_vals" that lets you do e.g.

things {
  - "foo"
  - "bar"
  - "baz"
}
@[KDL::Child(unwrap: "dash_vals")
property things : Array(String)

@BigBoyBarney
Copy link
Contributor

BigBoyBarney commented Feb 24, 2025

Looks overall good to me!
Arbitrary children + argument and children + property unwrapping is missing, but I will PR that once this is merged. I already have it implemented locally as I need it for my app's config.

This would effectively be @[KDL::Children] alternative to @[KDL::Child(unwrap: "dash_vals") and and allow for any unwrapping.

As an example, see provisional KDL config file and the corresponding class

The only other thing worth mentioning is my 2nd to last comment in serializable.cr:

def Object.from_kdl(doc)
  new doc
end

should not be defined globally. This has to be in the included macro, otherwise this defines a #from_kdl method on all types, everywhere.

@danini-the-panini
Copy link
Owner Author

Ok, I've added more of the unwrap options you suggested

@BigBoyBarney
Copy link
Contributor

Looks good to me! Parses my app's config file out of the box now.

Excellent work, thank you for your time and effort!

Comment on lines +63 to +69
def self.new(node : ::KDL::Node)
new_from_kdl_node(node)
end

def self.new(doc : ::KDL::Document)
new_from_kdl_node(::KDL::Node.new("__root", children: doc))
end
Copy link
Contributor

@BigBoyBarney BigBoyBarney Feb 25, 2025

Choose a reason for hiding this comment

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

An additional initialize method would be needed to allow newing up fully defined classes / structs.

def initialize
end

Otherwise something like

  class Mappings
    include KDL::Serializable

    @[KDL::Property(name: "update-frequency")]
    property update_frequency = 1

    @[KDL::Property(name: "last-update")]
    property last_update = 0
  end

will complain that Mappings.new needs an argument.

Error: wrong number of arguments for 'Shisho::Util::Mappings.new' (given 0, expected 1)

Overloads are:
- Shisho::Util::DB.new(node : ::KDL::Node)
- Shisho::Util::DB.new(doc : ::KDL::Document)

Copy link
Contributor

Choose a reason for hiding this comment

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

Actually this breaks the spec, so will have to figure something else out 🤔

Copy link
Owner Author

@danini-the-panini danini-the-panini Feb 25, 2025

Choose a reason for hiding this comment

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

Yeah, we can't have a default initializer unless all properties have a default value. Alternatively, we ditch the new and just do everything through from_kdl

Just realiased this has to go through new anyway because it is constructing an object.

Users of Serializable will just have to add their own explicit constructors then

Copy link
Contributor

Choose a reason for hiding this comment

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

Any way would work for me :D Currently I'm manually defining a default initializer in all fully defined classes to make it compile, but an automatic solution would be cool

Copy link
Owner Author

Choose a reason for hiding this comment

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

I'll see what I can do. If all ivars have default values I can auto-define a default initializer

Copy link
Owner Author

Choose a reason for hiding this comment

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

ok not sure how to get it to work unfortunately :/

Copy link
Contributor

@BigBoyBarney BigBoyBarney Feb 25, 2025

Choose a reason for hiding this comment

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

I was incorrectly under the assumption that ::JSON::Serializable did it automatically. It does not and requires a manual def initialize; end as well.

Never mind then :D

If you ask me, I think the PR is ready to be merged in that case. Excellent work! 👏

Copy link
Owner Author

Choose a reason for hiding this comment

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

Thanks for all the help!

@danini-the-panini danini-the-panini merged commit b1b2a9e into main Feb 25, 2025
1 check passed
@danini-the-panini danini-the-panini deleted the serializable branch February 25, 2025 13:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Serializable implementation
2 participants