Overview

Packages

  • tipy

Classes

  • Tipy
  • TipyApp
  • TipyCli
  • TipyCliSession
  • TipyConfig
  • TipyController
  • TipyCookie
  • TipyDAO
  • TipyEnv
  • TipyFlash
  • TipyInflector
  • TipyInput
  • TipyIOWrapper
  • TipyLogger
  • TipyMailer
  • TipyModel
  • TipyOutput
  • TipyRequest
  • TipySession
  • TipyTestCase
  • TipyTestRunner
  • TipyView

Exceptions

  • AssertionFailedException
  • CompileErrorException
  • CompileWarningException
  • CoreErrorException
  • CoreWarningException
  • DeprecatedException
  • NoMethodException
  • NoticeException
  • ParseException
  • RecoverableErrorException
  • StrictException
  • TipyDaoException
  • TipyException
  • TipyModelException
  • TipyRollbackException
  • TipyValidationException
  • UserDeprecatedException
  • UserErrorException
  • UserNoticeException
  • UserWarningException
  • WarningException
  • Overview
  • Package
  • Class
  • Deprecated
  • Todo
   1:    2:    3:    4:    5:    6:    7:    8:    9:   10:   11:   12:   13:   14:   15:   16:   17:   18:   19:   20:   21:   22:   23:   24:   25:   26:   27:   28:   29:   30:   31:   32:   33:   34:   35:   36:   37:   38:   39:   40:   41:   42:   43:   44:   45:   46:   47:   48:   49:   50:   51:   52:   53:   54:   55:   56:   57:   58:   59:   60:   61:   62:   63:   64:   65:   66:   67:   68:   69:   70:   71:   72:   73:   74:   75:   76:   77:   78:   79:   80:   81:   82:   83:   84:   85:   86:   87:   88:   89:   90:   91:   92:   93:   94:   95:   96:   97:   98:   99:  100:  101:  102:  103:  104:  105:  106:  107:  108:  109:  110:  111:  112:  113:  114:  115:  116:  117:  118:  119:  120:  121:  122:  123:  124:  125:  126:  127:  128:  129:  130:  131:  132:  133:  134:  135:  136:  137:  138:  139:  140:  141:  142:  143:  144:  145:  146:  147:  148:  149:  150:  151:  152:  153:  154:  155:  156:  157:  158:  159:  160:  161:  162:  163:  164:  165:  166:  167:  168:  169:  170:  171:  172:  173:  174:  175:  176:  177:  178:  179:  180:  181:  182:  183:  184:  185:  186:  187:  188:  189:  190:  191:  192:  193:  194:  195:  196:  197:  198:  199:  200:  201:  202:  203:  204:  205:  206:  207:  208:  209:  210:  211:  212:  213:  214:  215:  216:  217:  218:  219:  220:  221:  222:  223:  224:  225:  226:  227:  228:  229:  230:  231:  232:  233:  234:  235:  236:  237:  238:  239:  240:  241:  242:  243:  244:  245:  246:  247:  248:  249:  250:  251:  252:  253:  254:  255:  256:  257:  258:  259:  260:  261:  262:  263:  264:  265:  266:  267:  268:  269:  270:  271:  272:  273:  274:  275:  276:  277:  278:  279:  280:  281:  282:  283:  284:  285:  286:  287:  288:  289:  290:  291:  292:  293:  294:  295:  296:  297:  298:  299:  300:  301:  302:  303:  304:  305:  306:  307:  308:  309:  310:  311:  312:  313:  314:  315:  316:  317:  318:  319:  320:  321:  322:  323:  324:  325:  326:  327:  328:  329:  330:  331:  332:  333:  334:  335:  336:  337:  338:  339:  340:  341:  342:  343:  344:  345:  346:  347:  348:  349:  350:  351:  352:  353:  354:  355:  356:  357:  358:  359:  360:  361:  362:  363:  364:  365:  366:  367:  368:  369:  370:  371:  372:  373:  374:  375:  376:  377:  378:  379:  380:  381:  382:  383:  384:  385:  386:  387:  388:  389:  390:  391:  392:  393:  394:  395:  396:  397:  398:  399:  400:  401:  402:  403:  404:  405:  406:  407:  408:  409:  410:  411:  412:  413:  414:  415:  416:  417:  418:  419:  420:  421:  422:  423:  424:  425:  426:  427:  428:  429:  430:  431:  432:  433:  434:  435:  436:  437:  438:  439:  440:  441:  442:  443:  444:  445:  446:  447:  448:  449:  450:  451:  452:  453:  454:  455:  456:  457:  458:  459:  460:  461:  462:  463:  464:  465:  466:  467:  468:  469:  470:  471:  472:  473:  474:  475:  476:  477:  478:  479:  480:  481:  482:  483:  484:  485:  486:  487:  488:  489:  490:  491:  492:  493:  494:  495:  496:  497:  498:  499:  500:  501:  502:  503:  504:  505:  506:  507:  508:  509:  510:  511:  512:  513:  514:  515:  516:  517:  518:  519:  520:  521:  522:  523:  524:  525:  526:  527:  528:  529:  530:  531:  532:  533:  534:  535:  536:  537:  538:  539:  540:  541:  542:  543:  544:  545:  546:  547:  548:  549:  550:  551:  552:  553:  554:  555:  556:  557:  558:  559:  560:  561:  562:  563:  564:  565:  566:  567:  568:  569:  570:  571:  572:  573:  574:  575:  576:  577:  578:  579:  580:  581:  582:  583:  584:  585:  586:  587:  588:  589:  590:  591:  592:  593:  594:  595:  596:  597:  598:  599:  600:  601:  602:  603:  604:  605:  606:  607:  608:  609:  610:  611:  612:  613:  614:  615:  616:  617:  618:  619:  620:  621:  622:  623:  624:  625:  626:  627:  628:  629:  630:  631:  632:  633:  634:  635:  636:  637:  638:  639:  640:  641:  642:  643:  644:  645:  646:  647:  648:  649:  650:  651:  652:  653:  654:  655:  656:  657:  658:  659:  660:  661:  662:  663:  664:  665:  666:  667:  668:  669:  670:  671:  672:  673:  674:  675:  676:  677:  678:  679:  680:  681:  682:  683:  684:  685:  686:  687:  688:  689:  690:  691:  692:  693:  694:  695:  696:  697:  698:  699:  700:  701:  702:  703:  704:  705:  706:  707:  708:  709:  710:  711:  712:  713:  714:  715:  716:  717:  718:  719:  720:  721:  722:  723:  724:  725:  726:  727:  728:  729:  730:  731:  732:  733:  734:  735:  736:  737:  738:  739:  740:  741:  742:  743:  744:  745:  746:  747:  748:  749:  750:  751:  752:  753:  754:  755:  756:  757:  758:  759:  760:  761:  762:  763:  764:  765:  766:  767:  768:  769:  770:  771:  772:  773:  774:  775:  776:  777:  778:  779:  780:  781:  782:  783:  784:  785:  786:  787:  788:  789:  790:  791:  792:  793:  794:  795:  796:  797:  798:  799:  800:  801:  802:  803:  804:  805:  806:  807:  808:  809:  810:  811:  812:  813:  814:  815:  816:  817:  818:  819:  820:  821:  822:  823:  824:  825:  826:  827:  828:  829:  830:  831:  832:  833:  834:  835:  836:  837:  838:  839:  840:  841:  842:  843:  844:  845:  846:  847:  848:  849:  850:  851:  852:  853:  854:  855:  856:  857:  858:  859:  860:  861:  862:  863:  864:  865:  866:  867:  868:  869:  870:  871:  872:  873:  874:  875:  876:  877:  878:  879:  880:  881:  882:  883:  884:  885:  886:  887:  888:  889:  890:  891:  892:  893:  894:  895:  896:  897:  898:  899:  900:  901:  902:  903:  904:  905:  906:  907:  908:  909:  910:  911:  912:  913:  914:  915:  916:  917:  918:  919:  920:  921:  922:  923:  924:  925:  926:  927:  928:  929:  930:  931:  932:  933:  934:  935:  936:  937:  938:  939:  940:  941:  942:  943:  944:  945:  946:  947:  948:  949:  950:  951:  952:  953:  954:  955:  956:  957:  958:  959:  960:  961:  962:  963:  964:  965:  966:  967:  968:  969:  970:  971:  972:  973:  974:  975:  976:  977:  978:  979:  980:  981:  982:  983:  984:  985:  986:  987:  988:  989:  990:  991:  992:  993:  994:  995:  996:  997:  998:  999: 1000: 1001: 1002: 1003: 1004: 1005: 1006: 1007: 1008: 1009: 1010: 1011: 1012: 1013: 1014: 1015: 1016: 1017: 1018: 1019: 1020: 1021: 1022: 1023: 1024: 1025: 1026: 1027: 1028: 1029: 1030: 1031: 1032: 1033: 1034: 1035: 1036: 1037: 1038: 1039: 1040: 1041: 1042: 1043: 1044: 1045: 1046: 1047: 1048: 1049: 1050: 1051: 1052: 1053: 1054: 1055: 1056: 1057: 1058: 1059: 1060: 1061: 1062: 1063: 1064: 1065: 1066: 1067: 1068: 1069: 1070: 1071: 1072: 1073: 1074: 1075: 1076: 1077: 1078: 1079: 1080: 1081: 1082: 1083: 1084: 1085: 1086: 1087: 1088: 1089: 1090: 1091: 1092: 1093: 1094: 1095: 1096: 1097: 1098: 1099: 1100: 1101: 1102: 1103: 1104: 1105: 1106: 1107: 1108: 1109: 1110: 1111: 1112: 1113: 1114: 1115: 1116: 1117: 1118: 1119: 1120: 1121: 1122: 1123: 1124: 1125: 1126: 1127: 1128: 1129: 1130: 1131: 1132: 1133: 1134: 1135: 1136: 1137: 1138: 1139: 1140: 1141: 1142: 1143: 1144: 1145: 1146: 1147: 1148: 1149: 1150: 1151: 1152: 1153: 1154: 1155: 1156: 1157: 1158: 1159: 1160: 1161: 1162: 1163: 1164: 1165: 1166: 1167: 1168: 1169: 1170: 1171: 1172: 1173: 1174: 1175: 1176: 1177: 1178: 1179: 1180: 1181: 1182: 1183: 1184: 1185: 1186: 1187: 1188: 1189: 1190: 1191: 1192: 1193: 1194: 1195: 1196: 1197: 1198: 1199: 1200: 1201: 1202: 1203: 1204: 1205: 1206: 1207: 1208: 1209: 1210: 1211: 1212: 1213: 1214: 1215: 1216: 1217: 1218: 1219: 1220: 1221: 1222: 1223: 1224: 1225: 1226: 1227: 1228: 1229: 1230: 1231: 1232: 1233: 1234: 1235: 1236: 1237: 1238: 1239: 1240: 1241: 1242: 1243: 1244: 1245: 1246: 1247: 1248: 1249: 1250: 1251: 1252: 1253: 1254: 1255: 1256: 1257: 1258: 1259: 1260: 1261: 1262: 1263: 1264: 1265: 1266: 1267: 1268: 1269: 1270: 1271: 1272: 1273: 1274: 1275: 1276: 1277: 1278: 1279: 1280: 1281: 1282: 1283: 1284: 1285: 1286: 1287: 1288: 1289: 1290: 1291: 1292: 1293: 1294: 1295: 1296: 1297: 1298: 1299: 1300: 1301: 1302: 1303: 1304: 1305: 1306: 1307: 1308: 1309: 1310: 1311: 1312: 1313: 1314: 
