├── .travis.yml ├── src ├── objectify.cr └── objectify │ ├── version.cr │ ├── to_sql.cr │ └── to_object.cr ├── spec ├── spec_helper.cr └── objectify_spec.cr ├── shard.yml ├── .gitignore ├── .editorconfig ├── LICENSE └── README.md /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | -------------------------------------------------------------------------------- /src/objectify.cr: -------------------------------------------------------------------------------- 1 | require "./objectify/*" 2 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/objectify" 3 | -------------------------------------------------------------------------------- /src/objectify/version.cr: -------------------------------------------------------------------------------- 1 | module Objectify 2 | VERSION = "0.1.0" 3 | end 4 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: objectify 2 | version: 0.2.0 3 | 4 | authors: 5 | - drum445 6 | 7 | crystal: 1.2.0 8 | 9 | license: MIT 10 | -------------------------------------------------------------------------------- /spec/objectify_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Objectify do 4 | # TODO: Write tests 5 | 6 | it "works" do 7 | false.should eq(true) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | *.dwarf 6 | 7 | # Libraries don't need dependency lock 8 | # Dependencies will be locked in application that uses them 9 | /shard.lock 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 drum445 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/objectify/to_sql.cr: -------------------------------------------------------------------------------- 1 | require "db" 2 | 3 | module Objectify 4 | def self.to_sql(script : String, object) 5 | fields = self.get_fields(script) 6 | 7 | # create the args which is the object's attr value 8 | args = [] of DB::Any 9 | fields.each do |field| 10 | args.push(self.send(object, field)) 11 | end 12 | 13 | # prepare the script for db making sure to use the built in 14 | # parameterised queries to avoid sql injection 15 | fields.each do |field| 16 | script = script.gsub "{#{field}}", "?" 17 | end 18 | 19 | return script, args 20 | end 21 | 22 | # mimic send method in ruby to get the value of the object's attr 23 | private def self.send(obj : T, attr) forall T 24 | {% for ivar in T.instance_vars %} 25 | if {{ivar.stringify}} == attr 26 | return obj.@{{ivar.id}} 27 | end 28 | {% end %} 29 | end 30 | 31 | # create a string array of fields in sql script 32 | # these will be the values inbetween the {} 33 | private def self.get_fields(script : String) 34 | fields = Array(String).new 35 | found = false 36 | field = "" 37 | script.split("").each do |char| 38 | if char == "{" 39 | found = true 40 | next 41 | end 42 | 43 | if char == "}" 44 | fields.push(field) 45 | field = "" 46 | found = false 47 | end 48 | 49 | if found 50 | field += char 51 | end 52 | end 53 | 54 | fields 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /src/objectify/to_object.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | # allow Object.from_rs(rs), will work with arrays and single objects 4 | def Object.from_rs(rs) : self 5 | if self.is_a?(Array.class) 6 | new_array = self.new 7 | object_type = typeof(new_array[0]) 8 | return Objectify.to_many(rs, object_type) 9 | else 10 | return Objectify.to_one(rs, self) 11 | end 12 | end 13 | 14 | module Objectify 15 | module Mappable 16 | # currently uses JSON::Serializable to create custom initialiser 17 | macro included 18 | include JSON::Serializable 19 | end 20 | end 21 | 22 | # T.class so we can get type variable of the object and create an array 23 | def self.to_one(rs, object_type) 24 | self.build(rs, object_type)[0] 25 | end 26 | 27 | def self.to_many(rs, object_type) 28 | self.build(rs, object_type) 29 | end 30 | 31 | private def self.build(rs, object_type : T.class) forall T 32 | result = Array(T).new 33 | rs.each do 34 | temp = self.transform_one(rs, object_type) 35 | result.push(temp) 36 | end 37 | 38 | result 39 | end 40 | 41 | # transform each row to l 42 | private def self.transform_one(rs, object_type) 43 | col_names = rs.column_names 44 | 45 | # build a JSON string using our columns and fields 46 | string = JSON.build do |json| 47 | json.object do 48 | col_names.each do |col| 49 | json_encode_field json, col, rs.read 50 | end 51 | end 52 | end 53 | 54 | # create an object using the JSON created above 55 | begin 56 | object = object_type.from_json(string) 57 | rescue ex 58 | message = parse_error(ex.message) 59 | raise Exception.new(message) 60 | end 61 | 62 | # return object 63 | return object 64 | end 65 | 66 | # build a json object for the field 67 | private def self.json_encode_field(json, col, value) 68 | case value 69 | when Bytes 70 | # custom json encoding. Avoid extra allocations. 71 | json.field col do 72 | json.array do 73 | value.each do |e| 74 | json.scalar e 75 | end 76 | end 77 | end 78 | when Time::Span 79 | # Time Span isn't supported 80 | else 81 | # encode the value as their built in json format. 82 | json.field col do 83 | value.to_json(json) 84 | end 85 | end 86 | end 87 | 88 | private def self.parse_error(message) 89 | begin 90 | field = message.to_s.split(": ")[1] 91 | return "Result set is missing required field: #{field}" 92 | rescue 93 | field = message.to_s.split("#")[1].split(" ")[0] 94 | return "Invalid data type for field: #{field}" 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # objectify 2 | 3 | Crystal micro-orm library, similar to what dapper supplies for .NET Core 4 | Main features: 5 | SQL result sets to be transformed into an object or array of object 6 | SQL scripts to be injected with the correct variables from the passed object 7 | 8 | For the mapping to work (rs -> object) the column name in the result set must match the class' attribute name 9 | 10 | Uses the JSON library from stdlib to allow from_json to be used to prevent the need for messy custom initializers on each class. 11 | Simply ```include Objectify::Mappable``` in classes that will be created from a SQL result set 12 | This include is not needed if your class is using JSON.mapping 13 | 14 | ## Installation 15 | 16 | Add this to your application's `shard.yml`: 17 | 18 | ```yaml 19 | dependencies: 20 | objectify: 21 | github: drum445/objectify 22 | ``` 23 | 24 | ## Usage 25 | 26 | ```crystal 27 | require "objectify" 28 | 29 | # include objectify's mappable in your class 30 | class Foo 31 | include Objectify::Mappable 32 | end 33 | ``` 34 | 35 | ##### Result set to object (to_one) 36 | ```crystal 37 | require "db" 38 | require "mysql" 39 | require "objectify" 40 | 41 | # Your class that is to be built from SQL, will raise exception if: 42 | # A non-nillable field is not in the result set 43 | # Or a field in the result set does not match the attribute's type 44 | class Note 45 | include Objectify::Mappable 46 | property note_id : String 47 | property content : String 48 | property likes : Int64 49 | property updated : Time 50 | 51 | def initialize(@note_id, @content, @likes, @updated) 52 | end 53 | end 54 | 55 | db = DB.open "mysql://root:password@localhost:3306/test" 56 | 57 | db.query "SELECT '123' as note_id, 'hello' as content, 4 as likes, NOW() as updated FROM DUAL;" do |rs| 58 | note = Objectify.to_one(rs, Note) 59 | 60 | puts note # => Note Object 61 | end 62 | ``` 63 | 64 | ##### Result set to array of object (to_many) 65 | ```crystal 66 | require "db" 67 | require "mysql" 68 | require "objectify" 69 | 70 | # Your class that is to be built from SQL 71 | class Note 72 | include Objectify::Mappable 73 | property note_id : String 74 | property content : String 75 | property likes : Int64 76 | property updated : Time? # nillable as it is not in the result set 77 | end 78 | 79 | db = DB.open "mysql://root:password@localhost:3306/test" 80 | 81 | db.query "SELECT '123' as note_id, 'hello' as content, 4 as likes FROM DUAL 82 | UNION ALL 83 | SELECT '444', 'asd', 66 FROM DUAL;" do |rs| 84 | notes = Objectify.to_many(rs, Note) 85 | 86 | puts notes # => Array of Note 87 | end 88 | 89 | ``` 90 | 91 | #### Alternative syntax 92 | I prefer using the explicit "to_one" or "to_many" methods however if you like you can use Object.from_rs(rs) instead 93 | This will work with arrays or single objects 94 | ```crystal 95 | db.query "SELECT '123' as id, 'drum445' as username FROM DUAL;" do |rs| 96 | person = Person.from_rs(rs) 97 | people = Array(Person).from_rs(rs) 98 | 99 | puts person # => Person Object 100 | puts people # => Array of Person 101 | end 102 | ``` 103 | 104 | ##### Custom Properties 105 | As we are using the JSON::Serializable module we can take advantage of this: 106 | https://crystal-lang.org/api/0.25.1/JSON/Serializable.html 107 | Allowing us to have different column names in the result set map to our class attributes 108 | 109 | ```crystal 110 | 111 | require "db" 112 | require "mysql" 113 | require "objectify" 114 | 115 | class Person 116 | include Objectify::Mappable 117 | 118 | @[JSON::Field(key: "id", emit_null: true)] 119 | property person_id : String 120 | property username : String 121 | property created : Time? 122 | 123 | def initialize(@person_id : String, @username) 124 | end 125 | end 126 | 127 | db = DB.open "mysql://root:password@localhost:3306/test" 128 | 129 | # id will map to Person.person_id due to JSON::Field settings 130 | db.query "SELECT '123' as id, 'drum445' as username FROM DUAL;" do |rs| 131 | person = Objectify.to_one(rs, Person) 132 | 133 | puts person # => Person Object 134 | end 135 | ``` 136 | 137 | #### Inserting object into DB 138 | ```crystal 139 | require "db" 140 | require "mysql" 141 | require "objectify" 142 | 143 | db = DB.open "mysql://root:password@localhost:3306/test" 144 | 145 | class Person 146 | property id : String 147 | property username : String 148 | 149 | def initialize(@id, @username) 150 | end 151 | end 152 | 153 | # create a person object and insert it into the db 154 | p = Person.new("1234", "test") 155 | query, args = Objectify.to_sql("INSERT INTO person (person_id, username) VALUES({id}, {username})", p) 156 | db.exec query, args 157 | ``` 158 | ## Error Handling 159 | Checks for required fields (non nillable) ```Result set is missing required field: foo``` 160 | Checks for correct data type: ```Invalid data type for field: foo``` 161 | 162 | 163 | ## Contributors 164 | 165 | - [drum445](https://github.com/drum445) ed - creator, maintainer 166 | --------------------------------------------------------------------------------