<?php
/**
 * TipyModel
 *
 * @package tipy
 */

/**
 * Thown on TipyModel errors like "Unknown property", "Unknown metod", etc...
 */
class TipyModelException extends Exception {}

/**
 * Thow this from TipyModel::validate() method on validation errors
 */
class TipyValidationException extends Exception {}

/**
 * M in MVC. TipyModel is ORM connecting objects to database
 *
 * TipyModel:
 *
 * - Represents row in a table
 * - Defines associations and hierarchies between models
 * - Validates models before they get persisted to the database
 * - Performs database operations in an object-oriented fashion
 *
 * ## Conventions
 *
 * TipyModel follows "Convention over Configuration" paradigm and tries to use as
 * little configuration as possible. Of course you can configure model-database mapping
 * in the way you wish but you will write your code much faster following TipyModel
 * conventions:
 *
 * - <b>Class Name</b> - singular, CamelCase, first letter in upper case. - **BlogPost**
 * - <b>Table Name</b> - plural, snake_case, all letters in lower case    - **blog_posts**
 * - <b>Model Properties</b> - camelCase, first letter in lower case      - **createdAt**
 * - <b>Table Fields</b> - snake_case, all letters in lower case          - **created_at**
 * - <b>Foreign Key</b> - foreign table name + "_id"                      - **author_id**
 * - <b>Primary Key</b> - is always **id**
 * - If your table has **created_at** and **updated_at** fields they will be handled
 *   automatically
 *
 * These conventions can be changed by overriding TipyModel methods:
 * {@link classNameToTableName()},
 * {@link fieldNameToAttrName()},
 * {@link tableForeignKeyFieldName()},
 * {@link classForeignKeyAttr()},
 * and constants:
 * {@link CREATED_AT},
 * {@link UPDATED_AT}
 *
 * ## Defining Models
 *
 * Let's say you have the following table
 *
 * <code>
 * create table users (
 *     id int(11),
 *     first_name varchar(255),
 *     last_name varchar(255),
 *     primary key (id)
 * );
 * </code>
 *
 * To make TipyModel from this table you simply need to extend TipyModel class
 *
 * <code>
 * class User extends TipyModel {
 * }
 * </code>
 *
 * This magically connect User class to users table (see Conventions section above)
 * gives User class a lot of useful methods and magic properties to access table
 * fields
 *
 * <code>
 * // create new User object and save it to database
 * $user = new User();
 * $user->firstName = 'John';
 * $user->lastName = 'Doe';
 * $user->save();
 *
 * // or like this
 * $user = User::create([
 *     'firstName' => 'John',
 *     'lastName'  => 'Doe'
 * ]);
 *
 * $id = $user->id;
 * $sameUser = User::load($id);
 * echo $sameUser->firstName;
 * </code>
 *
 * ## Validation
 *
 * Model-level validation is the best way to ensure that only valid data is saved
 * into database. It cannot be bypassed by end users and is convenient to test and maintain.
 *
 * Validations are run autmatically before {@link save()} and {@link update()} send
 * SQL INSERT or UPDATE queries to the database.
 *
 * To add validation to your model simply override {@link validate()} method.
 *
 * <code>
 * class User extends TipyModel {
 *    public function validate() {
 *        if (!$this->firstName) throw new TipyValidtionException('First name should not be blank!');
 *        if (!$this->lastName) throw new TipyValidtionException('Last name should not be blank!');
 *    }
 * }
 * </code>
 *
 * The common way to fail validation is to throw {@link TipyValidtionException} and then to
 * catch it in controller.
 *
 * ## Hooks
 *
 * TipyModel allows to define logic triggered before or after an alteration of the model state.
 * To do this override the following methods in your model:
 *
 * - {@link $beforeCreate()}
 * - {@link $afterCreate()}
 * - {@link $beforeUpdate()}
 * - {@link $afterUpdate()}
 * - {@link $beforeDelete()}
 * - {@link $afterDelete()}
 *
 * ## Associations
 *
 * Association is a connection between two models. By declaring associations you
 * define Primary Key-Foreign Key connection between instances of the two models,
 * and you also get a number of utility methods added to your model. Tipy supports
 * the following types of associations:
 *
 * - {@link $hasMany}
 * - {@link $hasOne}
 * - {@link $belongsTo}
 * - {@link $hasManyThrough}
 *
 * To define define model associations you need to assign values to these properties.
 * <code>
 * class User extends TipyModel {
 *    protected $hasMany = ['posts', 'comments'];
 * }
 * </code>
 *
 * Association class name is evaluated automatically by {@link TipyInflector->classify} inflection.
 * If you wan't to specify class name different from association name you can pass association 
 * options as arrays:
 *
 * <code>
 * class User extends TipyModel {
 *    protected $hasMany = [
 *        'posts' => ['class' => 'BlogPost'],
 *        'comments' => ['class' => 'BlogComment']
 *    ];
 * }
 * </code>
 *
 * ### hasMany
 *
 * A hasMany association indicates a one-to-many connection with another model.
 *
 * <code>
 * create table users (               create table blog_posts (
 *     id int(11), <---------------+      id int(11),
 *     first_name varchar(255),    |      title varchar(255),
 *     last_name varchar(255),     |      body text,
 *     primary key (id)            +----- user_id int(11),
 * );                                     primary key (id)
 *                                    );
 * </code>
 *
 * <code>
 * class User extends TipyModel {
 *    protected $hasMany = [
 *        'posts' => ['class' => 'BlogPost']
 *    );
 * }
 * </code>
 *
 * This gives User model magic property User::posts
 *
 * <code>
 * $posts = $user->posts;
 * </code>
 *
 * ### hasOne
 *
 * A hasOne association indicates a one-to-one connection with another model.
 *
 * <code>
 * create table users (               create table accounts (
 *     id int(11), <---------------+      id int(11),
 *     first_name varchar(255),    |      cc_number varchar(20),
 *     last_name varchar(255),     |      cc_expire_date varchar(5),
 *     primary key (id)            +----- user_id int(11),
 * );                                     primary key (id)
 *                                    );
 * </code>
 *
 * <code>
 * class User extends TipyModel {
 *    protected $hasOne = ['account'];
 * }
 * </code>
 *
 * This gives User model magic property User::account
 *
 * <code>
 * $ccNumber = $user->account->ccNumber;
 * </code>
 *
 * ### belongsTo
 *
 * A belongsTo association is an opposite to hasMany and hasOne
 *
 * <code>
 * create table users (               create table blog_posts (
 *     id int(11), <---------------+      id int(11),
 *     first_name varchar(255),    |      title varchar(255),
 *     last_name varchar(255),     |      body text,
 *     primary key (id)            +----- user_id int(11),
 * );                                     primary key (id)
 *                                    );
 * </code>
 *
 * <code>
 * class BlogPost extends TipyModel {
 *    protected $belongsTo = ['user'];
 * }
 * </code>
 *
 * This gives BlogPost model magic property BlogPost->user
 *
 * <code>
 * $firstName = $post->user->firstName;
 * </code>
 *
 * ### hasManyThrough
 *
 * A hasManyThrough association indicates a many-to-many connection with another model
 * through a third model.
 *
 * <code>
 * create table users (
 *     id int(11), <-------------+
 *     first_name varchar(255),  |   create table memberships (
 *     last_name varchar(255)    +------ user_id int(11).
 * );                                    id int(11),
 *                               +------ group_id int(11)
 * create table groups (         |   );
 *     id int(11), <-------------+
 *     name varchar(255)
 * );
 * </code>
 *
 * <code>
 * class User extends TipyModel {
 *    protected $hasManyThrough = [
 *        'groups' => ['class' => 'Group', 'through' => 'Membership'],
 *    );
 * }
 * </code>
 *
 * This gives User model magic property User::groups
 *
 * <code>
 * $groups = $user->groups;
 * </code>
 *

 *
 * ### Association Options
 *
 * Association options are arrays with the following keys
 *
 * - **'class'** - associated model class
 * - **'dependent'** - 'delete', 'nullify', or null -
 *   What to do with associated model rows when this model row is deleted
 * - **'conditions'** - conditions to select associated records in addition
 *   to foreign_key.
 * - **'values'** - values for conditions
 * - **'foreign_key'** - custom associated record foreign key
 * - **'through_key'** - custom second foreign key for $hasManyThrough
 *
 * <code>
 * class User extends TipyModel {
 *    protected $hasMany = [
 *        'messages' => [
 *            'class' => 'Message',
 *            'dependent' => 'delete'
 *        ],
 *        // 7 days old messages
 *        'oldMessages' => [
 *            'class' => 'Message',
 *            'conditions' => 'created_at < ?',
 *            'values' => [strtotime('-1 week')]
 *        ]
 *    ];
 * }
 * </code>
 *
 * ### Associations Cache
 *
 * Associations are cached. So if you call
 * <code>
 * $post->comments
 * </code>
 * more than once only one query will be executed (first call) and then
 * comments will always be taken from cache.
 *
 * Downside of this approach: Cache doesn't know if comments were deleted
 * or modified in the database. To reset associations cache use {@link TipyModel::reload()}
 *
 * Associations with conditions are not cached. This means that
 * <code>
 * $post->comments(['order' => 'created_at desc'])
 * </code>
 * will allways execute query
 *
 * In short always look for parethesis:
 * <code>
 * $post->comments; // is cached
 * $post->comments(...); // is not cached
 * </code>
 *
 * @todo Accept conditions and values in one array 'conditions' => ['id = ?', $id]
 * @todo Improve find() to accept arguments like Post::find('first', 'conditions' => ['created_at > ?', time()]);
 */
class TipyModel extends TipyDAO {

    const CREATED_AT = 'created_at';
    const UPDATED_AT = 'updated_at';

    /**
     * Rules to cast MySQL types to PHP types
     * @var array
     */
    protected static $mysqlToPhpTypes = [
        'char'      => 'string',
        'varchar'   => 'string',
        'binary'    => 'string',
        'varbinary' => 'string',
        'blob'      => 'string',
        'text'      => 'string',
        'enum'      => 'string',
        'set'       => 'string',
        'integer'   => 'integer',
        'int'       => 'integer',
        'smallint'  => 'integer',
        'tinyint'   => 'integer',
        'mediumint' => 'integer',
        'bigint'    => 'integer',
        'bit'       => 'integer',
        'boolean'   => 'integer',
        'decimal'   => 'float',
        'numeric'   => 'float',
        'double'    => 'float',
        'date'      => 'datetime',
        'datetime'  => 'datetime',
        'timestamp' => 'datetime'
    ];

    /**
     * Global models<->tables reflections cache
     *
     * One for all models
     * @var array
     */
    protected static $globalReflections = [];

    /**
     * Model class name
     *
     * Just to call get_class only once
     * @var string
     */
    public $className;

    /**
     * Table name
     * @var string
     */
    public $table;

    /**
     * Magic properties to access row columns
     *
     * filled automatically
     * @var array
     */
    public $attributes;

    /**
     * Table column names list
     *
     * fields autmatically
     * @var array
     */
    public $fields;

    /**
     * Table column types list
     *
     * filled autmatically
     * @var array
     */
    public $fieldTypes;

    /**
     * column => property reflections
     *
     * Associative array with connections betweed table fields and
     * model's magic propertiesa
     * @var array
     */
    public $reflections;


    /**
     * Magic properties values
     *
     * @var array
     */
    public $data;

    /**
     * True if table row represented by model is deleted
     * @var boolean
     */
    public $isDeletedRecord;

    /**
     * Associations cache
     * @var array
     */
    public $associationsCache;

    /**
     * One-to-many associations
     * @var array
     */
    protected $hasMany;

    /**
     * One-to-one associations
     * @var array
     */
    protected $hasOne;

    /**
     * Many-to-one associations
     * @var array
     */
    protected $belongsTo;

    /**
     * Many-to-many associations through a third models
     * @var array
     */
    protected $hasManyThrough;

    /**
     * Create new model
     *
     * <code>
     * $post = new BloPost;
     *
     * $post = new BloPost([
     *     'title' => 'Hello',
     *     'body' => 'World!'
     * ]);
     * </code>
     *
     * **NOTE:** This method does not save anything to database
     *
     * - New model is not saved to the database and has $this->isNewRecord() == true
     * - If $attrs has **'id'** key then model with the same id will be loaded from
     *   the database and its attributes will be overwritten (but not saved)
     *
     * @param array $attrs Associative array representing new model properies
     */
    public function __construct($attrs = null) {
        parent::__construct();
        $this->className = get_class($this);
        if (!$this->table) {
            $this->table = $this->classNameToTableName($this->className);
        }
        $this->makeReflection();
        $this->isDeletedRecord = false;
        $this->checkAssociationClasses();
        $this->associationsCache = [];
        if ($attrs) {
            if (array_key_exists('id', $attrs)) {
                $this->id = $attrs["id"];
                $this->reload();
            }
            foreach ($attrs as $name => $value) {
                if (!in_array($name, $this->attributes)) {
                    throw new TipyModelException("Unknown property '".$name."' for ".$this->className);
                }
                $this->data[$name] = $value;
            }
        }
    }

    /**
     * Output something on *echo $obj;*
     */
    public function __toString() {
        return '<'.$this->className.'>#'.$this->id;
    }

    /**
     * Magic method to assign model property
     *
     * @param string $name
     * @param mixed $value
     * @throws TipyModelException if model property is not defined
     */
    public function __set($name, $value) {
        $this->checkAttribute($name);
        $this->data[$name] = $value;
    }

    /**
     * Magic method to get the model property value or to execute model method
     *
     * Lookup order:
     *
     * - model methods
     * - model assosications
     * - model properties
     *
     * @param string $name
     * @throws TipyModelException on lookup failure
     */
    public function __get($name) {
        if (method_exists($this, $name) and is_callable([$this, $name])) {
            return call_user_func([$this, $name]);
        }
        if ($this->belongsTo && array_key_exists($name, $this->belongsTo)) {
            return $this->findBelongsTo($name);
        }
        if ($this->hasMany && array_key_exists($name, $this->hasMany)) {
            return $this->findHasMany($name);
        }
        if ($this->hasOne && array_key_exists($name, $this->hasOne)) {
            return $this->findHasOne($name);
        }
        if ($this->hasManyThrough && array_key_exists($name, $this->hasManyThrough)) {
            return $this->findHasManyThrough($name);
        }
        $this->checkAttribute($name);
        return array_key_exists($name, $this->data) ? $this->data[$name] : null;
    }

    /**
     * Magic method to call model property as a function
     *
     * Use it to pass conditions to assiciation properties
     *
     * <code>
     * $post->comments(['conditions' => 'created_at > ?', 'values' => strtotime('-1 day')])
     * </code>
     *
     * @param string $name
     * @param array $args
     * @throws TipyModelException on lookup failure
     */
    public function __call($name, $args) {
        if (isset($this->hasMany[$name])) {
            return $this->findHasMany($name, $args[0]);
        }
        if (isset($this->hasManyThrough[$name])) {
            return $this->findHasManyThrough($name, $args[0]);
        }
        throw new TipyModelException("Unknown method '".$name."' for ".$this->className);
    }

    /**
     * Check for model property existance
     *
     * @throws TipyModelException if property does not exist
     */
    public function checkAttribute($name) {
        if (!in_array($name, $this->attributes)) {
            throw new TipyModelException("Unknown property '".$name."' for ".$this->className);
        }
    }

    /**
     * Reflect database table to model
     *
     * Loads table columns and creates model properties from them
     */
    protected function makeReflection() {
        $this->data = [];
        if (array_key_exists($this->className, self::$globalReflections)) {
            $this->fields = self::$globalReflections[$this->className]["fields"];
            $this->fieldTypes = self::$globalReflections[$this->className]["fieldTypes"];
            $this->attributes = self::$globalReflections[$this->className]["attributes"];
            $this->reflections = self::$globalReflections[$this->className]["reflections"];
        } else {
            $this->fields = [];
            $this->attributes = [];
            $this->reflections = [];
            $fields = $this->queryAllRows("show columns from ".$this->table);
            foreach ($fields as $field) {
                $fieldName = $field["Field"];
                $fieldType = preg_replace('/\(.*\)/', '', $field["Type"]);
                $fieldType = preg_replace('/ unsigned/', '', $fieldType);
                $attrName = $this->fieldNameToAttrName($fieldName);
                $this->fields[] = $fieldName;
                $this->fieldTypes[$fieldName] = $fieldType;
                $this->attributes[] = $attrName;
                $this->reflections[$fieldName] = $attrName;
            }
            // Store reflections for future use of this class
            self::$globalReflections[$this->className] = [];
            self::$globalReflections[$this->className]["fields"] = $this->fields;
            self::$globalReflections[$this->className]["fieldTypes"] = $this->fieldTypes;
            self::$globalReflections[$this->className]["attributes"] = $this->attributes;
            self::$globalReflections[$this->className]["reflections"] = $this->reflections;
        }
    }

    /**
     * Creale model instance from the table row
     *
     * <code>
     * $post = BlogPost::load(123);
     * </code>
     *
     * @param integer $id
     * @return TipyModel|null
     */
    public static function load($id) {
        $className = get_called_class();
        $instance = new $className;
        $result =  $instance->queryRow(
            "select * from ".$instance->table." where id=?",
            [$id]
        );
        if (!$result) {
            return null;
        }
        $instance = self::instanceFromResult($instance, $result);
        return $instance;
    }

    /**
     * True if new model instance has not been saved yet
     *
     * @return boolean
     */
    public function isNewRecord() {
        return !$this->id;
    }

    /**
     * Save model data as a table row
     *
     * @throws TipyModelException if model is marked as deleted
     * @return mysqli_result
     */
    public function save() {
        if ($this->isDeletedRecord) {
            throw new TipyModelException('Unable to save deleted model');
        }
        $this->validate();
        if ($this->isNewRecord()) {
            if (in_array(static::CREATED_AT, $this->fields)) {
                if (!$this->createdAt) {
                    $this->createdAt = $this->getCurrentTime();
                }
            }
            $result = $this->createNewRecord();
        } else {
            if (in_array(static::UPDATED_AT, $this->fields)) {
                $this->updatedAt = $this->getCurrentTime();
            }
            $result = $this->updateRecord();
        }
        return $result;
    }

    /**
     * Set model property and update model row immediately
     *
     * @param string $name
     * @param mixed $value
     * @throws TipyModelException if property does not exist
     */
    public function update($name, $value) {
        $this->checkAttribute($name);
        $this->$name = $value;
        return $this->save();
    }

    /**
     * Reload model from the database
     *
     * @throws TipyModelException if trying to reload new (not-saved) model
     * @throws TipyModelException if trying to reload model marked as deleted
     */
    public function reload() {
        if (!$this->id) {
            throw new TipyModelException("Unable to reload unsaved model");
        }
        if ($this->isDeletedRecord) {
            throw new TipyModelException("Unable to reload deleted model");
        }
        $reloadedModel = $this->load($this->id);
        $this->data = $reloadedModel->data;
        $this->associationsCache = [];
    }

    /**
     * Create new model, save it to the database, and return model instance
     *
     * <code>
     * $post = BlogPost::create([
     *     'title' => 'Hello World'
     * ])
     *
     * // is equivalent of
     *
     * $post = new BlogPost;
     * $post->title = 'Hello World';
     * $post->save();
     * </code>
     *
     * @param array $attrs
     * @return TipyModel
     */
    public static function create($attr = null) {
        $className = get_called_class();
        $instance = new $className($attr);
        $instance->save();
        return $instance;
    }

    /**
     * Save new model as a table row
     *
     * @return mysqli_result
     */
    protected function createNewRecord() {
        $this->beforeCreate();
        $fields = [];
        $questions = [];
        $values = [];
        foreach ($this->reflections as $field => $attr) {
            // No need to create id
            // Skip attrs that doesn't set
            if ($field != "id" && array_key_exists($attr, $this->data)) {
                $fields[] = $field;
                $questions[] = "?";
                $values[] = $this->data[$attr];
            }
        }
        $fieldList = implode(",", $fields);
        $questions = implode(",", $questions);
        $query = "insert into ".$this->table."(".$fieldList.") values (".$questions.")";
        $result = $this->query($query, $values);
        $this->id = $this->lastInsertId();
        $this->afterCreate();
        return $result;
    }

    /**
     * Updates table row connected to model
     *
     * @return mysqli_result
     */
    protected function updateRecord() {
        if (!$this->id) {
            throw new TipyModelException("Cannot update record without an id");
        }
        $this->beforeUpdate();
        $query = "update ".$this->table." set ";
        $values = [];
        $updatePart = [];
        foreach ($this->reflections as $field => $attr) {
            // No need to update id
            // Skip attrs that doesn't set
            if ($field != "id" && array_key_exists($attr, $this->data)) {
                $updatePart[] = "$field=?";
                $values[] = $this->$attr;
            }
        }
        $query .= implode(", ", $updatePart)." where id = ?";
        $values[] = $this->id;
        $result = $this->query($query, $values);
        $this->afterUpdate();
        return $result;
    }

    /**
     * Delete table row connected to model
     *
     * - deletes table row
     * - deletes all rows for associations with **'dependent' => 'delete'**
     * - set to null all foreign keys for associations with **'dependent' => 'nullify'**
     * - marks model as deleted
     *
     * @return mysqli_result
     */
    public function delete() {
        if ($this->isNewRecord()) {
            throw new TipyModelException("Cannot delete unsaved model");
        }
        $this->beforeDelete();
        $result = $this->query("delete from ".$this->table." where id=?", [$this->id]);
        if ($this->hasOne) {
            foreach ($this->hasOne as $name => $properties) {
                if (array_key_exists('dependent', $properties)) {
                    if ($properties["dependent"]==='delete' && $this->$name) {
                        $this->$name->delete();
                    } elseif ($properties["dependent"]==='nullify' && $this->$name) {
                        $table = static::classNameToTableName($properties["class"]);
                        $key = array_key_exists('foreign_key', $properties) ? $properties["foreign_key"] : $this->tableForeignKeyFieldName($this->table);
                        $this->query('update '.$table.' set `'.$key.'` = null');
                    }
                }
            }
        }
        if ($this->hasMany) {
            foreach ($this->hasMany as $name => $properties) {
                foreach ($this->$name as $obj) {
                    if (array_key_exists('dependent', $properties)) {
                        if ($properties["dependent"]==='delete' && $obj) {
                            $obj->delete();
                        } elseif ($properties["dependent"]==='nullify' && $obj) {
                            $table = static::classNameToTableName($properties["class"]);
                            $key = $properties["foreign_key"] ? $properties["foreign_key"] : $this->tableForeignKeyFieldName($this->table);
                            $this->query('update '.$table.' set `'.$key.'` = null');
                        }
                    }
                }
            }
        }
        $this->isDeletedRecord = true;
        $this->associationsCache = [];
        $result = true;
        $this->afterDelete();
        return $result;
    }

    /**
     * Count model records by conditions
     *
     * <code>
     * $post = BlogPost::count([
     *      'conditions' => "user_id = ?",
     *      'values' => [42]
     * ]);
     * </code>
     *
     * @return integer
     */
    public static function count($options = []) {
        $className = get_called_class();
        $instance = new $className;
        $sql = "select count(id) as quantity from ".$instance->table;
        $where = "";
        if (array_key_exists('conditions', $options)) {
            $where = " where ".$options["conditions"];
        }
        $sql = $sql.$where;
        if (!isset($options['values'])) {
            $options['values'] = [];
        }
        $result = $instance->queryRow($sql, $options["values"]);
        return (int) $result['quantity'];
    }

    /**
     * Return array of models by conditions
     *
     * <code>
     * $post = BlogPost::find([
     *      'conditions' => "title =?",
     *      'values' => ['Hello'],
     *      'limit' => 2.
     *      'offset' => 3,
     *      'order' => 'user_id asc'
     * ]);
     * </code>
     *
     * @param array $options
     * @return array
     */
    public static function find($options = ['values' => []]) {
        $className = get_called_class();
        $instance = new $className;
        $sql = "select * from ".$instance->table;
        $where = "";
        $order = "";
        if (array_key_exists('conditions', $options)) {
            $where = " where ".$options["conditions"];
        }
        $order = " order by ".(isset($options["order"]) ? $options["order"] : "id");
        $sql = $sql.$where.$order;
        if (!array_key_exists('values', $options)) {
            $options["values"] = [];
        }
        if (array_key_exists('limit', $options)) {
            if (!array_key_exists('offset', $options)) {
                $options["offset"] = 0;
            }
            $result =  $instance->limitQueryAllRows($sql, $options["offset"], $options["limit"], $options["values"]);
        } else {
            $result =  $instance->queryAllRows($sql, $options["values"]);
        }
        $instances = [];
        foreach ($result as $record) {
            $instance = new $className;
            $instance = self::instanceFromResult($instance, $record);
            $instances[] = $instance;
        }
        return $instances;
    }

    /**
     * Same as find but return only first instance
     *
     * <code>
     * $post = BlogPost::findFirst([
     *      'conditions' => "title =?",
     *      'values' => ['Hello'],
     *      'limit' => 2.
     *      'offset' => 3,
     *      'order' => 'user_id asc'
     * ]);
     * </code>
     *
     * @return TipyModel
     */
    public static function findFirst($options = []) {
        $options["limit"] = 1;
        $result = self::find($options);
        if (sizeof($result) > 0) {
            return $result[0];
        } else {
            return null;
        }
    }

    /**
     * Validates model data
     *
     * Executed before SQL INSERT or UPDATE statements are sent to database.
     * Override this in your model classes
     */
    public function validate() {
    }


    /**
     * Fill model instance with data from mysqli_result
     *
     * @param TipyModel $instance
     * @param mysqli_result
     * @return TipyModel
     */
    protected static function instanceFromResult($instance, $result) {
        foreach ($instance->reflections as $field => $attr) {
            if (array_key_exists($field, $result) && $result[$field] !== null) {
                $instance->data[$attr] = $instance->typeCast($field, $result[$field]);
            } else {
                $instance->data[$attr] = null;
            }
        }
        return $instance;
    }

    /**
     * Cast column value to PHP type
     *
     * MySQL returns all field values as strings.
     * This method return value casted to PHP type
     * using rules from {@link $mysqlToPhpTypes}
     *
     * @param string field
     * @param mixed $value
     * @return mixed
     */
    protected function typeCast($field, $value) {
        $type = $this->fieldTypes[$field];
        switch (self::$mysqlToPhpTypes[$type]) {
            // $value is already string so we don't typecast to string
            case 'integer':
                settype($value, 'integer');
                break;
            case 'float':
                settype($value, 'float');
                break;
            case 'datetime':
                $value = new DateTime($value);
                break;
        }
        return $value;
    }


    /**
     * Check and fill missing association classes
     * Recognize class names from association names
     * This allows to define assiciations by specifying
     * its name only
     * <code>
     *  $hasMany = ['comments', 'posts'];
     * </cde>
     */
     private function checkAssociationClasses() {
        foreach(['hasMany', 'hasOne', 'belongsTo', 'hasManyThrough'] as $assocType) {
            if (!$this->$assocType) {
                continue;
            }
            if (!is_array($this->$assocType)) {
                throw new TipyModelException("Association definition should be an Array");
            }
            $assocArray = $this->$assocType;
            foreach($assocArray as $name => $options) {
                // If $name is integer then this is assoc specified without options
                // Make it array for future usage
                if (is_int($name)) {
                    // Transform ['post'] array to ['post' => []]
                    unset($assocArray[$name]);
                    $name = $options;
                    $assocArray[$name] = [];
                }
                if (!array_key_exists('class', $assocArray[$name])) {
                    $assocArray[$name]['class'] = TipyInflector::classify($name);
                }
            }
            $this->$assocType = $assocArray;
        }
     }

    /**
     * Select from the database and instantiaate {@link $hasMany}
     * associated models by name and conditions
     *
     * @internal
     * @param string name
     * @param array $options
     * @return array
     */
    protected function findHasMany($name, $options = null) {
        $cacheAssoc = false;
        if (!$options and isset($this->associationsCache[$name])) {
            return $this->associationsCache[$name];
        } elseif (!$options) {
            $cacheAssoc = true;
            $options = [];
        }
        if (!isset($options["values"])) {
            $options["values"] = [];
        }
        $assocClass = $this->hasMany[$name]["class"];
        $parentKey = array_key_exists('foreign_key', $this->hasMany[$name]) ? $this->hasMany[$name]["foreign_key"] : $this->tableForeignKeyFieldName($this->table);
        $conditions = "$parentKey=?";
        if (isset($this->hasMany[$name]["conditions"])) {
            $conditions = $conditions." and (".$this->hasMany[$name]["conditions"].")";
        }
        if (isset($options["conditions"])) {
            $options["conditions"] = "(".$options["conditions"].") and ".$conditions;
        } else {
            $options["conditions"] = $conditions;
        }
        $options["values"][] = $this->id;
        if (array_key_exists('values', $this->hasMany[$name])) {
            $options["values"][] = $this->hasMany[$name]["values"];
        }
        $result = call_user_func($assocClass .'::find', $options);
        if ($cacheAssoc) {
            $this->associationsCache[$name] = $result;
        }
        return $result;
    }

    /**
     * Select from the database and instantiate {@link $hasOne}
     * associated model by name
     *
     * @internal
     * @param string name
     * @return TipyModel
     */
    protected function findHasOne($name) {
        if (isset($this->associationsCache[$name])) {
            return $this->associationsCache[$name];
        }
        $assocClass = $this->hasOne[$name]["class"];
        $parentKey = array_key_exists('foreign_key', $this->hasOne[$name]) ? $this->hasOne[$name]["foreign_key"] : $this->tableForeignKeyFieldName($this->table);
        $conditions = "$parentKey=?";
        $options = ["conditions" => $conditions, "values" => [$this->id]];
        $assoc = call_user_func($assocClass .'::findFirst', $options);
        $this->associationsCache[$name] = $assoc;
        return $assoc;
    }

    /**
     * Select from the database and instantiate {@link $belongsTo}
     * associated model by name
     *
     * @internal
     * @param string name
     * @return TipyModel
     */
    protected function findBelongsTo($name) {
        if (isset($this->associationsCache[$name])) {
            return $this->associationsCache[$name];
        }
        $assocClass = $this->belongsTo[$name]["class"];
        $attr = array_key_exists('foreign_key', $this->belongsTo[$name]) ? $this->fieldNameToAttrName($this->belongsTo[$name]["foreign_key"]) : $this->classForeignKeyAttr($assocClass);
        $assoc = call_user_func($assocClass .'::load', $this->$attr);
        $this->associationsCache[$name] = $assoc;
        return $assoc;
    }

    /**
     * Select from the database and instantiate {@link $hasManyThrough}
     * associated model by name and conditions
     *
     * @internal
     * @param string name
     * @param array $options
     * @return array
     */
    protected function findHasManyThrough($name, $options = null) {
        $cacheAssoc = false;
        if (!$options and array_key_exists($name, $this->associationsCache)) {
            return $this->associationsCache[$name];
        } elseif (!$options) {
            $cacheAssoc = true;
        }
        $throughClass = $this->hasManyThrough[$name]["through"];
        $parentKey = array_key_exists('foreign_key', $this->hasManyThrough[$name]) ? $this->hasManyThrough[$name]["foreign_key"] : $this->tableForeignKeyFieldName($this->table);
        $throughOptions = [
            "conditions" => "$parentKey=?",
            "values"     => [$this->id]
        ];
        $result = call_user_func($throughClass .'::find', $throughOptions);
        if (!$result) {
            return [];
        }
        $ids = [];
        $targetKey = array_key_exists('through_key', $this->hasManyThrough[$name]) ? $this->fieldNameToAttrName($this->hasManyThrough[$name]["through_key"]) : $this->classForeignKeyAttr($this->hasManyThrough[$name]["class"]);
        foreach ($result as $row) {
            $ids[] = "'" . $row->$targetKey . "'";
        }
        $conditions = "id in (".implode(', ', $ids).")";
        if (!$options) {
            $options = [];
        }
        if (!isset($options["values"])) {
            $options["values"] = [];
        }
        $assocClass = $this->hasManyThrough[$name]["class"];

        if (isset($options["conditions"])) {
            $options["conditions"] = "(".$options["conditions"].") and ".$conditions;
        } else {
            $options["conditions"] = $conditions;
        }

        $result = call_user_func($assocClass .'::find', $options);
        if ($cacheAssoc) {
            $this->associationsCache[$name] = $result;
        }
        return $result;
    }

    /**
     * Lock model's connected row for update
     *
     * Should be called inside {@link transaction()}
     *
     * @throws TipyDaoException if called outside transaction
     */
    public function lockForUpdate() {
        if (!$this->isTransactionInProgress()) {
            throw new TipyDaoException('No any transaction in progress');
        }
        return $this->query('select id from '.$this->table.' where id=? for update', [$this->id]);
    }

    // ----------------------------------------
    // Hooks
    // ----------------------------------------

    /**
     * TipyModel hook called before {@link create()} and {@link createNewRecord}
     *
     * Override this in your model to add logic
     */
    public function beforeCreate() {
    }

    /**
     * TipyModel hook called after {@link update()} and {@link updateRecord}
     *
     * Override this in your model to add logic
     */
    public function afterCreate() {
    }

    /**
     * TipyModel hook called before {@link update()} and {@link createNewRecord}
     *
     * Override this in your model to add logic
     */
    public function beforeUpdate() {
    }

    /**
     * TipyModel hook called after {@link update()} and {@link createNewRecord}
     *
     * Override this in your model to add logic
     */
    public function afterUpdate() {
    }

    /**
     * TipyModel hook called before {@link delete()}
     *
     * Override this in your model to add logic
     */
    public function beforeDelete() {
    }

    /**
     * TipyModel hook called after {@link delete()}
     *
     * Override this in your model to add logic
     */
    public function afterDelete() {
    }

    /**
     * ------------------------------------------------------
     * Conventions Definition
     * Override methods below to change TipyModel conventions
     * ------------------------------------------------------
     */

    /**
     * Converts table name to model class name
     *
     * You may override this method to change the rules
     *
     * @param string $className
     * @return string
     */
    protected static function classNameToTableName($className) {
        return TipyInflector::tableize($className);
    }

    /**
     * Converts table column name to model property name
     *
     * You may override this method to change the rules
     *
     * @param string $fieldName
     * @return string
     */
    protected static function fieldNameToAttrName($fieldName) {
        return TipyInflector::camelCase($fieldName);
    }

    /**
     * Return foreign key name for foreign table
     *
     * You may override this method to change the rules
     *
     * @param string $tableName
     * @return string
     */
    protected static function tableForeignKeyFieldName($tableName) {
        return TipyInflector::singularize($tableName)."_id";
    }

    /**
     * Associated model property representing foreign key
     *
     * You may override this method to change the rules
     *
     * @param string $className
     * @return string
     */
    protected static function classForeignKeyAttr($className) {
        return lcfirst($className)."Id";
    }

    /**
     * Return current time for *created_at* and *updated_at* columns
     *
     * Default TipyModel convention is to use **unix timestamp** for
     * timestamps. You can change this by overriding this method
     * in your model
     *
     * @return integer
     */
    protected static function getCurrentTime() {
        return time();
    }
}
tipy API documentation generated by ApiGen