diff --git a/coverage/lcov.info b/coverage/lcov.info deleted file mode 100644 index 01c31163..00000000 --- a/coverage/lcov.info +++ /dev/null @@ -1,1263 +0,0 @@ -SF:/Users/leofarias/Projects/ack/lib/src/ack.dart -DA:12,0 -DA:14,0 -DA:15,0 -DA:16,2 -DA:17,2 -DA:18,0 -DA:20,1 -DA:22,0 -DA:24,1 -DA:28,0 -DA:30,3 -DA:33,0 -DA:35,3 -DA:40,0 -DA:41,0 -DA:42,1 -DA:43,3 -DA:44,0 -DA:45,0 -DA:46,1 -DA:47,5 -LF:21 -LH:10 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/constraints/constraint.dart -DA:8,43 -DA:10,4 -DA:11,12 -DA:14,2 -DA:15,8 -DA:23,18 -DA:25,30 -DA:27,15 -DA:29,3 -DA:30,12 -DA:33,1 -DA:34,4 -DA:48,114 -DA:50,90 -DA:57,16 -DA:58,32 -LF:16 -LH:16 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/constraints/constraint_extensions.dart -DA:8,2 -DA:9,4 -DA:12,6 -DA:15,3 -DA:18,2 -DA:21,6 -DA:24,6 -DA:27,1 -DA:28,2 -DA:31,0 -DA:34,1 -DA:35,2 -DA:38,4 -DA:41,2 -DA:44,2 -DA:48,9 -DA:51,6 -DA:54,9 -DA:57,6 -DA:60,1 -DA:61,2 -DA:74,0 -DA:75,0 -DA:86,0 -DA:87,0 -DA:99,2 -DA:100,6 -DA:109,4 -DA:110,12 -DA:118,2 -DA:119,6 -LF:31 -LH:26 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/constraints/validators.dart -DA:15,10 -DA:16,10 -DA:18,10 -DA:21,10 -DA:23,30 -DA:28,7 -DA:29,7 -DA:34,6 -DA:46,25 -DA:47,2 -DA:52,2 -DA:53,2 -DA:55,2 -DA:57,2 -DA:59,0 -DA:60,0 -DA:71,25 -DA:72,2 -DA:77,2 -DA:80,2 -DA:86,8 -DA:87,6 -DA:88,6 -DA:91,2 -DA:94,2 -DA:96,2 -DA:98,0 -DA:99,0 -DA:113,4 -DA:114,4 -DA:116,4 -DA:119,4 -DA:120,8 -DA:122,4 -DA:124,8 -DA:126,4 -DA:128,20 -DA:130,4 -DA:133,0 -DA:134,0 -DA:144,3 -DA:145,3 -DA:159,2 -DA:160,2 -DA:178,2 -DA:179,2 -DA:182,10 -DA:183,2 -DA:186,2 -DA:187,4 -DA:189,2 -DA:191,4 -DA:203,26 -DA:204,3 -DA:209,3 -DA:210,3 -DA:212,2 -DA:225,23 -DA:226,0 -DA:231,0 -DA:234,0 -DA:235,0 -DA:246,0 -DA:248,0 -DA:264,3 -DA:268,3 -DA:269,3 -DA:270,3 -DA:274,6 -DA:275,0 -DA:277,12 -DA:278,0 -DA:282,3 -DA:285,6 -DA:287,3 -DA:293,2 -DA:295,6 -DA:298,0 -DA:299,0 -DA:301,0 -DA:303,0 -DA:313,25 -DA:314,2 -DA:319,2 -DA:320,2 -DA:322,2 -DA:324,2 -DA:339,3 -DA:340,3 -DA:342,3 -DA:345,3 -DA:346,9 -DA:348,3 -DA:350,9 -DA:353,0 -DA:354,0 -DA:368,3 -DA:369,3 -DA:371,3 -DA:374,3 -DA:375,9 -DA:377,3 -DA:380,9 -DA:383,0 -DA:384,0 -DA:395,3 -DA:396,3 -DA:401,3 -DA:402,6 -DA:404,3 -DA:406,15 -DA:408,3 -DA:411,0 -DA:412,0 -DA:426,5 -DA:427,5 -DA:429,5 -DA:432,5 -DA:433,15 -DA:435,5 -DA:437,15 -DA:440,0 -DA:441,0 -DA:455,3 -DA:456,3 -DA:458,3 -DA:461,3 -DA:462,9 -DA:464,3 -DA:466,9 -DA:469,0 -DA:470,0 -DA:490,3 -DA:492,3 -DA:494,3 -DA:496,2 -DA:497,8 -DA:499,1 -DA:501,1 -DA:502,0 -DA:503,2 -DA:506,0 -DA:507,0 -DA:508,0 -DA:509,0 -DA:522,1 -DA:523,1 -DA:525,1 -DA:528,1 -DA:529,3 -DA:531,1 -DA:533,2 -DA:534,4 -DA:537,0 -DA:538,0 -DA:558,3 -DA:560,3 -DA:562,3 -DA:565,2 -DA:566,8 -DA:568,2 -DA:570,2 -DA:571,0 -DA:572,4 -DA:575,0 -DA:576,0 -DA:577,0 -DA:578,0 -DA:603,2 -DA:605,2 -DA:607,2 -DA:610,2 -DA:612,14 -DA:614,2 -DA:616,6 -DA:619,0 -DA:620,0 -DA:621,0 -DA:622,0 -DA:623,0 -DA:624,0 -DA:639,0 -DA:640,0 -DA:642,0 -DA:645,0 -DA:646,0 -DA:648,0 -DA:650,0 -DA:653,0 -DA:654,0 -DA:668,0 -DA:669,0 -DA:671,0 -DA:674,0 -DA:675,0 -DA:677,0 -DA:679,0 -DA:682,0 -DA:683,0 -DA:694,6 -DA:695,6 -DA:698,18 -DA:701,6 -DA:702,42 -DA:704,6 -DA:705,12 -DA:707,12 -DA:709,0 -DA:711,0 -DA:713,0 -DA:726,7 -DA:727,7 -DA:729,14 -DA:732,7 -DA:734,28 -DA:737,3 -DA:739,3 -DA:740,3 -DA:741,3 -DA:742,9 -DA:743,3 -DA:745,3 -DA:756,3 -DA:757,3 -DA:760,3 -DA:764,3 -DA:767,3 -DA:768,6 -DA:769,12 -DA:770,3 -DA:771,3 -DA:775,3 -DA:778,3 -DA:779,6 -DA:780,12 -DA:781,12 -DA:782,7 -DA:783,3 -DA:786,3 -DA:788,6 -DA:789,6 -DA:792,2 -DA:794,2 -DA:795,2 -DA:798,2 -DA:799,2 -DA:800,4 -DA:801,2 -DA:811,3 -DA:812,3 -DA:817,3 -DA:820,6 -DA:825,6 -DA:829,9 -DA:832,3 -DA:834,6 -DA:835,9 -DA:839,7 -LF:258 -LH:191 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/converters/open_api_schema.dart -DA:14,0 -DA:15,1 -DA:20,0 -DA:21,1 -DA:24,1 -DA:27,0 -DA:28,0 -DA:30,0 -DA:31,0 -DA:32,0 -DA:34,1 -DA:35,1 -DA:37,0 -DA:38,2 -DA:39,0 -DA:40,1 -DA:41,1 -DA:42,4 -DA:45,3 -DA:47,0 -DA:48,0 -DA:50,0 -DA:59,0 -DA:62,1 -DA:67,0 -DA:69,0 -DA:70,1 -DA:72,1 -DA:73,0 -DA:75,0 -DA:76,1 -DA:77,0 -DA:78,1 -DA:79,1 -DA:80,1 -DA:81,0 -DA:83,1 -DA:84,2 -DA:86,0 -DA:87,3 -DA:89,1 -DA:90,0 -DA:91,1 -DA:92,0 -DA:93,1 -DA:94,0 -DA:95,3 -DA:97,2 -DA:99,0 -DA:100,0 -DA:101,0 -DA:102,1 -DA:103,5 -DA:104,2 -DA:105,0 -DA:106,0 -DA:107,1 -DA:109,3 -DA:110,1 -DA:111,1 -DA:112,1 -DA:115,1 -DA:116,1 -DA:117,1 -DA:121,0 -DA:122,0 -DA:126,0 -DA:127,0 -DA:128,0 -DA:129,0 -DA:131,1 -DA:132,1 -DA:133,1 -DA:134,1 -DA:135,0 -DA:136,1 -DA:137,0 -DA:138,0 -DA:139,0 -DA:140,1 -DA:141,1 -DA:142,4 -DA:143,2 -DA:144,1 -DA:146,0 -DA:148,2 -DA:149,2 -DA:150,0 -DA:151,5 -DA:153,0 -DA:154,2 -DA:155,2 -DA:156,0 -DA:157,0 -DA:158,1 -DA:159,2 -DA:160,1 -DA:161,1 -DA:162,1 -DA:163,0 -DA:164,1 -DA:165,2 -DA:167,1 -DA:168,2 -DA:169,1 -DA:171,0 -DA:172,0 -DA:173,1 -DA:174,2 -DA:175,0 -DA:176,1 -DA:177,2 -DA:179,1 -DA:180,2 -DA:181,0 -DA:183,0 -DA:186,1 -DA:187,0 -DA:188,2 -DA:189,0 -DA:190,0 -DA:191,0 -DA:192,1 -DA:193,0 -DA:194,1 -DA:195,1 -DA:196,1 -DA:197,1 -DA:198,1 -DA:199,1 -DA:200,1 -DA:201,0 -DA:207,0 -DA:210,0 -DA:212,0 -DA:213,1 -DA:214,0 -DA:216,1 -DA:218,2 -DA:219,0 -DA:220,0 -DA:222,0 -DA:225,0 -LF:143 -LH:80 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/helpers/template.dart -DA:5,1 -DA:10,1 -DA:12,1 -DA:15,1 -DA:22,1 -DA:24,1 -DA:25,1 -DA:31,2 -DA:32,1 -DA:35,1 -DA:37,1 -DA:39,2 -DA:40,2 -DA:41,1 -DA:43,1 -DA:45,1 -DA:46,1 -DA:51,2 -DA:56,1 -DA:57,1 -DA:59,1 -DA:60,0 -DA:62,1 -DA:65,1 -DA:66,1 -DA:70,2 -DA:73,1 -DA:76,1 -DA:77,1 -DA:81,1 -DA:82,2 -DA:83,1 -DA:84,1 -DA:89,2 -DA:90,1 -DA:91,1 -DA:92,1 -DA:93,2 -DA:94,1 -DA:97,1 -DA:98,2 -DA:99,2 -DA:103,2 -DA:104,1 -DA:107,1 -DA:113,1 -DA:114,1 -DA:115,1 -DA:116,1 -DA:117,1 -DA:118,1 -DA:121,1 -DA:130,1 -DA:131,1 -DA:134,2 -DA:135,2 -DA:136,1 -DA:138,1 -DA:140,0 -DA:141,0 -DA:142,0 -DA:149,1 -DA:153,0 -DA:156,2 -DA:159,0 -DA:161,0 -DA:162,0 -DA:163,0 -DA:166,0 -LF:69 -LH:59 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/schemas/schema.dart -DA:74,63 -DA:76,1 -DA:85,30 -DA:94,15 -DA:95,0 -DA:97,30 -DA:98,33 -DA:99,15 -DA:100,15 -DA:105,0 -DA:106,0 -DA:107,0 -DA:108,0 -DA:109,0 -DA:111,6 -DA:113,6 -DA:134,12 -DA:137,2 -DA:140,2 -DA:143,2 -DA:146,2 -DA:156,15 -DA:161,7 -DA:162,7 -DA:163,12 -DA:164,18 -DA:165,6 -DA:168,0 -DA:169,15 -DA:170,0 -DA:172,10 -DA:173,10 -DA:174,10 -DA:175,20 -DA:176,0 -DA:177,10 -DA:182,15 -DA:183,0 -DA:184,15 -DA:185,11 -DA:186,11 -DA:188,11 -DA:193,15 -DA:194,0 -DA:195,0 -DA:196,0 -DA:198,0 -DA:199,0 -DA:203,0 -DA:205,0 -DA:206,0 -DA:211,15 -DA:212,15 -DA:213,45 -DA:214,30 -DA:215,0 -DA:216,0 -DA:217,0 -DA:218,0 -DA:219,0 -DA:220,0 -DA:221,0 -DA:222,2 -DA:223,2 -DA:224,4 -DA:225,6 -DA:226,2 -DA:227,2 -DA:228,2 -DA:235,0 -DA:244,0 -DA:245,0 -DA:251,9 -DA:252,36 -DA:254,2 -DA:255,8 -DA:256,0 -DA:261,0 -DA:266,4 -DA:267,0 -DA:268,0 -DA:269,0 -DA:276,1 -DA:277,2 -DA:282,1 -DA:289,61 -DA:290,0 -DA:297,15 -DA:299,0 -DA:301,0 -DA:302,0 -DA:303,6 -DA:304,10 -DA:305,7 -DA:306,6 -DA:314,0 -DA:315,7 -DA:316,11 -DA:317,6 -DA:318,6 -DA:326,0 -DA:327,0 -DA:328,0 -DA:329,0 -DA:334,2 -DA:336,0 -DA:345,15 -DA:347,15 -DA:348,9 -DA:349,16 -DA:350,12 -DA:356,0 -DA:363,0 -DA:364,0 -DA:371,7 -DA:372,0 -DA:373,0 -DA:374,0 -DA:375,0 -DA:378,14 -DA:379,5 -DA:380,7 -DA:381,6 -DA:382,7 -DA:386,1 -DA:388,4 -LF:126 -LH:76 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/schemas/list/list_schema.dart -DA:6,7 -DA:13,7 -DA:15,2 -DA:17,6 -DA:19,6 -DA:20,0 -DA:21,6 -DA:23,5 -DA:25,6 -DA:26,0 -DA:27,5 -DA:29,15 -DA:30,20 -DA:32,5 -DA:33,0 -DA:34,0 -DA:37,10 -DA:38,0 -DA:39,0 -DA:40,0 -DA:41,0 -DA:44,6 -DA:45,0 -DA:46,6 -DA:48,6 -DA:49,12 -DA:50,12 -DA:53,0 -DA:55,6 -DA:61,5 -DA:62,0 -DA:63,0 -DA:64,0 -DA:65,0 -DA:66,0 -DA:67,0 -DA:68,5 -DA:69,5 -DA:70,1 -DA:71,5 -DA:72,5 -DA:73,5 -DA:77,0 -DA:78,0 -DA:84,0 -DA:86,0 -DA:88,0 -DA:92,0 -DA:94,0 -LF:49 -LH:26 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/schemas/num/num_schema.dart -DA:7,28 -DA:13,4 -DA:20,35 -DA:26,11 -DA:30,35 -LF:5 -LH:5 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/schemas/string/string_schema.dart -DA:7,54 -DA:13,7 -LF:2 -LH:2 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/schemas/discriminated/discriminated_object_schema.dart -DA:8,4 -DA:17,4 -DA:19,0 -DA:20,3 -DA:21,6 -DA:24,0 -DA:25,0 -DA:27,2 -DA:28,0 -DA:29,0 -DA:30,4 -DA:32,3 -DA:34,3 -DA:35,0 -DA:36,3 -DA:38,3 -DA:40,4 -DA:42,3 -DA:43,6 -DA:44,6 -DA:45,9 -DA:46,3 -DA:47,3 -DA:49,3 -DA:50,3 -DA:51,3 -DA:52,3 -DA:53,3 -DA:56,0 -DA:58,3 -DA:59,0 -DA:60,6 -DA:61,0 -DA:62,3 -DA:65,0 -DA:70,0 -DA:74,0 -DA:80,0 -DA:84,1 -DA:89,0 -DA:90,0 -DA:91,0 -DA:92,0 -DA:93,1 -DA:94,0 -DA:95,1 -DA:96,1 -DA:97,1 -DA:98,1 -DA:99,1 -DA:101,0 -DA:102,0 -DA:103,0 -DA:104,0 -DA:105,0 -DA:106,0 -DA:107,0 -DA:108,0 -DA:109,0 -DA:115,0 -DA:117,0 -DA:121,0 -DA:123,0 -DA:124,0 -DA:125,0 -DA:127,0 -DA:129,0 -DA:130,0 -DA:134,0 -DA:135,0 -LF:70 -LH:32 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/schemas/object/object_schema.dart -DA:11,0 -DA:12,8 -DA:21,0 -DA:22,8 -DA:23,24 -DA:24,1 -DA:25,5 -DA:29,0 -DA:30,0 -DA:31,16 -DA:32,0 -DA:33,0 -DA:34,0 -DA:35,16 -DA:36,1 -DA:38,0 -DA:40,2 -DA:44,0 -DA:47,0 -DA:49,0 -DA:50,4 -DA:51,0 -DA:52,4 -DA:53,2 -DA:54,2 -DA:55,0 -DA:56,2 -DA:57,0 -DA:58,3 -DA:59,2 -DA:60,1 -DA:61,1 -DA:62,1 -DA:63,1 -DA:64,0 -DA:66,2 -DA:67,0 -DA:69,0 -DA:70,7 -DA:71,0 -DA:72,2 -DA:76,5 -DA:78,0 -DA:83,14 -DA:85,12 -DA:87,12 -DA:89,6 -DA:91,6 -DA:92,6 -DA:93,6 -DA:95,0 -DA:96,6 -DA:97,6 -DA:99,18 -DA:100,6 -DA:104,6 -DA:105,0 -DA:106,6 -DA:107,0 -DA:108,6 -DA:109,0 -DA:110,6 -DA:111,0 -DA:112,7 -DA:113,0 -DA:114,6 -DA:115,0 -DA:116,18 -DA:117,6 -DA:118,6 -DA:120,6 -DA:121,0 -DA:122,6 -DA:124,6 -DA:125,8 -DA:127,0 -DA:129,10 -DA:131,4 -DA:132,8 -DA:138,0 -DA:140,0 -DA:142,0 -DA:147,0 -DA:155,0 -DA:156,0 -DA:157,0 -DA:164,0 -DA:165,0 -DA:166,3 -DA:174,0 -DA:176,3 -DA:177,2 -DA:178,2 -DA:179,2 -DA:180,3 -DA:181,1 -DA:182,3 -DA:183,3 -DA:184,0 -DA:185,0 -DA:186,0 -DA:187,2 -DA:188,0 -DA:189,2 -DA:190,2 -DA:191,2 -DA:192,7 -DA:193,4 -DA:194,4 -DA:195,0 -DA:197,0 -DA:198,0 -DA:199,0 -DA:200,0 -DA:201,0 -DA:202,0 -LF:116 -LH:67 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/schemas/boolean/boolean_schema.dart -DA:23,29 -DA:29,5 -LF:2 -LH:2 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/validation/ack_exception.dart -DA:7,4 -DA:9,2 -DA:10,6 -DA:13,6 -DA:15,1 -DA:17,2 -LF:6 -LH:6 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/validation/schema_error.dart -DA:10,0 -DA:12,17 -DA:16,0 -DA:18,0 -DA:19,2 -DA:20,2 -DA:21,2 -DA:22,4 -DA:23,2 -DA:24,2 -DA:26,0 -DA:27,0 -DA:28,0 -DA:29,0 -DA:30,0 -DA:34,0 -DA:36,0 -DA:37,0 -DA:40,0 -DA:41,0 -DA:42,0 -DA:43,0 -DA:44,0 -DA:47,0 -DA:48,0 -DA:50,0 -DA:51,0 -DA:52,0 -DA:56,0 -DA:57,0 -DA:58,16 -DA:60,0 -DA:61,16 -DA:63,16 -DA:64,16 -DA:65,16 -DA:66,0 -DA:67,0 -DA:68,3 -DA:70,3 -DA:72,10 -DA:73,50 -DA:76,0 -DA:77,0 -DA:78,0 -DA:80,0 -DA:81,0 -DA:82,0 -DA:83,0 -DA:90,0 -DA:91,2 -DA:93,2 -DA:94,2 -DA:95,12 -DA:96,0 -DA:97,0 -DA:98,0 -DA:103,4 -DA:104,4 -DA:105,0 -DA:106,4 -DA:107,4 -DA:108,4 -DA:109,0 -DA:110,8 -DA:114,2 -DA:115,6 -DA:118,0 -DA:120,0 -DA:125,0 -DA:126,1 -DA:127,1 -DA:128,0 -DA:129,1 -DA:130,1 -DA:131,1 -DA:135,1 -DA:137,2 -DA:138,1 -DA:139,5 -DA:141,2 -DA:142,2 -DA:143,0 -DA:144,0 -DA:145,0 -DA:146,0 -DA:148,0 -DA:151,1 -DA:152,2 -DA:153,0 -DA:156,0 -DA:157,0 -DA:158,0 -DA:159,0 -DA:166,0 -DA:175,0 -DA:180,0 -DA:181,0 -DA:182,0 -DA:184,0 -DA:187,0 -LF:101 -LH:41 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/validation/schema_result.dart -DA:10,16 -DA:13,16 -DA:14,16 -DA:18,16 -DA:19,16 -DA:22,8 -DA:23,8 -DA:25,0 -DA:29,26 -DA:30,0 -DA:34,32 -DA:36,0 -DA:37,0 -DA:38,0 -DA:40,6 -DA:41,6 -DA:42,2 -DA:43,6 -DA:48,12 -DA:49,25 -DA:50,0 -DA:51,0 -DA:52,0 -DA:53,0 -DA:56,1 -DA:57,5 -DA:61,0 -DA:62,0 -DA:63,0 -DA:64,3 -DA:65,3 -DA:66,4 -DA:67,6 -DA:74,0 -DA:77,13 -DA:79,0 -DA:81,0 -DA:83,13 -DA:84,12 -DA:86,12 -DA:88,0 -DA:89,16 -DA:96,1 -DA:97,2 -DA:104,1 -DA:105,4 -DA:108,0 -DA:114,0 -DA:115,0 -DA:116,16 -DA:118,0 -DA:120,0 -DA:124,0 -DA:125,0 -DA:129,16 -DA:138,0 -LF:56 -LH:32 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/helpers.dart -DA:3,4 -DA:4,4 -DA:5,4 -DA:7,4 -DA:10,5 -DA:12,0 -DA:15,0 -DA:16,10 -DA:19,5 -DA:20,0 -DA:21,0 -DA:24,10 -DA:25,10 -DA:29,5 -DA:34,5 -DA:35,5 -DA:36,0 -DA:37,3 -DA:38,2 -DA:39,0 -DA:40,4 -DA:42,0 -DA:45,0 -DA:47,5 -DA:48,15 -DA:49,5 -DA:50,3 -DA:53,15 -DA:55,0 -DA:57,5 -DA:60,0 -DA:61,0 -DA:64,5 -DA:65,0 -DA:67,5 -DA:69,5 -DA:71,0 -DA:72,6 -DA:73,6 -DA:76,20 -DA:77,15 -DA:78,0 -DA:79,15 -DA:81,10 -DA:83,15 -DA:85,15 -DA:88,15 -DA:89,10 -DA:90,15 -DA:91,10 -DA:93,15 -DA:97,5 -DA:99,0 -DA:101,10 -DA:103,0 -DA:104,0 -DA:105,0 -DA:106,0 -DA:107,0 -DA:109,0 -DA:111,2 -DA:115,2 -DA:116,4 -DA:117,2 -DA:118,3 -DA:119,2 -DA:121,2 -DA:123,0 -DA:124,0 -DA:126,0 -DA:127,0 -DA:128,0 -DA:129,27 -DA:130,0 -DA:131,16 -DA:133,22 -DA:135,11 -DA:136,11 -DA:137,0 -DA:138,21 -DA:139,10 -DA:140,5 -DA:142,10 -DA:147,0 -DA:148,0 -DA:149,27 -DA:151,1 -DA:152,3 -DA:153,0 -DA:154,10 -DA:155,20 -DA:156,10 -DA:167,2 -DA:168,2 -DA:169,2 -DA:172,4 -DA:173,3 -DA:178,5 -DA:183,1 -DA:184,1 -DA:185,1 -DA:186,1 -DA:187,1 -DA:188,1 -DA:189,1 -DA:190,1 -LF:106 -LH:75 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/context.dart -DA:7,30 -DA:14,40 -DA:21,15 -DA:25,45 -DA:28,15 -DA:29,15 -DA:30,60 -DA:33,15 -DA:35,45 -DA:37,0 -DA:38,0 -DA:41,0 -DA:47,23 -DA:48,0 -LF:14 -LH:10 -end_of_record diff --git a/docs/api-reference/index.mdx b/docs/api-reference/index.mdx index b5318c6f..5d0ff753 100644 --- a/docs/api-reference/index.mdx +++ b/docs/api-reference/index.mdx @@ -198,30 +198,41 @@ the annotations below, run: dart run build_runner build ``` -### `@AckModel()` +### `@Schemable()` **Target**: Dart classes **Generates**: A schema constant only -Annotate a Dart class to automatically generate a validation schema. The generator analyzes your class fields and creates a schema constant named `Schema` (e.g., `userSchema`). +Annotate a Dart class to automatically generate a validation schema. The generator reads one constructor contract, turns its named parameters into schema fields, and creates a schema constant named `Schema` (for example, `userSchema`). **Parameters:** - `schemaName`: Custom name for the generated schema constant - `description`: Documentation string for the schema -- `additionalProperties`: Allow extra fields not defined in the class +- `additionalProperties`: Allow extra fields not defined in the class (passthrough behavior) - `additionalPropertiesField`: Field name to store extra properties (must be `Map`) -- `discriminatedKey`: Field name for type discrimination (use on abstract base classes) -- `discriminatedValue`: Discriminator value for this subtype (use on concrete implementations) +- `discriminatorKey`: Field name for type discrimination (use on sealed base classes) +- `discriminatorValue`: Discriminator value for this subtype (use on concrete implementations) +- `caseStyle`: Case transformation for constructor parameter names +- `useProviders`: Explicit `SchemaProvider` registrations for custom non-schemable types + +`@Schemable` libraries with a `part` directive must include an unprefixed +`import 'package:ack/ack.dart';` because generated code references `Ack` +directly. **Example:** ```dart -@AckModel(description: 'User profile') +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +part 'user.g.dart'; + +@Schemable(description: 'User profile') class User { final String name; final int age; - User({required this.name, required this.age}); + const User({required this.name, required this.age}); } // Generated: @@ -235,13 +246,46 @@ if (result.isOk) { } ``` +`@AckModel()` still works as a compatibility alias, but new code should prefer `@Schemable()`. + +### `@SchemaConstructor()` + +**Target**: Constructors + +Marks the constructor that defines the generated schema contract when the unnamed constructor is not the correct wire shape. The selected constructor must use named parameters only. + +### `@SchemaKey()` + +**Target**: Constructor parameters + +Overrides the generated schema key for a specific constructor parameter. + +### `@Description()` + +**Target**: Constructor parameters + +Attaches schema documentation to a generated field. + +### `SchemaProvider` + +**Target**: Provider classes registered through `@Schemable(useProviders: ...)` + +Supplies an explicit schema for a custom type that is not itself `@Schemable()`. Providers must: + +- be concrete classes +- have a const unnamed constructor with no parameters +- implement `SchemaProvider` +- return `AckSchema` from the `schema` getter + +Provider targets that already have generated schemas are rejected. Generated schemable types remain the source of truth for those models. + ### `@AckType()` **Target**: Schema variables and getters **Generates**: ONLY an extension type (schema must already exist) -Annotate an existing schema variable or getter to generate an extension type wrapper. Unlike `@AckModel`, this does not create the schema—it only adds type-safe access to a schema you've already written. +Annotate an existing schema variable or getter to generate an extension type wrapper. Unlike `@Schemable`, this does not create the schema. It only adds type-safe access to a schema you've already written. **Supported schema types:** - `Ack.object({...})` → Object extension types diff --git a/docs/core-concepts/configuration.mdx b/docs/core-concepts/configuration.mdx index 02702ffc..e499008f 100644 --- a/docs/core-concepts/configuration.mdx +++ b/docs/core-concepts/configuration.mdx @@ -97,40 +97,41 @@ final signUpSchema = Ack.object({ The `ack_generator` package provides automatic schema generation from annotated classes. This feature is production-ready. -To use code generation, annotate your classes with `@AckModel`. The generator creates a validation schema from your class structure. Use `@AckField` for field-level constraints: +To use code generation, annotate your classes with `@Schemable()`. The generator reads a constructor contract, not field annotations: ```dart import 'package:ack_annotations/ack_annotations.dart'; part 'user.g.dart'; -@AckModel( +@Schemable( description: 'User profile with flexible preferences', additionalProperties: true, additionalPropertiesField: 'preferences' ) class User { - @AckField(constraints: ['minLength(2)', 'maxLength(50)']) final String name; - - @AckField(constraints: ['email']) final String email; - - @AckField(constraints: ['min(18)']) final int? age; // Optional field // Collects extra properties not defined in the schema final Map preferences; - User({ - required this.name, - required this.email, - this.age, + const User({ + @MinLength(2) @MaxLength(50) required this.name, + @Email() required this.email, + @Min(18) this.age, this.preferences = const {}, }); } ``` +Key rules: + +- The selected constructor must use named parameters. +- Use `@SchemaConstructor()` to choose a non-default constructor. +- Use parameter annotations such as `@SchemaKey(...)` and `@Description(...)` to customize generated fields. + After running `dart run build_runner build`, the generator creates in `user.g.dart`: 1. **Schema constant**: `final userSchema = Ack.object({...});` @@ -172,5 +173,5 @@ annotated with `@AckType()` (see ## Summary - Ack primarily uses **schema-level configuration** through methods and parameters like `.minLength()`, `.optional()`, `additionalProperties: ...`. -- **Manual schema definition** is the recommended approach for production applications. +- Ack supports both **manual schema definition** and **constructor-driven schema generation** for production applications. - There is currently no central **global configuration** object provided by Ack itself. diff --git a/docs/core-concepts/json-serialization.mdx b/docs/core-concepts/json-serialization.mdx index b422c2ed..6ba48816 100644 --- a/docs/core-concepts/json-serialization.mdx +++ b/docs/core-concepts/json-serialization.mdx @@ -87,7 +87,8 @@ if (result.isOk) { ## Using Code Generation -The `ack_generator` package automatically creates schemas from your annotated classes: +The `ack_generator` package automatically creates schemas from `@Schemable()` +classes using the selected constructor's named parameters: ```dart // user.dart @@ -95,13 +96,13 @@ import 'package:ack_annotations/ack_annotations.dart'; part 'user.g.dart'; -@AckModel() +@Schemable() class User { final String name; final String email; final int age; - User({required this.name, required this.email, required this.age}); + const User({required this.name, required this.email, required this.age}); } ``` @@ -143,7 +144,7 @@ if (result.isOk) { ### Generating Extension Types from Standalone Schemas with `@AckType()` -When you write schemas manually (without a backing class), annotate the variable or getter with `@AckType()` to generate an extension type wrapper. Unlike `@AckModel`, this **does not create the schema**—it only adds type-safe access to your hand-written schema. +When you write schemas manually (without a backing class), annotate the variable or getter with `@AckType()` to generate an extension type wrapper. Unlike `@Schemable`, this **does not create the schema**—it only adds type-safe access to your hand-written schema. ```dart // user_schema.dart diff --git a/docs/core-concepts/typesafe-schemas.mdx b/docs/core-concepts/typesafe-schemas.mdx index c7a53441..38d9fded 100644 --- a/docs/core-concepts/typesafe-schemas.mdx +++ b/docs/core-concepts/typesafe-schemas.mdx @@ -4,34 +4,40 @@ title: TypeSafe Schemas Ack generates **extension types** that wrap validated data so you can work with strongly typed objects instead of raw `Map`. This guide shows -how `@AckModel()` and `@AckType()` annotations interact with the generator to +how `@Schemable()` and `@AckType()` annotations interact with the generator to produce these typed views. ## Overview -- `@AckModel()` goes on a **Dart class** and produces a **schema constant** (e.g., `userSchema`). +- `@Schemable()` goes on a **Dart class** and produces a **schema constant** (e.g., `userSchema`). - `@AckType()` goes on a **schema variable/getter** and produces an **extension type** for type-safe access (the schema already exists). The type name is derived from the variable name (e.g., `userSchema` → `UserType`). - Both annotations live in `package:ack_annotations` and are processed by the `ack_generator` builder via `dart run build_runner build`. -## Schema Generation from Classes with `@AckModel()` +## Schema Generation from Classes with `@Schemable()` -Use `@AckModel()` when you have a Dart class that should drive schema generation. The generator creates a validation schema based on your class structure. +Use `@Schemable()` when you have a Dart class that should drive schema generation. The generator creates a validation schema from the selected constructor's named parameters. ```dart +import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; part 'user.g.dart'; -@AckModel(description: 'Profile details') +@Schemable(description: 'Profile details') class User { final String name; final int age; - User({required this.name, required this.age}); + const User({required this.name, required this.age}); } ``` +`@Schemable` source files that declare a `part` must include an unprefixed +`import 'package:ack/ack.dart';` because generated code references `Ack` +directly. Unsupported custom types must be provided through +`@Schemable(useProviders: const [...])`. + Running `dart run build_runner build` writes `user.g.dart` with: - **Schema constant**: `final userSchema = Ack.object({...});` @@ -53,28 +59,27 @@ For type-safe access without manual casting, define a schema variable in source ### Discriminated Hierarchies -Annotate an abstract base with `discriminatedKey` and each subtype with a -`discriminatedValue` to receive a generated discriminated schema that maps +Annotate an abstract base with `discriminatorKey` and each subtype with a +`discriminatorValue` to receive a generated discriminated schema that maps discriminator values to subtype schemas. ```dart -@AckModel(discriminatedKey: 'type') -abstract class Shape { - String get type; +@Schemable(discriminatorKey: 'type') +sealed class Shape { + const Shape(); } -@AckModel(discriminatedValue: 'circle') +@Schemable(discriminatorValue: 'circle') class Circle extends Shape { - @override - String get type => 'circle'; final double radius; - Circle(this.radius); + + const Circle({required this.radius}); } ``` ## Typed Schemas from Hand-Written Definitions with `@AckType()` -Use `@AckType()` when you write schemas manually but still want type-safe access via extension types. Unlike `@AckModel`, this annotation does **not** generate a schema—it only generates an extension type wrapper for an existing schema. +Use `@AckType()` when you write schemas manually but still want type-safe access via extension types. Unlike `@Schemable`, this annotation does **not** generate a schema. It only generates an extension type wrapper for an existing schema. ```dart import 'package:ack/ack.dart'; @@ -103,7 +108,7 @@ After running `dart run build_runner build`, the part file contains: - **Extension type** `AddressType(Map _data)` for the nested schema. - **Static helpers** `parse`/`safeParse` so you can do `final user = UserType.parse(json);`. -**Key difference from @AckModel**: The schemas (`userSchema`, `addressSchema`) already exist in your source code. The annotation only adds the extension type layer. +**Key difference from `@Schemable`**: The schemas (`userSchema`, `addressSchema`) already exist in your source code. The annotation only adds the extension type layer. ### Supported Schema Shapes @@ -157,21 +162,23 @@ Extension types implement the underlying Dart type, so primitive wrappers still behave like `String`, `int`, etc., while object wrappers implement `Map`. -## Choosing Between `@AckModel` and `@AckType` +## Choosing Between `@Schemable` and `@AckType` | Annotation | Target | Generates Schema? | Generates Extension Type? | Use When | |------------|--------|-------------------|---------------------------|----------| -| `@AckModel` | Dart class | ✅ Yes | ❌ No | You have a class definition and want schema generation | +| `@Schemable` | Dart class | ✅ Yes | ❌ No | You have a class definition and want schema generation | | `@AckType` | Top-level schema variable/getter | ❌ No (uses existing) | ✅ Yes | You have a schema and want type-safe access | **Examples:** -- Use **`@AckModel`** when you have a Dart class (or class hierarchy) that should drive schema generation. +- Use **`@Schemable`** when you have a Dart class (or class hierarchy) that should drive schema generation. - Use **`@AckType`** on hand-written top-level schema variables or getters when you want type-safe extension type access. - **Note:** `@AckType` is not supported on classes or generated schemas. If you need extension types, define the schema directly in your source file. +Legacy `@AckModel()` still works as a compatibility alias, but new code should prefer `@Schemable()`. + ## Build Runner Checklist 1. Add `ack_generator` and `ack_annotations` to `pubspec.yaml`. diff --git a/docs/guides/migration-v1.mdx b/docs/guides/migration-v1.mdx index a2bba492..690eff53 100644 --- a/docs/guides/migration-v1.mdx +++ b/docs/guides/migration-v1.mdx @@ -243,12 +243,12 @@ if (result.isOk) { **Before (v0.x):** ```dart -@AckModel(discriminatedKey: 'type', model: true) +@AckModel(discriminatorKey: 'type', model: true) abstract class Animal { String get type; } -@AckModel(discriminatedValue: 'cat', model: true) +@AckModel(discriminatorValue: 'cat', model: true) class Cat extends Animal { @override String get type => 'cat'; @@ -259,7 +259,7 @@ class Cat extends Animal { Cat({required this.meow, this.lives = 9}); } -@AckModel(discriminatedValue: 'dog', model: true) +@AckModel(discriminatorValue: 'dog', model: true) class Dog extends Animal { @override String get type => 'dog'; @@ -284,12 +284,12 @@ if (result.isOk) { **After (v1.0):** ```dart -@AckModel(discriminatedKey: 'type') +@AckModel(discriminatorKey: 'type') abstract class Animal { String get type; } -@AckModel(discriminatedValue: 'cat') +@AckModel(discriminatorValue: 'cat') class Cat extends Animal { @override String get type => 'cat'; @@ -300,7 +300,7 @@ class Cat extends Animal { Cat({required this.meow, this.lives = 9}); } -@AckModel(discriminatedValue: 'dog') +@AckModel(discriminatorValue: 'dog') class Dog extends Animal { @override String get type => 'dog'; @@ -756,6 +756,39 @@ if (result.isOk) { ## Troubleshooting +### Error: "Undefined name 'Ack'" in generated `.g.dart` + +`@Schemable` generated code references `Ack` directly. If your source file uses a +`part` directive, add an unprefixed Ack import: + +```dart +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +part 'user.g.dart'; +``` + +Avoid prefix-only imports (for example `import 'package:ack/ack.dart' as ack;`) +in `@Schemable` source files. + +### Error: "Only named constructor parameters are supported" + +Constructor-driven generation only supports named parameters. Migrate positional +parameters to named parameters, or mark an alternate named constructor with +`@SchemaConstructor()`. + +### Error: "Unsupported type ... Annotate the type with @Schemable() or register a schema provider" + +For custom non-schemable types, register a typed provider: + +```dart +@Schemable(useProviders: const [MoneySchemaProvider]) +class Invoice { + const Invoice({required this.total}); + final Money total; +} +``` + ### Error: "The getter 'value' isn't defined" This error occurs when trying to access `.value` on a schema, which no longer exists in v1.0. diff --git a/example/lib/anyof_example.dart b/example/lib/anyof_example.dart index df72bb9d..247e32c4 100644 --- a/example/lib/anyof_example.dart +++ b/example/lib/anyof_example.dart @@ -22,7 +22,10 @@ class NumericUserId extends UserId { } /// Example 2: API Response that can return different types -@AckModel(description: 'API response with different possible payloads') +@Schemable( + description: 'API response with different possible payloads', + useProviders: const [ResponseDataSchemaProvider], +) class ApiResponse { final String status; final ResponseData data; // This would be the AnyOf field @@ -42,7 +45,39 @@ final responseDataSchema = Ack.anyOf([ listResponseSchema, ]); -@AckModel() +class ResponseDataSchemaProvider implements SchemaProvider { + const ResponseDataSchemaProvider(); + + @override + AckSchema get schema => responseDataSchema.transform( + (value) => switch (value) { + { + 'id': final String id, + 'name': final String name, + 'email': final String email, + } => + UserResponse(id: id, name: name, email: email), + {'code': final String code, 'message': final String message} => + ErrorResponse( + code: code, + message: message, + details: switch (value) { + {'details': final Map details} => details.cast(), + _ => null, + }, + ), + { + 'items': final List items, + 'total': final int total, + 'page': final int page, + } => + ListResponse(items: items.cast(), total: total, page: page), + _ => throw StateError('Unsupported ResponseData payload: $value'), + }, + ); +} + +@Schemable() class UserResponse extends ResponseData { final String id; final String name; @@ -55,7 +90,7 @@ class UserResponse extends ResponseData { }); } -@AckModel() +@Schemable() class ErrorResponse extends ResponseData { final String code; final String message; @@ -68,7 +103,7 @@ class ErrorResponse extends ResponseData { }); } -@AckModel() +@Schemable() class ListResponse extends ResponseData { final List items; final int total; @@ -82,7 +117,10 @@ class ListResponse extends ResponseData { } /// Example 3: Settings value that can be different types -@AckModel(description: 'Configuration setting with flexible value type') +@Schemable( + description: 'Configuration setting with flexible value type', + useProviders: const [SettingValueSchemaProvider], +) class Setting { final String key; final SettingValue value; // AnyOf: string, number, boolean, object @@ -102,6 +140,21 @@ final settingValueSchema = Ack.anyOf([ Ack.object({}, additionalProperties: true), // ObjectSetting ]); +class SettingValueSchemaProvider implements SchemaProvider { + const SettingValueSchemaProvider(); + + @override + AckSchema get schema => settingValueSchema.transform( + (value) => switch (value) { + final String text => StringSetting(text), + final double number => NumberSetting(number), + final bool enabled => BooleanSetting(enabled), + final Map settings => ObjectSetting(settings.cast()), + _ => throw StateError('Unsupported SettingValue payload: $value'), + }, + ); +} + class StringSetting extends SettingValue { final String value; const StringSetting(this.value); diff --git a/example/lib/anyof_example.g.dart b/example/lib/anyof_example.g.dart index b907c2e9..32918939 100644 --- a/example/lib/anyof_example.g.dart +++ b/example/lib/anyof_example.g.dart @@ -11,7 +11,7 @@ part of 'anyof_example.dart'; /// API response with different possible payloads final apiResponseSchema = Ack.object({ 'status': Ack.string(), - 'data': responseDataSchema, + 'data': (const ResponseDataSchemaProvider().schema as AckSchema), }); /// Generated schema for UserResponse @@ -39,5 +39,5 @@ final listResponseSchema = Ack.object({ /// Configuration setting with flexible value type final settingSchema = Ack.object({ 'key': Ack.string(), - 'value': settingValueSchema, + 'value': (const SettingValueSchemaProvider().schema as AckSchema), }); diff --git a/example/lib/described_model.dart b/example/lib/described_model.dart index 933a4b7f..e149cc54 100644 --- a/example/lib/described_model.dart +++ b/example/lib/described_model.dart @@ -3,35 +3,30 @@ import 'package:ack_annotations/ack_annotations.dart'; part 'described_model.g.dart'; -@AckModel(description: 'User profile with comprehensive field descriptions') +@Schemable(description: 'User profile with comprehensive field descriptions') class UserProfile { - @AckField(description: 'Unique identifier for the user') final String id; - @AckField(description: 'User\'s full display name') - @MinLength(2) final String name; - @AckField(description: 'Primary email address for communication') - @Email() final String email; - @AckField(description: 'User age in years (must be 13 or older)') - @Min(13) final int age; - @AckField(description: 'Optional profile picture URL') - @Url() final String? avatarUrl; final String? bio; // No description - should render normally UserProfile({ - required this.id, - required this.name, + @Description('Unique identifier for the user') required this.id, + @Description('User\'s full display name') @MinLength(2) required this.name, + @Description('Primary email address for communication') + @Email() required this.email, + @Description('User age in years (must be 13 or older)') + @Min(13) required this.age, - this.avatarUrl, + @Description('Optional profile picture URL') @Url() this.avatarUrl, this.bio, }); } diff --git a/example/lib/discriminated_example.dart b/example/lib/discriminated_example.dart index 897a979b..4f965a4e 100644 --- a/example/lib/discriminated_example.dart +++ b/example/lib/discriminated_example.dart @@ -4,13 +4,13 @@ import 'package:ack_annotations/ack_annotations.dart'; part 'discriminated_example.g.dart'; // Base discriminated class for animals -@AckModel(discriminatedKey: 'type') -abstract class Animal { +@Schemable(discriminatorKey: 'type') +sealed class Animal { String get type; } // Concrete implementations with discriminator values -@AckModel(discriminatedValue: 'cat') +@Schemable(discriminatorValue: 'cat') class Cat extends Animal { @override String get type => 'cat'; @@ -21,7 +21,7 @@ class Cat extends Animal { Cat({required this.meow, this.lives = 9}); } -@AckModel(discriminatedValue: 'dog') +@Schemable(discriminatorValue: 'dog') class Dog extends Animal { @override String get type => 'dog'; @@ -32,7 +32,7 @@ class Dog extends Animal { Dog({required this.bark, required this.breed}); } -@AckModel(discriminatedValue: 'bird') +@Schemable(discriminatorValue: 'bird') class Bird extends Animal { @override String get type => 'bird'; @@ -44,13 +44,13 @@ class Bird extends Animal { } // Another discriminated hierarchy for shapes -@AckModel(discriminatedKey: 'kind') -abstract class Shape { +@Schemable(discriminatorKey: 'kind') +sealed class Shape { String get kind; double get area; } -@AckModel(discriminatedValue: 'circle') +@Schemable(discriminatorValue: 'circle') class Circle extends Shape { @override String get kind => 'circle'; @@ -63,7 +63,7 @@ class Circle extends Shape { double get area => 3.14159 * radius * radius; } -@AckModel(discriminatedValue: 'rectangle') +@Schemable(discriminatorValue: 'rectangle') class Rectangle extends Shape { @override String get kind => 'rectangle'; diff --git a/example/lib/discriminated_example.g.dart b/example/lib/discriminated_example.g.dart index fd37a693..231b07d7 100644 --- a/example/lib/discriminated_example.g.dart +++ b/example/lib/discriminated_example.g.dart @@ -23,7 +23,7 @@ final shapeSchema = Ack.discriminated( final catSchema = Ack.object({ 'type': Ack.literal('cat'), 'meow': Ack.boolean(), - 'lives': Ack.integer(), + 'lives': Ack.integer().optional(), }); /// Generated schema for Dog diff --git a/example/lib/edge_case_models.dart b/example/lib/edge_case_models.dart index fd2372b2..4f81dec5 100644 --- a/example/lib/edge_case_models.dart +++ b/example/lib/edge_case_models.dart @@ -5,7 +5,7 @@ part 'edge_case_models.g.dart'; // Test deeply nested generic types - INTENTIONALLY COMMENTED OUT // This demonstrates complex generics that are NOT supported by the generator -// @AckModel(model: true) +// @Schemable() // class ComplexGenericModel { // final String id; // final List>> nestedData; @@ -20,7 +20,7 @@ part 'edge_case_models.g.dart'; // Test circular reference handling - INTENTIONALLY COMMENTED OUT // Self-referencing models are not yet supported -// @AckModel(model: true) +// @Schemable() // class NodeModel { // final String id; // final String name; @@ -36,7 +36,7 @@ part 'edge_case_models.g.dart'; // } // Test very large number of fields (performance) - THIS WORKS -@AckModel() +@Schemable() class LargeFieldModel { final String field1; final String field2; @@ -84,24 +84,20 @@ class LargeFieldModel { } // Test special characters in field names - THIS WORKS -@AckModel() +@Schemable() class SpecialFieldsModel { - @AckField(jsonKey: 'user-id') final String userId; - @AckField(jsonKey: 'full_name') final String fullName; - @AckField(jsonKey: 'email.address') final String emailAddress; - @AckField(jsonKey: 'meta:data') final String metadata; SpecialFieldsModel({ - required this.userId, - required this.fullName, - required this.emailAddress, - required this.metadata, + @SchemaKey('user-id') required this.userId, + @SchemaKey('full_name') required this.fullName, + @SchemaKey('email.address') required this.emailAddress, + @SchemaKey('meta:data') required this.metadata, }); } diff --git a/example/lib/mixed_examples.dart b/example/lib/mixed_examples.dart index e2192a20..cc64e67d 100644 --- a/example/lib/mixed_examples.dart +++ b/example/lib/mixed_examples.dart @@ -5,7 +5,7 @@ import 'package:ack_annotations/ack_annotations.dart'; part 'mixed_examples.g.dart'; /// Example 1: Schema-only (default behavior) -@AckModel(description: 'Basic user model - generates only schema variable') +@Schemable(description: 'Basic user model - generates only schema variable') class BasicUser { final String id; final String username; @@ -15,7 +15,7 @@ class BasicUser { } /// Example 2: Enhanced user with validation -@AckModel(description: 'Enhanced user with comprehensive validation') +@Schemable(description: 'Enhanced user with comprehensive validation') class EnhancedUser { final String id; final String username; @@ -31,20 +31,23 @@ class EnhancedUser { } /// Example 3: Enum example with schema-only -@AckModel(description: 'Order with status enum - schema only') +@Schemable(description: 'Order with status enum - schema only') class Order { final String id; - - @EnumString(['pending', 'processing', 'shipped', 'delivered', 'cancelled']) final String status; final double total; - Order({required this.id, required this.status, required this.total}); + Order({ + required this.id, + @EnumString(['pending', 'processing', 'shipped', 'delivered', 'cancelled']) + required this.status, + required this.total, + }); } /// Example 4: Complex nested model -@AckModel( +@Schemable( description: 'Blog post with author - demonstrates nested models', additionalProperties: true, additionalPropertiesField: 'metadata', @@ -70,29 +73,23 @@ class BlogPost { } /// Example 5: Model with various constraints -@AckModel(description: 'Product inventory with comprehensive constraints') +@Schemable(description: 'Product inventory with comprehensive constraints') class ProductInventory { - @MinLength(3) - @MaxLength(50) final String sku; - @Min(0) - @Max(10000) final int quantity; - @Min(0.01) final double unitPrice; - @Pattern(r'^\d{4}-\d{2}-\d{2}$') final String lastRestocked; final bool isAvailable; ProductInventory({ - required this.sku, - required this.quantity, - required this.unitPrice, - required this.lastRestocked, + @MinLength(3) @MaxLength(50) required this.sku, + @Min(0) @Max(10000) required this.quantity, + @Min(0.01) required this.unitPrice, + @Pattern(r'^\d{4}-\d{2}-\d{2}$') required this.lastRestocked, required this.isAvailable, }); } diff --git a/example/lib/product_model.dart b/example/lib/product_model.dart index beaaecb3..a51c0ea9 100644 --- a/example/lib/product_model.dart +++ b/example/lib/product_model.dart @@ -3,27 +3,22 @@ import 'package:ack_annotations/ack_annotations.dart'; part 'product_model.g.dart'; -@AckModel( +@Schemable( description: 'A product model with validation', additionalProperties: true, additionalPropertiesField: 'metadata', ) class Product { - @MinLength(1) final String id; - @MinLength(3) final String name; final String description; - @Min(0.01) final double price; - @Email() final String? contactEmail; - @Url() final String? imageUrl; final Category category; @@ -34,36 +29,33 @@ class Product { final String? updatedAt; - @Positive() final int stockQuantity; - @EnumString(['draft', 'published', 'archived']) final String status; - @Pattern(r'^[A-Z]{2,3}-\d{4}$') final String productCode; final Map metadata; Product({ - required this.id, - required this.name, + @MinLength(1) required this.id, + @MinLength(3) required this.name, required this.description, - required this.price, - this.contactEmail, - this.imageUrl, + @Min(0.01) required this.price, + @Email() this.contactEmail, + @Url() this.imageUrl, required this.category, required this.releaseDate, required this.createdAt, this.updatedAt, - required this.stockQuantity, - required this.status, - required this.productCode, + @Positive() required this.stockQuantity, + @EnumString(['draft', 'published', 'archived']) required this.status, + @Pattern(r'^[A-Z]{2,3}-\d{4}$') required this.productCode, this.metadata = const {}, }); } -@AckModel( +@Schemable( description: 'A category for organizing products', additionalProperties: true, additionalPropertiesField: 'metadata', diff --git a/example/lib/simple_examples.dart b/example/lib/simple_examples.dart index 7562083e..bd930348 100644 --- a/example/lib/simple_examples.dart +++ b/example/lib/simple_examples.dart @@ -5,7 +5,7 @@ part 'simple_examples.g.dart'; /// Example 1: User with Additional Properties /// Shows how to store flexible user preferences -@AckModel( +@Schemable( description: 'User with flexible preferences', additionalProperties: true, additionalPropertiesField: 'preferences', @@ -28,7 +28,7 @@ class User { /// Example 2: Product with Metadata /// Shows how to store product variants and SEO data -@AckModel( +@Schemable( description: 'Product with flexible metadata', additionalProperties: true, additionalPropertiesField: 'metadata', @@ -51,7 +51,7 @@ class Product { /// Example 3: Simple Model (No Additional Properties) /// Shows traditional fixed schema for comparison -@AckModel(description: 'Simple model without additional properties') +@Schemable(description: 'Simple model without additional properties') class SimpleItem { final String id; final String name; diff --git a/example/lib/simple_examples.g.dart b/example/lib/simple_examples.g.dart index 62e5862b..231c698e 100644 --- a/example/lib/simple_examples.g.dart +++ b/example/lib/simple_examples.g.dart @@ -28,5 +28,5 @@ final productSchema = Ack.object({ final simpleItemSchema = Ack.object({ 'id': Ack.string(), 'name': Ack.string(), - 'active': Ack.boolean(), + 'active': Ack.boolean().optional(), }); diff --git a/example/lib/special_types_model.dart b/example/lib/special_types_model.dart index 79cb9616..314c803b 100644 --- a/example/lib/special_types_model.dart +++ b/example/lib/special_types_model.dart @@ -3,7 +3,7 @@ import 'package:ack_annotations/ack_annotations.dart'; part 'special_types_model.g.dart'; -@AckModel() +@Schemable() class Event { final String name; final DateTime timestamp; diff --git a/example/lib/status_model.dart b/example/lib/status_model.dart index 64dde3e9..508eee03 100644 --- a/example/lib/status_model.dart +++ b/example/lib/status_model.dart @@ -3,10 +3,11 @@ import 'package:ack_annotations/ack_annotations.dart'; part 'status_model.g.dart'; -@AckModel(description: 'A model demonstrating enum field validation') +@Schemable(description: 'A model demonstrating enum field validation') class StatusModel { - @EnumString(['active', 'inactive', 'pending']) final String simpleStatus; - StatusModel({required this.simpleStatus}); + StatusModel({ + @EnumString(['active', 'inactive', 'pending']) required this.simpleStatus, + }); } diff --git a/example/lib/test_extension_types.dart b/example/lib/test_extension_types.dart index a1d7bf70..8be962e0 100644 --- a/example/lib/test_extension_types.dart +++ b/example/lib/test_extension_types.dart @@ -4,7 +4,7 @@ import 'package:ack_annotations/ack_annotations.dart'; part 'test_extension_types.g.dart'; /// Simple model to test basic extension type generation -@AckModel() +@Schemable() class SimpleUser { final String name; final int age; @@ -14,7 +14,7 @@ class SimpleUser { } /// Model with nested types to test dependency ordering -@AckModel() +@Schemable() class Address { final String street; final String city; @@ -23,7 +23,7 @@ class Address { Address({required this.street, required this.city, required this.country}); } -@AckModel() +@Schemable() class UserWithAddress { final String name; final Address address; @@ -37,7 +37,7 @@ class UserWithAddress { } /// Model with collections to test list handling -@AckModel() +@Schemable() class BlogPost { final String title; final String content; diff --git a/example/lib/validation_test_model.dart b/example/lib/validation_test_model.dart index a4181646..9c0ef5b9 100644 --- a/example/lib/validation_test_model.dart +++ b/example/lib/validation_test_model.dart @@ -5,7 +5,7 @@ part 'validation_test_model.g.dart'; // This model demonstrates complex generics that are NOT supported by the generator // This is intentionally commented out to avoid build errors -// @AckModel(model: true) +// @Schemable() // class ComplexValidationModel { // final String id; // @@ -19,7 +19,7 @@ part 'validation_test_model.g.dart'; // } // Simple model that DOES work with the generator -@AckModel() +@Schemable() class SimpleValidationModel { final String id; final String name; diff --git a/example/test/comprehensive_model_test.dart b/example/test/comprehensive_model_test.dart index ec006f2f..09b1a5b8 100644 --- a/example/test/comprehensive_model_test.dart +++ b/example/test/comprehensive_model_test.dart @@ -6,9 +6,8 @@ import 'package:test/test.dart'; void main() { group('Comprehensive Model Examples', () { - group('Schema-only approach (model: false)', () { + group('Schema-first validation approach', () { test('simple examples without SchemaModel', () { - // User schema (model: false) final userData = { 'id': 'user_1', 'name': 'John Doe', @@ -19,7 +18,7 @@ void main() { final user = simple.userSchema.parse(userData) as Map; expect(user['id'], equals('user_1')); - // In schema-only mode (model: false), additional properties stay at top level + // Additional properties stay at top level in the validated map output. expect(user['theme'], equals('dark')); expect(user['language'], equals('en')); }); diff --git a/llms.txt b/llms.txt index 17e80649..e7fcbc30 100644 --- a/llms.txt +++ b/llms.txt @@ -575,13 +575,13 @@ For polymorphic data validation based on a discriminator field: ```dart // Base class with discriminator key -@AckModel(discriminatedKey: 'type') +@AckModel(discriminatorKey: 'type') abstract class Animal { String get type; } // Concrete implementations with discriminator values -@AckModel(discriminatedValue: 'cat') +@AckModel(discriminatorValue: 'cat') class Cat extends Animal { @override String get type => 'cat'; @@ -590,7 +590,7 @@ class Cat extends Animal { Cat({required this.meow, this.lives = 9}); } -@AckModel(discriminatedValue: 'dog') +@AckModel(discriminatorValue: 'dog') class Dog extends Animal { @override String get type => 'dog'; @@ -898,7 +898,7 @@ dart run build_runner build --delete-conflicting-outputs ### `@AckModel(...)` - `schemaName`: overrides generated schema variable name -- `discriminatedKey` and `discriminatedValue`: configure discriminated models +- `discriminatorKey` and `discriminatorValue`: configure discriminated models - `additionalProperties` and `additionalPropertiesField`: control additional property behavior --- @@ -1106,7 +1106,7 @@ print(user.name); // Direct typed access | Class with constraints | `@AckModel` | Class annotation | | Reusable schema variable | `@AckType` | Variable annotation | | Type-safe validated data | `@AckType` | Generates extension type | -| Polymorphic/discriminated from classes | `@AckModel` | With `discriminatedKey`/`discriminatedValue` | +| Polymorphic/discriminated from classes | `@AckModel` | With `discriminatorKey`/`discriminatorValue` | | Typed discriminated wrappers | `@AckType` | `Ack.discriminated(...)` with `@AckType` object branches | ### Naming Conventions diff --git a/packages/ack_annotations/README.md b/packages/ack_annotations/README.md index 64d12842..2e207907 100644 --- a/packages/ack_annotations/README.md +++ b/packages/ack_annotations/README.md @@ -1,148 +1,179 @@ # ack_annotations -Annotation package for the Ack validation ecosystem. Use these annotations on your Dart data classes to drive code generation with `ack_generator` and produce strongly typed validation schemas. +Annotations for constructor-driven Ack schema generation. ---- +Use `ack_annotations` with `ack_generator` to derive `Ack.object()` schemas from + Dart models, constructor parameters, and explicit schema providers. -## Installation - -Add to your `pubspec.yaml` (check [pub.dev](https://pub.dev/packages/ack_annotations) for the latest versions): +## Install ```yaml dependencies: - ack_annotations: ^1.0.0-beta.7 + ack_annotations: ^1.0.0-beta.9 dev_dependencies: - ack_generator: ^1.0.0-beta.7 + ack_generator: ^1.0.0-beta.9 build_runner: ^2.4.0 ``` -Or use the Dart CLI: - -```bash -dart pub add ack_annotations -dart pub add --dev ack_generator build_runner -``` - ---- - ## Quick Start ```dart +import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -@AckModel() +part 'product.g.dart'; + +@Schemable() class Product { final String name; final double price; + final String? description; - Product({required this.name, required this.price}); + const Product({ + required this.name, + required this.price, + this.description, + }); } ``` -Run the generator: +When using a `part` directive with `@Schemable`, include an unprefixed +`import 'package:ack/ack.dart';` because generated code references `Ack` +directly. + +Generate the schema: ```bash dart run build_runner build --delete-conflicting-outputs ``` -The generator emits a matching `productSchema` that you can access from `ack_generator` output. +The generator emits `productSchema`. ---- +## Constructor Contract -## `@AckModel` +`@Schemable()` reads one constructor and turns its named parameters into schema + properties. -Annotate classes that need schema generation. +- Use the unnamed constructor by default. +- Mark a different constructor with `@SchemaConstructor()`. +- Use only named parameters in the selected constructor. +- Apply parameter metadata on constructor parameters, not on fields. -Key options: +```dart +@Schemable() +class User { + final String name; + final String email; + + const User._({required this.name, required this.email}); -- `schemaName`: override the generated schema identifier. -- `description`: surface documentation in the generated schema. -- `additionalProperties`: allow unmodelled JSON fields. -- `additionalPropertiesField`: capture extra properties in a `Map` field. -- `discriminatedKey`: configure the discriminator field on base classes (for unions). -- `discriminatedValue`: declare the discriminator value on concrete subclasses. + @SchemaConstructor() + const User.fromApi({ + @SchemaKey('full_name') required this.name, + @Description('Primary email address') required this.email, + }); +} +``` -Example (discriminated union): +## Parameter Annotations + +Use constructor-parameter annotations to control the generated schema. + +- `@SchemaKey('wire_name')`: override the property name +- `@Description('...')`: attach schema documentation +- `@MinLength`, `@MaxLength`, `@Email`, `@Url`, `@Pattern` +- `@Min`, `@Max`, `@Positive`, `@MultipleOf` +- `@MinItems`, `@MaxItems`, `@EnumString` ```dart -@AckModel(discriminatedKey: 'type') -abstract class Notification { - String get type; -} +@Schemable() +class SignupRequest { + final String username; + final String email; + final int age; -@AckModel(discriminatedValue: 'email') -class EmailNotification extends Notification { - @override - String get type => 'email'; - final String subject; - EmailNotification({required this.subject}); + const SignupRequest({ + @MinLength(3) @MaxLength(20) required this.username, + @Email() required this.email, + @Min(13) required this.age, + }); } ``` ---- +## Typed Schema Providers -## `@AckField` +Register a `SchemaProvider` when a constructor parameter uses a custom type + that is not itself `@Schemable()`. -Annotate individual fields to fine-tune schema generation. +`SchemaProvider` must return `AckSchema`. If the wire shape differs from + `T`, return a transformed schema. + +Provider targets that are themselves `@Schemable()` are rejected. If you need +composition over generated schemas, keep the provider target non-schemable and +reference generated schemas inside the provider implementation. ```dart -@AckModel() -class User { - @AckField(constraints: ['minLength(1)', 'maxLength(50)']) - final String name; +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; - @AckField(jsonKey: 'primary_email', constraints: ['email']) - final String email; +class Money { + final int cents; + const Money(this.cents); +} - @AckField(requiredMode: AckFieldRequiredMode.required) - final bool marketingOptIn; +class MoneySchemaProvider implements SchemaProvider { + const MoneySchemaProvider(); - User({ - required this.name, - required this.email, - required this.marketingOptIn, - }); + @override + AckSchema get schema => Ack.object({ + 'cents': Ack.integer(), + }).transform((value) => Money(value!['cents'] as int)); +} + +@Schemable(useProviders: const [MoneySchemaProvider]) +class Invoice { + final Money total; + + const Invoice({required this.total}); } ``` -Field options: -- `requiredMode`: tri-state requiredness (`auto`, `required`, `optional`). -- `jsonKey`: map to a different JSON key. -- `description`: add documentation to the generated schema. -- `constraints`: string-based helpers for quick validation rules. +## Discriminated Models ---- +Use sealed roots for discriminated unions. -## Constraint Annotations +```dart +@Schemable(discriminatorKey: 'type') +sealed class Notification { + const Notification(); +} -`ack_annotations` also exposes typed constraint annotations for readability. +@Schemable(discriminatorValue: 'email') +class EmailNotification extends Notification { + final String subject; + + const EmailNotification({required this.subject}); +} +``` -| Category | Annotation | Generated constraint | -| --- | --- | --- | -| String | `@MinLength(n)` / `@MaxLength(n)` | `.minLength(n)` / `.maxLength(n)` | -| String | `@Email()` / `@Url()` | `.email()` / `.url()` | -| Pattern | `@Pattern('^[A-Z]')` | `.pattern(...)` | -| Number | `@Min(0)` / `@Max(10)` | `.min(0)` / `.max(10)` | -| Number | `@Positive()` | `.positive()` | -| Number | `@MultipleOf(5)` | `.multipleOf(5)` | -| List | `@MinItems(1)` / `@MaxItems(10)` | `.minLength(1)` / `.maxLength(10)` | -| Enum | `@EnumString(['draft','published'])` | `.enumString([...])` | +## Additional Properties (Passthrough) -Mix and match annotation-based constraints with the string list syntax from `@AckField`—both map to the same generator capabilities. +Use `@Schemable(additionalProperties: true)` to generate +`Ack.object(..., additionalProperties: true)` (equivalent passthrough behavior +for unknown keys). ---- +## Compatibility -## Working With build_runner +These APIs still exist for migration, but new code should avoid them: -1. Make sure all Ack packages are on matching versions. -2. Run `dart run build_runner build --delete-conflicting-outputs` after changing annotated classes. -3. For continuous development, `dart run build_runner watch` keeps schemas in sync. +- `@AckModel()` and `ackModel` +- `AckField` ---- +`AckField` is deprecated and no longer drives generation. Use constructor + parameter annotations instead. -## Further Reading +## Related Packages -- Core concepts & runtime API: [`ack` README](../../README.md) -- Generator workflows: [`ack_generator` README](../ack_generator/README.md) -- Full 1.0 migration plan: [`MIGRATION.md`](../../MIGRATION.md) +- Runtime schemas: [`ack`](../../README.md) +- Code generation: [`ack_generator`](../ack_generator/README.md) diff --git a/packages/ack_annotations/lib/ack_annotations.dart b/packages/ack_annotations/lib/ack_annotations.dart index c5ad5223..ba6afcce 100644 --- a/packages/ack_annotations/lib/ack_annotations.dart +++ b/packages/ack_annotations/lib/ack_annotations.dart @@ -1,5 +1,6 @@ library ack_annotations; +export 'src/schemable.dart'; export 'src/ack_model.dart'; export 'src/ack_field.dart'; export 'src/ack_type.dart'; diff --git a/packages/ack_annotations/lib/src/ack_field.dart b/packages/ack_annotations/lib/src/ack_field.dart index a252152d..f189b3cc 100644 --- a/packages/ack_annotations/lib/src/ack_field.dart +++ b/packages/ack_annotations/lib/src/ack_field.dart @@ -5,9 +5,15 @@ import 'package:meta/meta_meta.dart'; /// - [auto]: Infer from nullability and default value. /// - [required]: Always required. /// - [optional]: Always optional. +@Deprecated( + 'AckField is no longer used by the generator. Use constructor parameters instead.', +) enum AckFieldRequiredMode { auto, required, optional } /// Annotation to configure field generation +@Deprecated( + 'AckField is no longer used by the generator. Use constructor parameter annotations instead.', +) @Target({TargetKind.field}) class AckField { /// Requiredness mode for this field. diff --git a/packages/ack_annotations/lib/src/ack_model.dart b/packages/ack_annotations/lib/src/ack_model.dart index 71f4d416..07d3ba08 100644 --- a/packages/ack_annotations/lib/src/ack_model.dart +++ b/packages/ack_annotations/lib/src/ack_model.dart @@ -1,103 +1,39 @@ import 'package:meta/meta_meta.dart'; -/// Annotation to mark a class for schema generation. +import 'schemable.dart'; + +/// Deprecated compatibility alias for [Schemable]. +/// +/// `AckModel` now follows the same constructor-driven contract as `Schemable`: /// -/// Generates a schema constant for validating data against your class structure. -/// For type-safe access to validated data, use [@AckType] on schema variables. +/// - the selected constructor defines the schema shape +/// - only named constructor parameters are supported +/// - field-level metadata is no longer part of the model contract +/// - discriminated roots must be `sealed` /// -/// This annotation can be used in two main ways: +/// New code should prefer `@Schemable()`. /// -/// ## Regular Models -/// Generate schema validation for a class: /// ```dart -/// @AckModel() +/// @Schemable() /// class User { /// final String name; -/// final int age; -/// User({required this.name, required this.age}); -/// } -/// ``` /// -/// ## Discriminated Types (Polymorphic Models) -/// Generate discriminated schemas for inheritance hierarchies: -/// -/// ### Base Class (Abstract) -/// Use [discriminatedKey] to specify the field that determines the type: -/// ```dart -/// @AckModel(discriminatedKey: 'type') -/// abstract class Animal { -/// String get type; +/// const User({required this.name}); /// } /// ``` -/// -/// ### Concrete Implementations -/// Use [discriminatedValue] to specify this class's discriminator value: -/// ```dart -/// @AckModel(discriminatedValue: 'cat') -/// class Cat extends Animal { -/// @override -/// String get type => 'cat'; -/// final bool meow; -/// Cat({required this.meow}); -/// } -/// -/// @AckModel(discriminatedValue: 'dog') -/// class Dog extends Animal { -/// @override -/// String get type => 'dog'; -/// final bool bark; -/// Dog({required this.bark}); -/// } -/// ``` -/// -/// This generates: -/// ```dart -/// final animalSchema = Ack.discriminated( -/// discriminatorKey: 'type', -/// schemas: { -/// 'cat': catSchema, -/// 'dog': dogSchema, -/// }, -/// ); -/// ``` +@Deprecated('Use @Schemable() instead.') @Target({TargetKind.classType}) -class AckModel { - /// Optional custom schema class name - final String? schemaName; - - /// Optional description for the schema - final String? description; - - /// Whether to allow additional properties not defined in the schema - final bool additionalProperties; - - /// The name of the field that should store additional properties - /// Must be a `Map` field in your class - final String? additionalPropertiesField; - - /// Field name to use for discriminating between types in a polymorphic hierarchy. - /// Use this on abstract/base classes to indicate which field contains the type discriminator. - /// Example: @AckModel(discriminatedKey: 'type') - /// Cannot be used together with [discriminatedValue]. - final String? discriminatedKey; - - /// The discriminator value this class represents in a polymorphic hierarchy. - /// Use this on concrete classes that extend an abstract class with [discriminatedKey]. - /// Example: @AckModel(discriminatedValue: 'cat') - /// Cannot be used together with [discriminatedKey]. - final String? discriminatedValue; - +class AckModel extends Schemable { const AckModel({ - this.schemaName, - this.description, - this.additionalProperties = false, - this.additionalPropertiesField, - this.discriminatedKey, - this.discriminatedValue, - }) : assert( - discriminatedKey == null || discriminatedValue == null, - 'discriminatedKey and discriminatedValue cannot be used together', - ); + super.schemaName, + super.description, + super.additionalProperties = false, + super.additionalPropertiesField, + super.discriminatorKey, + super.discriminatorValue, + super.caseStyle = CaseStyle.none, + super.useProviders = const [], + }); } /// Convenience constant for simple cases without options. @@ -107,13 +43,19 @@ class AckModel { /// @ackModel /// class User { /// final String name; -/// final int age; +/// +/// const User({required this.name}); /// } /// ``` /// /// For custom options, use the class constructor: /// ```dart /// @AckModel(description: 'A user model') -/// class User { ... } +/// class User { +/// final String name; +/// +/// const User({required this.name}); +/// } /// ``` +@Deprecated('Use `schemable` instead.') const ackModel = AckModel(); diff --git a/packages/ack_annotations/lib/src/ack_type.dart b/packages/ack_annotations/lib/src/ack_type.dart index e94cf139..cf70c60e 100644 --- a/packages/ack_annotations/lib/src/ack_type.dart +++ b/packages/ack_annotations/lib/src/ack_type.dart @@ -3,7 +3,7 @@ import 'package:meta/meta_meta.dart'; /// Annotation to generate extension types for validated data. /// /// **Note:** This annotation should only be used on schema variables and getters, -/// not on classes. Use [@AckModel] to generate schemas from classes; if you need +/// not on classes. Use [@Schemable] to generate schemas from classes; if you need /// extension types, define the schema in source and annotate it with [@AckType]. /// /// Can be applied to: @@ -235,7 +235,7 @@ import 'package:meta/meta_meta.dart'; /// not change the generated representation type. /// - **Dart version**: Requires Dart 3.3+ for extension type support /// -/// See also: [AckModel], [AckField] +/// See also: [Schemable], [SchemaProvider] @Target({TargetKind.topLevelVariable, TargetKind.getter}) class AckType { /// Optional custom name for the generated extension type. diff --git a/packages/ack_annotations/lib/src/constraints.dart b/packages/ack_annotations/lib/src/constraints.dart index 19a6f294..220ad896 100644 --- a/packages/ack_annotations/lib/src/constraints.dart +++ b/packages/ack_annotations/lib/src/constraints.dart @@ -5,31 +5,31 @@ import 'package:meta/meta_meta.dart'; // ============================================================================ /// String length constraints -@Target({TargetKind.field}) +@Target({TargetKind.parameter}) class MinLength { final int length; const MinLength(this.length); } -@Target({TargetKind.field}) +@Target({TargetKind.parameter}) class MaxLength { final int length; const MaxLength(this.length); } /// String format constraints -@Target({TargetKind.field}) +@Target({TargetKind.parameter}) class Email { const Email(); } -@Target({TargetKind.field}) +@Target({TargetKind.parameter}) class Url { const Url(); } /// String pattern constraints -@Target({TargetKind.field}) +@Target({TargetKind.parameter}) class Pattern { final String pattern; const Pattern(this.pattern); @@ -39,24 +39,24 @@ class Pattern { // NUMERIC CONSTRAINTS // ============================================================================ -@Target({TargetKind.field}) +@Target({TargetKind.parameter}) class Min { final num value; const Min(this.value); } -@Target({TargetKind.field}) +@Target({TargetKind.parameter}) class Max { final num value; const Max(this.value); } -@Target({TargetKind.field}) +@Target({TargetKind.parameter}) class Positive { const Positive(); } -@Target({TargetKind.field}) +@Target({TargetKind.parameter}) class MultipleOf { final num value; const MultipleOf(this.value); @@ -66,13 +66,13 @@ class MultipleOf { // LIST CONSTRAINTS // ============================================================================ -@Target({TargetKind.field}) +@Target({TargetKind.parameter}) class MinItems { final int count; const MinItems(this.count); } -@Target({TargetKind.field}) +@Target({TargetKind.parameter}) class MaxItems { final int count; const MaxItems(this.count); @@ -86,7 +86,7 @@ class MaxItems { /// Generates: .enumString(['value1', 'value2', ...]) /// Usage: @EnumString(['draft', 'published', 'archived']) /// This is for validating string fields against a set of allowed string values -@Target({TargetKind.field}) +@Target({TargetKind.parameter}) class EnumString { final List values; const EnumString(this.values); diff --git a/packages/ack_annotations/lib/src/schemable.dart b/packages/ack_annotations/lib/src/schemable.dart new file mode 100644 index 00000000..5bce194f --- /dev/null +++ b/packages/ack_annotations/lib/src/schemable.dart @@ -0,0 +1,84 @@ +import 'package:ack/ack.dart'; +import 'package:meta/meta_meta.dart'; + +/// Controls how constructor parameter names are converted into schema keys. +enum CaseStyle { none, camelCase, pascalCase, snakeCase, paramCase } + +/// Marks a class as schema-generating using its constructor contract. +@Target({TargetKind.classType}) +class Schemable { + /// Optional custom schema class name. + final String? schemaName; + + /// Optional description for the schema. + final String? description; + + /// Whether to allow additional properties not defined in the schema. + final bool additionalProperties; + + /// The field that stores additional properties on the model. + final String? additionalPropertiesField; + + /// Discriminator key for sealed union roots. + final String? discriminatorKey; + + /// Discriminator value for concrete union leaves. + final String? discriminatorValue; + + /// Case style to apply to parameter names before schema generation. + final CaseStyle caseStyle; + + /// Explicit compile-time schema providers for custom types without generated schemas. + final List useProviders; + + const Schemable({ + this.schemaName, + this.description, + this.additionalProperties = false, + this.additionalPropertiesField, + this.discriminatorKey, + this.discriminatorValue, + this.caseStyle = CaseStyle.none, + this.useProviders = const [], + }) : assert( + discriminatorKey == null || discriminatorValue == null, + 'discriminatorKey and discriminatorValue cannot be used together', + ); +} + +/// Convenience constant for the default schemable configuration. +const schemable = Schemable(); + +/// Marks the constructor that defines the schema contract. +@Target({TargetKind.constructor}) +class SchemaConstructor { + const SchemaConstructor(); +} + +/// Overrides the generated schema key for a constructor parameter. +@Target({TargetKind.parameter}) +class SchemaKey { + final String name; + + const SchemaKey(this.name); +} + +/// Attaches human-readable schema documentation to a constructor parameter. +@Target({TargetKind.parameter}) +class Description { + final String value; + + const Description(this.value); +} + +/// Compile-time contract for explicit custom type-schema providers. +/// +/// Register provider types through `@Schemable(useProviders: const [...])` +/// when a constructor parameter uses a custom type that is not itself +/// schemable. Providers may compose generated schemas internally, but the +/// provider target type itself must not be `@Schemable()`. +abstract interface class SchemaProvider { + const SchemaProvider(); + + AckSchema get schema; +} diff --git a/packages/ack_annotations/pubspec.yaml b/packages/ack_annotations/pubspec.yaml index 6071779c..d80385c7 100644 --- a/packages/ack_annotations/pubspec.yaml +++ b/packages/ack_annotations/pubspec.yaml @@ -8,6 +8,7 @@ environment: sdk: '>=3.8.0 <4.0.0' dependencies: + ack: ^1.0.0-beta.9 meta: ^1.15.0 dev_dependencies: diff --git a/packages/ack_generator/README.md b/packages/ack_generator/README.md index 3678a947..925041e7 100644 --- a/packages/ack_generator/README.md +++ b/packages/ack_generator/README.md @@ -1,496 +1,189 @@ -# Ack Generator +# ack_generator -Code generator for the Ack validation library that automatically creates schema validation code from annotated Dart classes. +Build-time schema generation for Ack. -## Overview +`ack_generator` reads `@Schemable()` models and emits schema variables such as + `userSchema`, `invoiceSchema`, and discriminated union schemas for sealed + hierarchies. -Ack Generator analyzes your Dart models and produces corresponding `Ack.object()` schemas. You annotate your classes with `@AckModel()`, and the generator creates schema variables that you can use for runtime validation. - -The generator handles: -- Basic schema generation from class fields -- Nested models and complex types -- Discriminated types for polymorphic validation -- Field-level constraints and customization -- Additional properties support - -## Installation - -Add the following dependencies to your `pubspec.yaml` (check [pub.dev](https://pub.dev/packages/ack) for the latest versions): +## Install ```yaml dependencies: - ack: ^1.0.0-beta.7 - ack_annotations: ^1.0.0-beta.7 + ack: ^1.0.0-beta.9 + ack_annotations: ^1.0.0-beta.9 dev_dependencies: - ack_generator: ^1.0.0-beta.7 + ack_generator: ^1.0.0-beta.9 build_runner: ^2.4.0 ``` -Or use the Dart CLI: - -```bash -dart pub add ack ack_annotations -dart pub add --dev ack_generator build_runner -``` - -Run `dart pub get` to install the packages. - -## Basic usage - -### 1. Annotate your model - -Create a Dart class and annotate it with `@AckModel()`: +## Generate a Schema ```dart -// user.dart +import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; part 'user.g.dart'; -@AckModel() +@Schemable() class User { final String name; final String email; final int? age; - User({required this.name, required this.email, this.age}); + const User({ + required this.name, + required this.email, + this.age, + }); } ``` -### 2. Generate the schema - -Run the build_runner to generate the schema code: +Run the generator: ```bash -dart run build_runner build +dart run build_runner build --delete-conflicting-outputs ``` -This creates a `user.g.dart` file containing the generated schema: +Generated output: ```dart -// user.g.dart (generated) - final userSchema = Ack.object({ 'name': Ack.string(), 'email': Ack.string(), - 'age': Ack.integer().optional(), + 'age': Ack.integer().optional().nullable(), }); ``` -### 3. Use the generated schema - -Import the generated part file and use the schema for validation: - -```dart -import 'user.dart'; - -void main() { - final userData = {'name': 'Alice', 'email': 'alice@example.com', 'age': 30}; - - final result = userSchema.safeParse(userData); - - if (result.isOk) { - final validatedData = result.getOrThrow(); - final user = User( - name: validatedData['name'] as String, - email: validatedData['email'] as String, - age: validatedData['age'] as int?, - ); - print('User created: ${user.name}'); - } else { - print('Validation failed: ${result.getError()}'); - } -} -``` +`@Schemable` libraries that use a `part` directive must include an unprefixed +`import 'package:ack/ack.dart';` because generated code references `Ack` +directly. -## Features +## What the Generator Uses -### Automatic schema generation +The generator is constructor-driven. -The generator creates schemas based on your class fields and their types: +- It reads the unnamed constructor by default. +- `@SchemaConstructor()` selects a different constructor. +- The selected constructor must use named parameters only. +- Unsupported custom types fail generation unless you register + `@Schemable(useProviders: const [...])`. +- Parameter annotations define keys, descriptions, and constraints. +- Field annotations are no longer the primary generation surface. ```dart -@AckModel() -class Product { - final String name; - final double price; - final bool inStock; - final List tags; +@Schemable() +class Profile { + final String displayName; + final String email; - Product({ - required this.name, - required this.price, - required this.inStock, - required this.tags, + const Profile({ + @SchemaKey('display_name') required this.displayName, + @Email() required this.email, }); } - -// Generated schema -final productSchema = Ack.object({ - 'name': Ack.string(), - 'price': Ack.double(), - 'inStock': Ack.boolean(), - 'tags': Ack.list(Ack.string()), -}); ``` -### Field constraints +## Custom Types with Typed Providers -Use `@AckField` to add validation constraints: +If a parameter type is not built in and is not another `@Schemable()` model, + register a `SchemaProvider`. ```dart -@AckModel() -class User { - @AckField(constraints: ['minLength(1)', 'maxLength(50)']) - final String name; - - @AckField(constraints: ['email']) - final String email; - - @AckField(constraints: ['min(0)', 'max(150)']) - final int? age; +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; - User({required this.name, required this.email, this.age}); +class Money { + final int cents; + const Money(this.cents); } -// Generated schema includes constraints -final userSchema = Ack.object({ - 'name': Ack.string().minLength(1).maxLength(50), - 'email': Ack.string().email(), - 'age': Ack.integer().min(0).max(150).optional(), -}); -``` - -### Custom JSON keys - -Map class fields to different JSON property names: - -```dart -@AckModel() -class User { - @AckField(jsonKey: 'full_name') - final String name; - - @AckField(jsonKey: 'email_address') - final String email; +class MoneySchemaProvider implements SchemaProvider { + const MoneySchemaProvider(); - User({required this.name, required this.email}); + @override + AckSchema get schema => Ack.object({ + 'cents': Ack.integer(), + }).transform((value) => Money(value!['cents'] as int)); } -// Generated schema uses custom keys -final userSchema = Ack.object({ - 'full_name': Ack.string(), - 'email_address': Ack.string(), -}); -``` - -### Additional properties +@Schemable(useProviders: const [MoneySchemaProvider]) +class Invoice { + final Money total; + final List lineItems; -Allow or disallow extra fields in validated objects: - -```dart -@AckModel(additionalProperties: true) -class FlexibleModel { - final String id; - - FlexibleModel({required this.id}); + const Invoice({ + required this.total, + required this.lineItems, + }); } - -// Generated schema allows additional properties -final flexibleModelSchema = Ack.object({ - 'id': Ack.string(), -}, additionalProperties: true); ``` -By default, `additionalProperties` is `false`, which means the schema rejects any fields not explicitly defined in the class. - -### Nested models - -The generator handles nested model references: - -```dart -@AckModel() -class Address { - final String street; - final String city; +The generated schema embeds the provider schema for both `Money` and + `List`. Provider targets that are themselves `@Schemable()` are + rejected; generated schemas remain the source of truth for schemable types. - Address({required this.street, required this.city}); -} - -@AckModel() -class User { - final String name; - final Address address; +## Additional Properties (Passthrough) - User({required this.name, required this.address}); -} - -// Generated schemas -final addressSchema = Ack.object({ - 'street': Ack.string(), - 'city': Ack.string(), -}); - -final userSchema = Ack.object({ - 'name': Ack.string(), - 'address': addressSchema, -}); -``` +Use `@Schemable(additionalProperties: true)` for object passthrough behavior +(`Ack.object(..., additionalProperties: true)` in generated output). -## Advanced features +## Discriminated Unions -### Discriminated types - -Use discriminated types to validate polymorphic data structures. Define a base class with a discriminator key, then create subclasses with specific discriminator values: +Use `discriminatorKey` on a sealed base class and `discriminatorValue` on each + concrete subtype. ```dart -@AckModel(discriminatedKey: 'type') -abstract class Shape { - String get type; +@Schemable(discriminatorKey: 'type') +sealed class Shape { + const Shape(); } -@AckModel(discriminatedValue: 'circle') +@Schemable(discriminatorValue: 'circle') class Circle extends Shape { - @AckField(constraints: ['positive()']) final double radius; - Circle({required this.radius}); - - @override - String get type => 'circle'; + const Circle({@Positive() required this.radius}); } -@AckModel(discriminatedValue: 'rectangle') +@Schemable(discriminatorValue: 'rectangle') class Rectangle extends Shape { - @AckField(constraints: ['positive()']) final double width; - - @AckField(constraints: ['positive()']) final double height; - Rectangle({required this.width, required this.height}); - - @override - String get type => 'rectangle'; -} -``` - -The generator creates a discriminated schema that validates based on the discriminator field: - -```dart -// Generated schemas -final circleSchema = Ack.object({ - 'type': Ack.literal('circle'), - 'radius': Ack.double().positive(), -}); - -final rectangleSchema = Ack.object({ - 'type': Ack.literal('rectangle'), - 'width': Ack.double().positive(), - 'height': Ack.double().positive(), -}); - -final shapeSchema = Ack.discriminated( - discriminatorKey: 'type', - schemas: { - 'circle': circleSchema, - 'rectangle': rectangleSchema, - }, -); -``` - -Use the discriminated schema to validate different shape types: - -```dart -final circleData = {'type': 'circle', 'radius': 5.0}; -final rectangleData = {'type': 'rectangle', 'width': 10.0, 'height': 20.0}; - -final circleResult = shapeSchema.safeParse(circleData); -final rectangleResult = shapeSchema.safeParse(rectangleData); - -if (circleResult.isOk) { - final data = circleResult.getOrThrow(); - final circle = Circle(radius: data['radius'] as double); - print('Circle with radius: ${circle.radius}'); -} - -if (rectangleResult.isOk) { - final data = rectangleResult.getOrThrow(); - final rectangle = Rectangle( - width: data['width'] as double, - height: data['height'] as double, - ); - print('Rectangle: ${rectangle.width} x ${rectangle.height}'); -} -``` - -### Supported constraints - -You can use the following constraints with `@AckField`: - -**String constraints:** -- `minLength(n)` - Minimum string length -- `maxLength(n)` - Maximum string length -- `email` - Email format validation -- `url` - URL format validation -- `notEmpty` - Non-empty string - -**Number constraints:** -- `min(n)` - Minimum value -- `max(n)` - Maximum value -- `positive()` - Positive numbers only -- `negative()` - Negative numbers only -- `nonNegative()` - Zero or positive numbers -- `nonPositive()` - Zero or negative numbers - -**List constraints:** -- `minLength(n)` - Minimum list length -- `maxLength(n)` - Maximum list length -- `notEmpty` - Non-empty list - -```dart -final priceSchema = Ack.double().nonNegative().max(100); -``` -Use `nonNegative()` / `nonPositive()` as concise aliases for `.min(0)` / `.max(0)` while keeping consistent error messages. - -## Usage examples - -### Validating API request data - -```dart -@AckModel() -class CreateUserRequest { - @AckField(constraints: ['minLength(1)', 'maxLength(100)']) - final String username; - - @AckField(constraints: ['email']) - final String email; - - @AckField(constraints: ['minLength(8)']) - final String password; - - CreateUserRequest({ - required this.username, - required this.email, - required this.password, + const Rectangle({ + @Positive() required this.width, + @Positive() required this.height, }); } - -// In your API handler -void handleCreateUser(Map requestBody) { - final result = createUserRequestSchema.safeParse(requestBody); - - if (!result.isOk) { - return sendError(400, result.getError().toString()); - } - - final validatedData = result.getOrThrow(); - final request = CreateUserRequest( - username: validatedData['username'] as String, - email: validatedData['email'] as String, - password: validatedData['password'] as String, - ); - - // Create user with validated data - createUser(request); -} ``` -### Validating configuration files +This produces a discriminated schema keyed by `type`. -```dart -@AckModel() -class DatabaseConfig { - @AckField(constraints: ['minLength(1)']) - final String host; - - @AckField(constraints: ['min(1)', 'max(65535)']) - final int port; - - @AckField(constraints: ['minLength(1)']) - final String database; - - final String? username; - final String? password; - - DatabaseConfig({ - required this.host, - required this.port, - required this.database, - this.username, - this.password, - }); -} +## Notes -// Load and validate configuration -void loadConfig(String jsonString) { - final json = jsonDecode(jsonString) as Map; - final result = databaseConfigSchema.safeParse(json); - - if (!result.isOk) { - throw ConfigurationError('Invalid database config: ${result.getError()}'); - } - - final validatedData = result.getOrThrow(); - final config = DatabaseConfig( - host: validatedData['host'] as String, - port: validatedData['port'] as int, - database: validatedData['database'] as String, - username: validatedData['username'] as String?, - password: validatedData['password'] as String?, - ); - - connectToDatabase(config); -} -``` +- The generated output is schema-first. It validates data; it does not + automatically instantiate your model classes. +- A typed `SchemaProvider` should parse to `T`. Use a transformed schema if + the wire shape differs from the model type. +- `additionalProperties: true` is the `@Schemable` equivalent of + `.passthrough()` for object schemas. +- Prefix-qualified schemable models and providers are supported. -## Development +## Compatibility -### Regenerating code +These legacy APIs still work for migration, but new code should avoid them: -If you modify your annotated models or add new constraints, regenerate the schemas: +- `@AckModel()` and `ackModel` +- `AckField` -```bash -# Clean previous builds -dart run build_runner clean - -# Generate fresh code -dart run build_runner build - -# Or use watch mode during development -dart run build_runner watch -``` - -### Troubleshooting - -**Part directive missing:** -If you see errors about missing generated code, ensure your model file includes the part directive: - -```dart -part 'your_file_name.g.dart'; -``` +## Development -**Build conflicts:** -If the generator reports conflicts, run the build with the delete flag: +Useful commands: ```bash -dart run build_runner build --delete-conflicting-outputs +dart format . +dart analyze +dart test ``` - -**Type resolution errors:** -Ensure all referenced types have `@AckModel()` annotations or are built-in Dart types that Ack supports. - -## Contributing - -Contributions are welcome. Follow these guidelines: - -1. Check existing issues before creating new ones -2. Follow the existing code style and patterns -3. Add tests for new features -4. Update documentation for public API changes -5. Run `melos test` to ensure all tests pass - -## License - -This project is licensed under the MIT License. See the LICENSE file for details. diff --git a/packages/ack_generator/lib/src/analyzer/field_analyzer.dart b/packages/ack_generator/lib/src/analyzer/field_analyzer.dart index 5d00a477..aa3faa07 100644 --- a/packages/ack_generator/lib/src/analyzer/field_analyzer.dart +++ b/packages/ack_generator/lib/src/analyzer/field_analyzer.dart @@ -1,186 +1,109 @@ +import 'package:ack_annotations/ack_annotations.dart'; +import 'package:analyzer/dart/constant/value.dart'; import 'package:analyzer/dart/element/element2.dart'; import 'package:analyzer/dart/element/nullability_suffix.dart'; -import 'package:analyzer/dart/constant/value.dart'; import 'package:source_gen/source_gen.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import '../models/field_info.dart'; import '../models/constraint_info.dart'; +import '../models/field_info.dart'; +import '../utils/annotation_utils.dart'; +import '../utils/case_style_utils.dart'; import '../utils/doc_comment_utils.dart'; -/// Analyzes individual fields in a model +/// Analyzes constructor parameters in a schemable model. class FieldAnalyzer { - FieldInfo analyze(FieldElement2 field) { - // Check for @AckField annotation - final ackFieldAnnotation = TypeChecker.typeNamed( - AckField, - ).firstAnnotationOf(field); - - // Determine JSON key (from annotation or field name) - final jsonKey = _getJsonKey(field, ackFieldAnnotation); - - // Determine if field is required - final isRequired = _isRequired(field, ackFieldAnnotation); - - // Extract constraints - final constraints = _extractConstraints(field, ackFieldAnnotation); - - // Extract description from annotation - final description = _getDescription(field, ackFieldAnnotation); + FieldInfo analyze( + FormalParameterElement parameter, { + required CaseStyle caseStyle, + }) { + final jsonKey = _getJsonKey(parameter, caseStyle); + final constraints = _extractDecoratorConstraints(parameter); + final description = _getDescription(parameter); return FieldInfo( - name: field.name3!, + name: parameter.name3!, jsonKey: jsonKey, - type: field.type, - isRequired: isRequired, - isNullable: field.type.nullabilitySuffix != NullabilitySuffix.none, + type: parameter.type, + isRequired: parameter.isRequiredNamed, + isNullable: parameter.type.nullabilitySuffix != NullabilitySuffix.none, constraints: constraints, description: description, ); } - String _getJsonKey(FieldElement2 field, DartObject? annotation) { + String _getJsonKey(FormalParameterElement parameter, CaseStyle caseStyle) { + final annotation = schemaKeyChecker.firstAnnotationOf(parameter); if (annotation != null) { - final jsonKeyField = annotation.getField('jsonKey'); - if (jsonKeyField != null && !jsonKeyField.isNull) { - return jsonKeyField.toStringValue()!; + final keyName = annotation.getField('name')?.toStringValue(); + if (keyName != null && keyName.isNotEmpty) { + return keyName; } } - return field.name3!; + + return applyCaseStyle(caseStyle, parameter.name3!); } - String? _getDescription(FieldElement2 field, DartObject? annotation) { - // Priority 1: Check annotation (explicit override takes precedence) + String? _getDescription(FormalParameterElement parameter) { + final annotation = descriptionChecker.firstAnnotationOf(parameter); if (annotation != null) { - final descriptionField = annotation.getField('description'); - if (descriptionField != null && !descriptionField.isNull) { - return descriptionField.toStringValue(); + final value = annotation.getField('value')?.toStringValue(); + if (value != null && value.isNotEmpty) { + return value; } } - // Priority 2: Fallback to doc comment - return parseDocComment(field.documentationComment); - } - - bool _isRequired(FieldElement2 field, DartObject? annotation) { - // If no annotation, use automatic detection. - if (annotation == null) { - return _inferRequiredFromField(field); - } - - // Tri-state mode is authoritative. - return switch (_getRequiredMode(annotation)) { - AckFieldRequiredMode.required => true, - AckFieldRequiredMode.optional => false, - AckFieldRequiredMode.auto => _inferRequiredFromField(field), - }; + return parseDocComment(parameter.documentationComment); } - bool _inferRequiredFromField(FieldElement2 field) { - return field.type.nullabilitySuffix == NullabilitySuffix.none && - !field.firstFragment.hasInitializer; - } - - AckFieldRequiredMode _getRequiredMode(DartObject annotation) { - final modeIndex = annotation - .getField('requiredMode') - ?.getField('index') - ?.toIntValue(); - return switch (modeIndex) { - 0 => AckFieldRequiredMode.auto, - 1 => AckFieldRequiredMode.required, - 2 => AckFieldRequiredMode.optional, - _ => AckFieldRequiredMode.auto, - }; - } - - List _extractConstraints( - FieldElement2 field, - DartObject? annotation, + List _extractDecoratorConstraints( + FormalParameterElement parameter, ) { final constraints = []; - // Extract constraints from @AckField annotation. - constraints.addAll(_extractAckFieldConstraints(annotation)); - - // Extract constraints from decorator annotations - constraints.addAll(_extractDecoratorConstraints(field)); - - return constraints; - } - - List _extractAckFieldConstraints(DartObject? annotation) { - if (annotation == null) return const []; - - final constraints = []; - final constraintsField = annotation.getField('constraints'); - if (constraintsField == null || constraintsField.isNull) { - return constraints; - } - - final constraintsList = constraintsField.toListValue(); - if (constraintsList == null) return constraints; - - for (final constraint in constraintsList) { - // Parse constraint strings like "minLength(3)", "email()". - final constraintStr = constraint.toStringValue(); - if (constraintStr == null) continue; - - final parsed = _parseConstraintString(constraintStr); - if (parsed != null) { - constraints.add(parsed); - } - } - - return constraints; - } - - ConstraintInfo? _parseConstraintString(String constraint) { - // Match patterns like "minLength(3)" or "email()" - final match = RegExp(r'(\w+)\((.*)\)').firstMatch(constraint); - if (match != null) { - final name = match.group(1)!; - final argsStr = match.group(2)!; - final args = argsStr.isEmpty - ? [] - : argsStr.split(',').map((a) => a.trim()).toList(); - - return ConstraintInfo(name: name, arguments: args); + _addStringConstraints(constraints, parameter); + _addNumericConstraints(constraints, parameter); + _addListConstraints(constraints, parameter); + _addEnumConstraints(constraints, parameter); + + final hasEnumString = constraints.any((c) => c.name == 'enumString'); + final hasOtherStringConstraints = constraints.any( + (c) => const { + 'minLength', + 'maxLength', + 'email', + 'url', + 'matches', + }.contains(c.name), + ); + if (hasEnumString && hasOtherStringConstraints) { + throw ArgumentError( + '@EnumString cannot be combined with other string constraints ' + '(@MinLength, @MaxLength, @Email, @Url, @Pattern) on parameter ' + '"${parameter.name3}".', + ); } - // Simple constraint without parentheses - return ConstraintInfo(name: constraint, arguments: []); - } - - List _extractDecoratorConstraints(FieldElement2 field) { - final constraints = []; - - _addStringConstraints(constraints, field); - _addNumericConstraints(constraints, field); - _addListConstraints(constraints, field); - _addEnumConstraints(constraints, field); - return constraints; } void _addStringConstraints( List constraints, - FieldElement2 field, + FormalParameterElement parameter, ) { - _addIntConstraint(constraints, field, MinLength, 'length', 'minLength'); - _addIntConstraint(constraints, field, MaxLength, 'length', 'maxLength'); + _addIntConstraint(constraints, parameter, MinLength, 'length', 'minLength'); + _addIntConstraint(constraints, parameter, MaxLength, 'length', 'maxLength'); - if (TypeChecker.typeNamed(Email).hasAnnotationOfExact(field)) { - constraints.add(ConstraintInfo(name: 'email', arguments: [])); + if (TypeChecker.typeNamed(Email).hasAnnotationOfExact(parameter)) { + constraints.add(constraint('email')); } - if (TypeChecker.typeNamed(Url).hasAnnotationOfExact(field)) { - constraints.add(ConstraintInfo(name: 'url', arguments: [])); + if (TypeChecker.typeNamed(Url).hasAnnotationOfExact(parameter)) { + constraints.add(constraint('url')); } final patternAnnotation = TypeChecker.typeNamed( Pattern, - ).firstAnnotationOf(field); + ).firstAnnotationOf(parameter); final pattern = patternAnnotation?.getField('pattern')?.toStringValue(); if (pattern != null) { constraints.add(ConstraintInfo(name: 'matches', arguments: [pattern])); @@ -189,43 +112,44 @@ class FieldAnalyzer { void _addNumericConstraints( List constraints, - FieldElement2 field, + FormalParameterElement parameter, ) { - _addNumericConstraint(constraints, field, Min, 'min'); - _addNumericConstraint(constraints, field, Max, 'max'); + _addNumericConstraint(constraints, parameter, Min, 'min'); + _addNumericConstraint(constraints, parameter, Max, 'max'); - if (TypeChecker.typeNamed(Positive).hasAnnotationOfExact(field)) { - constraints.add(ConstraintInfo(name: 'positive', arguments: [])); + if (TypeChecker.typeNamed(Positive).hasAnnotationOfExact(parameter)) { + constraints.add(constraint('positive')); } - _addNumericConstraint(constraints, field, MultipleOf, 'multipleOf'); + _addNumericConstraint(constraints, parameter, MultipleOf, 'multipleOf'); } void _addListConstraints( List constraints, - FieldElement2 field, + FormalParameterElement parameter, ) { - _addIntConstraint(constraints, field, MinItems, 'count', 'minItems'); - _addIntConstraint(constraints, field, MaxItems, 'count', 'maxItems'); + _addIntConstraint(constraints, parameter, MinItems, 'count', 'minItems'); + _addIntConstraint(constraints, parameter, MaxItems, 'count', 'maxItems'); } void _addEnumConstraints( List constraints, - FieldElement2 field, + FormalParameterElement parameter, ) { - final enumStringAnnotation = TypeChecker.typeNamed( + final annotation = TypeChecker.typeNamed( EnumString, - ).firstAnnotationOf(field); - if (enumStringAnnotation == null) return; + ).firstAnnotationOf(parameter); + if (annotation == null) return; - final valuesList = enumStringAnnotation.getField('values')?.toListValue(); + final valuesList = annotation.getField('values')?.toListValue(); if (valuesList == null) return; final values = valuesList - .map((v) => v.toStringValue()) - .where((v) => v != null) + .map((value) => value.toStringValue()) + .where((value) => value != null) .cast() .toList(); + if (values.isNotEmpty) { constraints.add(ConstraintInfo(name: 'enumString', arguments: values)); } @@ -233,14 +157,14 @@ class FieldAnalyzer { void _addIntConstraint( List constraints, - FieldElement2 field, + FormalParameterElement parameter, Type annotationType, String valueFieldName, String constraintName, ) { final annotation = TypeChecker.typeNamed( annotationType, - ).firstAnnotationOf(field); + ).firstAnnotationOf(parameter); final value = annotation?.getField(valueFieldName)?.toIntValue(); if (value != null) { constraints.add( @@ -249,29 +173,41 @@ class FieldAnalyzer { } } - /// Extracts a numeric value from an annotation and adds it as a constraint. - /// - /// Tries toIntValue() first, then toDoubleValue(), because int literals - /// like @Min(5) have toDoubleValue() return null in the analyzer. void _addNumericConstraint( List constraints, - FieldElement2 field, + FormalParameterElement parameter, Type annotationType, String constraintName, ) { final annotation = TypeChecker.typeNamed( annotationType, - ).firstAnnotationOf(field); + ).firstAnnotationOf(parameter); if (annotation == null) return; - final valueField = annotation.getField('value'); - final valueStr = - valueField?.toIntValue()?.toString() ?? - valueField?.toDoubleValue()?.toString(); - if (valueStr != null) { - constraints.add( - ConstraintInfo(name: constraintName, arguments: [valueStr]), - ); + final rawValue = annotation.getField('value'); + final value = _readNumericValue(rawValue); + if (value == null) return; + + constraints.add(ConstraintInfo(name: constraintName, arguments: [value])); + } + + String? _readNumericValue(DartObject? value) { + if (value == null || value.isNull) return null; + + final intValue = value.toIntValue(); + if (intValue != null) { + return intValue.toString(); + } + + final doubleValue = value.toDoubleValue(); + if (doubleValue != null) { + return doubleValue.toString(); } + + return null; + } + + ConstraintInfo constraint(String name) { + return ConstraintInfo(name: name, arguments: const []); } } diff --git a/packages/ack_generator/lib/src/analyzer/model_analyzer.dart b/packages/ack_generator/lib/src/analyzer/model_analyzer.dart index cd3a57ba..6c2d7fcf 100644 --- a/packages/ack_generator/lib/src/analyzer/model_analyzer.dart +++ b/packages/ack_generator/lib/src/analyzer/model_analyzer.dart @@ -1,67 +1,73 @@ +import 'package:ack_annotations/ack_annotations.dart'; import 'package:analyzer/dart/element/element2.dart'; +import 'package:analyzer/dart/element/type.dart'; import 'package:source_gen/source_gen.dart'; import '../models/field_info.dart'; import '../models/model_info.dart'; +import '../models/type_provider_info.dart'; +import '../utils/annotation_utils.dart'; import '../utils/doc_comment_utils.dart'; +import '../utils/type_resolver.dart'; import 'field_analyzer.dart'; -/// Analyzes classes annotated with @AckModel +/// Analyzes classes annotated with `@Schemable`. class ModelAnalyzer { final _fieldAnalyzer = FieldAnalyzer(); ModelInfo analyze(ClassElement2 element, ConstantReader annotation) { - // Extract schema name from annotation or generate it - final schemaName = annotation.read('schemaName').isNull - ? null - : annotation.read('schemaName').stringValue; - + final schemaName = _readOptionalString(annotation, 'schemaName'); final schemaClassName = schemaName ?? '${element.name3}Schema'; + final description = + _readOptionalString(annotation, 'description') ?? + parseDocComment(element.documentationComment); + final additionalProperties = + annotation.peek('additionalProperties')?.boolValue ?? false; + final additionalPropertiesField = _readOptionalString( + annotation, + 'additionalPropertiesField', + ); + final discriminatorKey = _readOptionalString( + annotation, + 'discriminatorKey', + ); + final discriminatorValue = _readOptionalString( + annotation, + 'discriminatorValue', + ); + final caseStyle = _readCaseStyle(annotation); + final typeProviders = _readTypeProviders(element, annotation); + final typeResolver = SchemableTypeResolver( + typeProviders: typeProviders, + currentLibrary: element.library2, + ); - // Extract description - annotation takes precedence, then doc comment - final description = annotation.read('description').isNull - ? parseDocComment(element.documentationComment) - : annotation.read('description').stringValue; - - // Extract additionalProperties settings - final additionalProperties = annotation.read('additionalProperties').isNull - ? false - : annotation.read('additionalProperties').boolValue; - - final additionalPropertiesField = - annotation.read('additionalPropertiesField').isNull - ? null - : annotation.read('additionalPropertiesField').stringValue; - - // Extract discriminated type parameters - final discriminatedKey = annotation.read('discriminatedKey').isNull - ? null - : annotation.read('discriminatedKey').stringValue; - - final discriminatedValue = annotation.read('discriminatedValue').isNull - ? null - : annotation.read('discriminatedValue').stringValue; - - // Validate discriminated type usage - _validateDiscriminatedTypeUsage( + _validateDiscriminatorTypeUsage( element, - discriminatedKey, - discriminatedValue, + discriminatorKey, + discriminatorValue, ); - // Analyze all fields + final constructor = _selectSchemaConstructor(element); final fields = []; - // Get all fields including inherited ones - final allFields = [ - ...element.fields2, - ...element.allSupertypes.expand((type) => type.element3.fields2), - ].where((field) => !field.isStatic && !field.isSynthetic); + for (final parameter in constructor.formalParameters) { + if (!parameter.isNamed) { + throw ArgumentError( + 'Only named constructor parameters are supported. ' + 'Parameter "${parameter.name3}" in ${_constructorLabel(element, constructor)} ' + 'must be named.', + ); + } - for (final field in allFields) { - final fieldInfo = _fieldAnalyzer.analyze(field); + final fieldInfo = _analyzeField( + element, + constructor, + parameter, + caseStyle, + typeResolver, + ); - // Skip the additionalPropertiesField from schema generation if (additionalPropertiesField != null && fieldInfo.name == additionalPropertiesField) { continue; @@ -70,7 +76,6 @@ class ModelAnalyzer { fields.add(fieldInfo); } - // Validate additionalPropertiesField if specified if (additionalPropertiesField != null) { _validateAdditionalPropertiesField( element, @@ -86,158 +91,372 @@ class ModelAnalyzer { fields: fields, additionalProperties: additionalProperties, additionalPropertiesField: additionalPropertiesField, - discriminatorKey: discriminatedKey, - discriminatorValue: discriminatedValue, - // subtypes will be populated later in a second pass + typeProviders: typeProviders, + discriminatorKey: discriminatorKey, + discriminatorValue: discriminatorValue, subtypeNames: null, ); } + void _validateFieldType( + ClassElement2 element, + ConstructorElement2 constructor, + FieldInfo field, + SchemableTypeResolver typeResolver, + ) { + try { + typeResolver.schemaExpressionFor(field.type); + } on UnsupportedSchemaTypeError catch (error) { + throw ArgumentError( + 'Unsupported type "${error.typeName}" for parameter "${field.name}" ' + 'in ${_constructorLabel(element, constructor)}. ' + 'Annotate the type with @Schemable() or register a schema provider with ' + '@Schemable(useProviders: const [YourProvider]).', + ); + } + } + + FieldInfo _analyzeField( + ClassElement2 element, + ConstructorElement2 constructor, + FormalParameterElement parameter, + CaseStyle caseStyle, + SchemableTypeResolver typeResolver, + ) { + final fieldInfo = _fieldAnalyzer.analyze(parameter, caseStyle: caseStyle); + _validateFieldType(element, constructor, fieldInfo, typeResolver); + + return fieldInfo.copyWith( + schemaExpressionOverride: typeResolver.schemaExpressionFor( + fieldInfo.type, + ), + ); + } + + ConstructorElement2 _selectSchemaConstructor(ClassElement2 element) { + final annotatedConstructors = element.constructors2 + .where( + (constructor) => + schemaConstructorChecker.hasAnnotationOfExact(constructor), + ) + .toList(); + + if (annotatedConstructors.length > 1) { + throw ArgumentError( + 'Class ${element.name3} has multiple @SchemaConstructor annotations. ' + 'Annotate exactly one constructor.', + ); + } + + final selected = annotatedConstructors.isNotEmpty + ? annotatedConstructors.single + : element.constructors2.cast().firstWhere( + (constructor) => constructor?.name3 == 'new', + orElse: () => null, + ); + + if (selected == null) { + throw ArgumentError( + 'Class ${element.name3} does not have a default unnamed constructor. ' + 'Annotate the intended constructor with @SchemaConstructor().', + ); + } + + if (selected.isFactory) { + throw ArgumentError( + '${_constructorLabel(element, selected)} cannot be a factory constructor. ' + 'Use a generative constructor for @Schemable classes.', + ); + } + + return selected; + } + + List _readTypeProviders( + ClassElement2 element, + ConstantReader annotation, + ) { + final rawProviders = annotation.peek('useProviders')?.listValue ?? const []; + final typeProviders = []; + final seenTargetTypes = {}; + + for (final rawProvider in rawProviders) { + final providerType = rawProvider.toTypeValue(); + final providerElement = providerType?.element3; + if (providerType is! InterfaceType || + providerElement is! InterfaceElement2) { + throw ArgumentError( + 'Invalid schema provider registration on ${element.name3}. ' + 'Each `useProviders` entry must be a provider type.', + ); + } + + final providerName = providerElement.name3; + if (providerName == null) { + throw ArgumentError( + 'Failed to resolve schema provider type for ${element.name3}.', + ); + } + + if (providerElement is! ClassElement2 || providerElement.isAbstract) { + throw ArgumentError( + 'Schema provider $providerName must be a concrete class and cannot be abstract.', + ); + } + + final defaultConstructor = providerElement.constructors2 + .cast() + .firstWhere( + (constructor) => + constructor?.name3 == 'new' && + constructor?.formalParameters.isEmpty == true, + orElse: () => null, + ); + + if (defaultConstructor == null || !defaultConstructor.isConst) { + throw ArgumentError( + 'Schema provider $providerName must declare a const unnamed constructor ' + 'with no parameters.', + ); + } + + final providerInterface = providerElement.allSupertypes + .cast() + .firstWhere( + (supertype) => + supertype?.element3.name3 == 'SchemaProvider' && + supertype?.typeArguments.isNotEmpty == true, + orElse: () => null, + ); + + if (providerInterface == null) { + throw ArgumentError( + 'Schema provider $providerName must implement SchemaProvider.', + ); + } + + final targetType = providerInterface.typeArguments.first; + final targetTypeName = targetType.getDisplayString( + withNullability: false, + ); + final targetTypeKey = typeIdentityKey(targetType); + _validateProviderTargetType(providerName, targetType); + _validateProviderSchemaType(providerType, providerName, targetType); + + final existingProvider = seenTargetTypes[targetTypeKey]; + if (existingProvider != null) { + throw ArgumentError( + 'Schema providers $existingProvider and $providerName both handle ' + '$targetTypeName. Register only one provider per target type.', + ); + } + + seenTargetTypes[targetTypeKey] = providerName; + typeProviders.add( + TypeProviderInfo( + providerTypeName: providerName, + targetType: targetType, + accessor: _providerAccessorFor(element.library2, providerElement), + ), + ); + } + + return typeProviders; + } + + String? _readOptionalString(ConstantReader annotation, String fieldName) { + final reader = annotation.peek(fieldName); + if (reader == null || reader.isNull) { + return null; + } + return reader.stringValue; + } + + CaseStyle _readCaseStyle(ConstantReader annotation) { + final reader = annotation.peek('caseStyle'); + if (reader == null || reader.isNull) { + return CaseStyle.none; + } + + final index = reader.objectValue.getField('index')?.toIntValue(); + if (index == null || index < 0 || index >= CaseStyle.values.length) { + return CaseStyle.none; + } + + return CaseStyle.values[index]; + } + void _validateAdditionalPropertiesField( ClassElement2 element, String fieldName, bool additionalProperties, ) { - // Find the field in the class - final field = element.fields2.firstWhere( - (f) => f.name3 == fieldName, - orElse: () => throw ArgumentError( - 'additionalPropertiesField "$fieldName" not found in class ${element.name3}', - ), + final field = element.fields2.cast().firstWhere( + (candidate) => candidate?.name3 == fieldName, + orElse: () => null, ); - // Check if additionalProperties is true when field is specified + if (field == null) { + throw ArgumentError( + 'additionalPropertiesField "$fieldName" not found in class ${element.name3}', + ); + } + if (!additionalProperties) { throw ArgumentError( 'additionalProperties must be true when additionalPropertiesField is specified', ); } - // Check if field type is Map or compatible using modern Dart pattern matching final fieldType = field.type.getDisplayString(); final isValidType = switch (fieldType) { String type when type.startsWith('Map true, - String type when type.startsWith('Map') => true, - String type when type.startsWith('Map') => true, _ => false, }; if (!isValidType) { throw ArgumentError( - 'additionalPropertiesField "$fieldName" must be of type Map or Map, got $fieldType', + 'additionalPropertiesField "$fieldName" must be of type ' + 'Map or Map, got $fieldType', ); } } - /// Validates discriminated type usage rules - void _validateDiscriminatedTypeUsage( - ClassElement2 element, - String? discriminatedKey, - String? discriminatedValue, + void _validateProviderSchemaType( + InterfaceType providerType, + String providerName, + DartType targetType, ) { - // Rule 1: discriminatedKey and discriminatedValue are mutually exclusive - if (discriminatedKey != null && discriminatedValue != null) { + final targetTypeName = targetType.getDisplayString(withNullability: false); + final targetTypeKey = typeIdentityKey(targetType); + final schemaGetter = providerType.lookUpGetter( + 'schema', + providerType.element3.library2, + concrete: true, + ); + + if (schemaGetter == null) { throw ArgumentError( - 'Class ${element.name3} cannot have both discriminatedKey and discriminatedValue. ' - 'Use discriminatedKey on base classes and discriminatedValue on concrete implementations.', + 'Schema provider $providerName must declare a `schema` getter.', ); } - // Rule 2: discriminatedKey should only be used on abstract classes - if (discriminatedKey != null && !element.isAbstract) { + final schemaType = _ackSchemaInterfaceFor(schemaGetter.returnType); + if (schemaType == null || schemaType.typeArguments.isEmpty) { throw ArgumentError( - 'discriminatedKey can only be used on abstract classes. ' - 'Class ${element.name3} should be declared as abstract.', + 'Schema provider $providerName must return AckSchema<$targetTypeName> ' + 'from `schema`.', ); } - // Rule 3: discriminatedValue should only be used on concrete classes - if (discriminatedValue != null && element.isAbstract) { + final providedType = schemaType.typeArguments.first; + final providedTypeName = providedType.getDisplayString( + withNullability: false, + ); + if (typeIdentityKey(providedType) != targetTypeKey) { throw ArgumentError( - 'discriminatedValue can only be used on concrete classes. ' - 'Class ${element.name3} is abstract and should use discriminatedKey instead.', + 'Schema provider $providerName must return AckSchema<$targetTypeName> ' + 'from `schema`, but returns AckSchema<$providedTypeName>.', ); } + } - // Rule 4: If discriminatedKey is used, validate the discriminator field exists - if (discriminatedKey != null) { - _validateDiscriminatorField(element, discriminatedKey); + void _validateProviderTargetType(String providerName, DartType targetType) { + final targetElement = targetType.element3; + if (targetElement is! InterfaceElement2) { + return; } - } - /// Validates that the discriminator field or getter exists and is properly typed - void _validateDiscriminatorField( - ClassElement2 element, - String discriminatorKey, - ) { - // Check if the field exists (including inherited fields) - final allFields = [ - ...element.fields2, - ...element.allSupertypes.expand((type) => type.element3.fields2), - ]; - - final discriminatorField = allFields.cast().firstWhere( - (field) => field?.name3 == discriminatorKey, - orElse: () => null, + if (firstSchemableAnnotationOf(targetElement) == null) { + return; + } + + final targetTypeName = targetType.getDisplayString(withNullability: false); + throw ArgumentError( + 'Schema provider $providerName cannot target $targetTypeName because ' + '$targetTypeName already has a generated schema. Remove the provider ' + 'registration or stop annotating $targetTypeName with @Schemable().', ); + } - if (discriminatorField != null) { - // Found as a field, validate type - final fieldType = discriminatorField.type.getDisplayString(); - if (!fieldType.startsWith('String')) { - throw ArgumentError( - 'Discriminator field "$discriminatorKey" must be of type String, got $fieldType', - ); - } - return; + InterfaceType? _ackSchemaInterfaceFor(DartType type) { + if (type is! InterfaceType) { + return null; } - // Check for getter (including inherited getters) - final allGetters = [ - ...element.getters2, - ...element.allSupertypes.expand((type) => type.element3.getters2), - ]; + if (type.element3.name3 == 'AckSchema' && type.typeArguments.isNotEmpty) { + return type; + } - final discriminatorGetter = allGetters.cast().firstWhere( - (getter) => getter?.name3 == discriminatorKey, + return type.allSupertypes.cast().firstWhere( + (supertype) => + supertype?.element3.name3 == 'AckSchema' && + supertype?.typeArguments.isNotEmpty == true, orElse: () => null, ); + } - if (discriminatorGetter != null) { - // Found as a getter, validate return type - final returnType = discriminatorGetter.returnType.getDisplayString(); - if (!returnType.startsWith('String')) { - throw ArgumentError( - 'Discriminator getter "$discriminatorKey" must return String, got $returnType', - ); - } - return; + String _providerAccessorFor( + LibraryElement2? currentLibrary, + InterfaceElement2 providerElement, + ) { + final providerName = providerElement.name3; + if (providerName == null) { + throw ArgumentError('Failed to resolve provider element name.'); } - throw ArgumentError( - 'Discriminator field or getter "$discriminatorKey" not found in class ${element.name3} or its supertypes.', - ); + final prefix = importPrefixForElement(currentLibrary, providerElement); + if (prefix == null) { + return 'const $providerName()'; + } + + return 'const $prefix.$providerName()'; + } + + void _validateDiscriminatorTypeUsage( + ClassElement2 element, + String? discriminatorKey, + String? discriminatorValue, + ) { + if (discriminatorKey != null && discriminatorValue != null) { + throw ArgumentError( + 'Class ${element.name3} cannot have both discriminatorKey and ' + 'discriminatorValue.', + ); + } + + if (discriminatorKey != null && !element.isSealed) { + throw ArgumentError( + 'discriminatorKey can only be used on sealed classes. ' + 'Class ${element.name3} must be declared sealed.', + ); + } + + if (discriminatorValue != null && element.isAbstract) { + throw ArgumentError( + 'discriminatorValue can only be used on concrete classes. ' + 'Class ${element.name3} is abstract.', + ); + } } - /// Builds discriminator relationships after all models have been analyzed - /// This is a second pass that connects base classes with their subtypes + /// Builds discriminator relationships after all models have been analyzed. List buildDiscriminatorRelationships( List modelInfos, List elements, ) { final updatedModelInfos = []; + final canonicalDiscriminatorKeysByBaseClass = {}; final elementsByName = { for (final element in elements) if (element.name3 != null) element.name3!: element, }; - // Group models by their discriminated state final baseClasses = []; final subtypes = []; for (final modelInfo in modelInfos) { if (modelInfo.isFromSchemaVariable) { - // Schema-variable models are linked in a separate pass. updatedModelInfos.add(modelInfo); continue; } @@ -247,17 +466,14 @@ class ModelAnalyzer { } else if (modelInfo.isDiscriminatedSubtype) { subtypes.add(modelInfo); } else { - // Regular models, no changes needed updatedModelInfos.add(modelInfo); } } - // For each base class, find and validate its subtypes for (final baseClass in baseClasses) { - final discriminatorKey = baseClass.discriminatorKey!; final matchingSubtypeNames = {}; + final matchingSubtypes = []; - // Find subtypes that belong to this base class for (final subtype in subtypes) { final subtypeElement = elementsByName[subtype.className]; if (subtypeElement == null) { @@ -266,51 +482,53 @@ class ModelAnalyzer { ); } - // Check if this subtype extends the base class if (_isSubtypeOf(subtypeElement, baseClass.className)) { final discriminatorValue = subtype.discriminatorValue!; - - // Validate no duplicate discriminator values if (matchingSubtypeNames.containsKey(discriminatorValue)) { throw ArgumentError( 'Duplicate discriminator value "$discriminatorValue" found in ' - '${subtype.className} and ${matchingSubtypeNames[discriminatorValue]}. ' - 'Each discriminator value must be unique within the hierarchy.', + '${subtype.className} and ${matchingSubtypeNames[discriminatorValue]}.', ); } matchingSubtypeNames[discriminatorValue] = subtype.className; - - // Validate the discriminator field override - _validateDiscriminatorOverride( - subtypeElement, - discriminatorKey, - discriminatorValue, - ); + matchingSubtypes.add(subtype); } } - // Create updated base class ModelInfo with subtype mapping - final updatedBaseClass = ModelInfo( - className: baseClass.className, - schemaClassName: baseClass.schemaClassName, - description: baseClass.description, - fields: baseClass.fields, - additionalProperties: baseClass.additionalProperties, - additionalPropertiesField: baseClass.additionalPropertiesField, - discriminatorKey: discriminatorKey, - discriminatorValue: null, - subtypeNames: matchingSubtypeNames, - ); + if (matchingSubtypeNames.isEmpty) { + throw ArgumentError( + 'Sealed discriminated root ${baseClass.className} has no annotated leaves.', + ); + } - updatedModelInfos.add(updatedBaseClass); + final canonicalDiscriminatorKey = _canonicalDiscriminatorKey( + baseClass, + matchingSubtypes, + ); + canonicalDiscriminatorKeysByBaseClass[baseClass.className] = + canonicalDiscriminatorKey; + + updatedModelInfos.add( + ModelInfo( + className: baseClass.className, + schemaClassName: baseClass.schemaClassName, + description: baseClass.description, + fields: baseClass.fields, + additionalProperties: baseClass.additionalProperties, + additionalPropertiesField: baseClass.additionalPropertiesField, + typeProviders: baseClass.typeProviders, + discriminatorKey: canonicalDiscriminatorKey, + discriminatorValue: null, + subtypeNames: matchingSubtypeNames, + ), + ); } - // Update subtypes with parent discriminator key information for (final subtype in subtypes) { - // Find the parent discriminator key for this subtype String? parentDiscriminatorKey; String? parentBaseClassName; + for (final baseClass in baseClasses) { final subtypeElement = elementsByName[subtype.className]; if (subtypeElement == null) { @@ -318,91 +536,104 @@ class ModelAnalyzer { 'Subtype class ${subtype.className} not found in annotated elements.', ); } + if (_isSubtypeOf(subtypeElement, baseClass.className)) { - parentDiscriminatorKey = baseClass.discriminatorKey; parentBaseClassName = baseClass.className; + parentDiscriminatorKey = + canonicalDiscriminatorKeysByBaseClass[baseClass.className] ?? + baseClass.discriminatorKey; break; } } - // Create updated subtype with parent discriminator key - final updatedSubtype = ModelInfo( - className: subtype.className, - schemaClassName: subtype.schemaClassName, - description: subtype.description, - fields: subtype.fields, - additionalProperties: subtype.additionalProperties, - additionalPropertiesField: subtype.additionalPropertiesField, - discriminatorKey: - parentDiscriminatorKey, // Add parent's discriminator key - discriminatorValue: subtype.discriminatorValue, - subtypeNames: null, - discriminatedBaseClassName: parentBaseClassName, - ); + if (parentDiscriminatorKey == null || parentBaseClassName == null) { + throw ArgumentError( + 'Class ${subtype.className} declares discriminatorValue but does not ' + 'extend a sealed @Schemable root in the same library.', + ); + } - updatedModelInfos.add(updatedSubtype); + updatedModelInfos.add( + ModelInfo( + className: subtype.className, + schemaClassName: subtype.schemaClassName, + description: subtype.description, + fields: subtype.fields, + additionalProperties: subtype.additionalProperties, + additionalPropertiesField: subtype.additionalPropertiesField, + typeProviders: subtype.typeProviders, + discriminatorKey: parentDiscriminatorKey, + discriminatorValue: subtype.discriminatorValue, + subtypeNames: null, + discriminatorBaseClassName: parentBaseClassName, + ), + ); } return updatedModelInfos; } - /// Checks if a class extends another class (direct or indirect inheritance) + String _canonicalDiscriminatorKey( + ModelInfo baseClass, + List matchingSubtypes, + ) { + final declaredKey = baseClass.discriminatorKey!; + final resolvedKeys = + matchingSubtypes + .map( + (subtype) => _resolvedDiscriminatorJsonKey(subtype, declaredKey), + ) + .whereType() + .toSet() + .toList() + ..sort(); + + if (resolvedKeys.isEmpty) { + return declaredKey; + } + + if (resolvedKeys.length != 1) { + final formattedKeys = resolvedKeys.map((key) => '"$key"').join(', '); + throw ArgumentError( + 'Discriminated root ${baseClass.className} resolves conflicting ' + 'discriminator keys for "$declaredKey": $formattedKeys. Ensure all ' + 'subtypes expose the same JSON key for the discriminator field.', + ); + } + + return resolvedKeys.single; + } + + String? _resolvedDiscriminatorJsonKey(ModelInfo subtype, String declaredKey) { + for (final field in subtype.fields) { + if (field.jsonKey == declaredKey) { + return declaredKey; + } + } + + for (final field in subtype.fields) { + if (field.name == declaredKey) { + return field.jsonKey; + } + } + + return null; + } + bool _isSubtypeOf(ClassElement2 element, String baseClassName) { return element.allSupertypes.any( (supertype) => supertype.element3.name3 == baseClassName, ); } - /// Validates that the discriminator field or getter is properly overridden in subtype - void _validateDiscriminatorOverride( + String _constructorLabel( ClassElement2 element, - String discriminatorKey, - String expectedValue, + ConstructorElement2 constructor, ) { - // First check for field override - final discriminatorField = element.fields2 - .cast() - .firstWhere( - (field) => field?.name3 == discriminatorKey, - orElse: () => null, - ); - - if (discriminatorField != null) { - // For fields, we can't easily validate the returned value at compile time - // The validation will happen at runtime through the schema validation - // We just ensure the field exists and has the correct type - final fieldType = discriminatorField.type.getDisplayString(); - if (!fieldType.startsWith('String')) { - throw ArgumentError( - 'Discriminator field "$discriminatorKey" override in ${element.name3} ' - 'must be of type String, got $fieldType', - ); - } - return; - } - - // Check for getter override - final discriminatorGetter = element.getters2 - .cast() - .firstWhere( - (getter) => getter?.name3 == discriminatorKey, - orElse: () => null, - ); - - if (discriminatorGetter != null) { - // For getters, validate return type - final returnType = discriminatorGetter.returnType.getDisplayString(); - if (!returnType.startsWith('String')) { - throw ArgumentError( - 'Discriminator getter "$discriminatorKey" override in ${element.name3} ' - 'must return String, got $returnType', - ); - } - return; + if (constructor.name3 == null || constructor.name3 == 'new') { + return '${element.name3}()'; } - throw ArgumentError( - 'Subtype ${element.name3} must override discriminator field or getter "$discriminatorKey"', - ); + return '${element.name3}.${constructor.name3}()'; } } diff --git a/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart b/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart index 193762e0..aabe93f0 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart @@ -318,7 +318,7 @@ class SchemaAstAnalyzer { schemaIdentity: sourceModel.schemaIdentity ?? _declarationVisitKey(resolved.sourceDeclaration), - discriminatedBaseClassName: sourceModel.discriminatedBaseClassName, + discriminatorBaseClassName: sourceModel.discriminatorBaseClassName, isFromSchemaVariable: true, representationType: sourceModel.representationType, @@ -568,7 +568,7 @@ class SchemaAstAnalyzer { schemaIdentity: sourceModel.schemaIdentity ?? _declarationVisitKey(resolved.sourceDeclaration), - discriminatedBaseClassName: sourceModel.discriminatedBaseClassName, + discriminatorBaseClassName: sourceModel.discriminatorBaseClassName, isFromSchemaVariable: true, representationType: transformOutputTypeString ?? sourceModel.representationType, @@ -1038,7 +1038,7 @@ class SchemaAstAnalyzer { discriminatorValue: model.discriminatorValue, subtypeNames: model.subtypeNames, schemaIdentity: _declarationVisitKey(declaration), - discriminatedBaseClassName: model.discriminatedBaseClassName, + discriminatorBaseClassName: model.discriminatorBaseClassName, isFromSchemaVariable: model.isFromSchemaVariable, representationType: model.representationType, isNullableSchema: model.isNullableSchema, @@ -3305,7 +3305,7 @@ class SchemaAstAnalyzer { discriminatorValue: model.discriminatorValue, subtypeNames: model.subtypeNames, schemaIdentity: model.schemaIdentity, - discriminatedBaseClassName: model.discriminatedBaseClassName, + discriminatorBaseClassName: model.discriminatorBaseClassName, isFromSchemaVariable: model.isFromSchemaVariable, representationType: representationType, isNullableSchema: model.isNullableSchema, diff --git a/packages/ack_generator/lib/src/builders/field_builder.dart b/packages/ack_generator/lib/src/builders/field_builder.dart index cacd69cd..814a494c 100644 --- a/packages/ack_generator/lib/src/builders/field_builder.dart +++ b/packages/ack_generator/lib/src/builders/field_builder.dart @@ -1,11 +1,11 @@ import 'dart:convert' show jsonEncode; -import 'package:analyzer/dart/element/type.dart'; import 'package:build/build.dart' show log; import '../models/constraint_info.dart'; import '../models/field_info.dart'; import '../models/model_info.dart'; +import '../utils/type_resolver.dart'; /// Builds field schema expressions class FieldBuilder { @@ -18,29 +18,12 @@ class FieldBuilder { _allModels = models; } - // Centralized type-to-schema mapping - static const _primitiveSchemas = { - 'String': 'Ack.string()', - 'int': 'Ack.integer()', - 'double': 'Ack.double()', - 'bool': 'Ack.boolean()', - 'num': 'Ack.double()', - }; - - static const _specialTypeSchemas = { - 'DateTime': 'Ack.string().datetime()', - 'Duration': 'Ack.integer()', - 'Uri': 'Ack.string().uri()', - }; - - // Constraint application registry - static final _constraints = { + static final _constraintBuilders = { 'minLength': (schema, args) => '$schema.minLength(${args[0]})', 'maxLength': (schema, args) => '$schema.maxLength(${args[0]})', 'notEmpty': (schema, args) => '$schema.notEmpty()', 'email': (schema, args) => '$schema.email()', 'url': (schema, args) => '$schema.url()', - // Use _buildRegexLiteral to safely handle all regex patterns including ''' 'matches': (schema, args) => '$schema.matches(${_buildRegexLiteral(args[0])})', 'min': (schema, args) => '$schema.min(${args[0]})', @@ -53,51 +36,33 @@ class FieldBuilder { final values = args.map((v) => "'$v'").join(', '); return 'Ack.enumString([$values])'; }, - // Use _buildRegexLiteral for pattern as well 'pattern': (schema, args) => '$schema.matches(${_buildRegexLiteral(args[0])})', }; - String buildFieldSchema(FieldInfo field, [ModelInfo? model]) { - // Check if this field is a discriminator field in a subtype - if (model != null && - model.isDiscriminatedSubtype && - model.discriminatorKey != null && - field.name == model.discriminatorKey) { - // Generate Ack.literal() for discriminator field - return 'Ack.literal(\'${model.discriminatorValue}\')'; + String buildFieldSchema(FieldInfo field, [ModelInfo? modelInfo]) { + if (modelInfo != null && + modelInfo.isDiscriminatedSubtype && + modelInfo.discriminatorKey != null && + field.name == modelInfo.discriminatorKey) { + return 'Ack.literal(\'${modelInfo.discriminatorValue}\')'; } - String schema; + var schema = + field.schemaExpressionOverride ?? + SchemableTypeResolver( + allModels: _allModels, + typeProviders: modelInfo?.typeProviders ?? const [], + ).schemaExpressionFor(field.type); - if (field.isPrimitive) { - schema = _buildSchemaForType(field.type); - } else if (field.isEnum) { - schema = _buildEnumSchema(field); - } else if (field.isGeneric) { - schema = _buildGenericSchema(field); - } else if (field.isList) { - schema = _buildListSchema(field); - } else if (field.isMap) { - schema = _buildMapSchema(field); - } else if (field.isSet) { - schema = _buildSetSchema(field); - } else { - // Nested schema reference (custom type with its own schema) - schema = _buildSchemaForType(field.type); - } - - // Apply constraints for (final constraint in field.constraints) { schema = _applyConstraint(schema, constraint); } - // Apply optional if field is not required if (!field.isRequired) { schema = '$schema.optional()'; } - // Apply nullable if field is nullable if (field.isNullable) { schema = '$schema.nullable()'; } @@ -112,141 +77,12 @@ class FieldBuilder { return schema; } - String _buildListSchema(FieldInfo field) { - // Use the DartType API instead of string parsing - final listType = field.type; - - if (listType is ParameterizedType && listType.typeArguments.isNotEmpty) { - final itemType = listType.typeArguments[0]; - final itemSchema = _buildSchemaForType(itemType); - return 'Ack.list($itemSchema)'; - } - - // Fallback for untyped lists - return 'Ack.list(Ack.any())'; - } - - String _buildMapSchema(FieldInfo field) { - // Extract map value type using DartType API - final mapType = field.type; - - // Try to get type arguments from the DartType - if (mapType is ParameterizedType && mapType.typeArguments.length == 2) { - // Use object with additionalProperties for all Maps - // Note: Non-String map keys will fail at runtime during JSON serialization, - // which is acceptable behavior - the generator should not prevent code generation - return 'Ack.object({}, additionalProperties: true)'; - } - - // Fallback for untyped maps - return 'Ack.object({}, additionalProperties: true)'; - } - - String _buildSetSchema(FieldInfo field) { - // Sets are serialized as arrays with unique constraint - // Extract set element type using DartType API - final setType = field.type; - - if (setType is ParameterizedType && setType.typeArguments.isNotEmpty) { - final elementType = setType.typeArguments[0]; - final elementSchema = _buildSchemaForType(elementType); - return 'Ack.list($elementSchema).unique()'; - } - - // Fallback for untyped sets - return 'Ack.list(Ack.any()).unique()'; - } - - String _buildSchemaForType(DartType type) { - // Use withNullability: false to get type name without '?' suffix - final typeName = type.getDisplayString(withNullability: false); - - // Check primitives via registry (use simple string matching) - if (_primitiveSchemas.containsKey(typeName)) { - return _primitiveSchemas[typeName]!; - } - - // Check special types via registry - for (final entry in _specialTypeSchemas.entries) { - if (_isDartCoreType(type, entry.key)) { - return entry.value; - } - } - - // Dynamic/Object - if (type.toString() == 'dynamic' || type.isDartCoreObject) { - return 'Ack.any()'; - } - - // Generic type parameter - if (type is TypeParameterType) { - return 'Ack.any()'; - } - - // Collections (recursive) - if (type.isDartCoreList) { - if (type is ParameterizedType && type.typeArguments.isNotEmpty) { - return 'Ack.list(${_buildSchemaForType(type.typeArguments[0])})'; - } - return 'Ack.list(Ack.any())'; - } - - if (type.isDartCoreMap) { - return 'Ack.object({}, additionalProperties: true)'; - } - - if (type.isDartCoreSet) { - if (type is ParameterizedType && type.typeArguments.isNotEmpty) { - return 'Ack.list(${_buildSchemaForType(type.typeArguments[0])}).unique()'; - } - return 'Ack.list(Ack.any()).unique()'; - } - - // Custom schema reference - look up by class name to honor custom schemaName - final modelInfo = _allModels.cast().firstWhere( - (m) => m?.className == typeName, - orElse: () => null, - ); - - if (modelInfo != null) { - // Use the model's schema class name (handles @AckModel(schemaName: ...)) - final schemaClassName = modelInfo.schemaClassName; - return '${schemaClassName[0].toLowerCase()}${schemaClassName.substring(1)}'; - } - - // Fallback to convention if model not found - return '${typeName[0].toLowerCase()}${typeName.substring(1)}Schema'; - } - - /// Checks if a type is a specific dart:core type by name - bool _isDartCoreType(DartType type, String typeName) { - final element = type.element3; - return element?.name3 == typeName && - element?.library2?.name3 == 'dart.core'; - } - - String _buildEnumSchema(FieldInfo field) { - // Use withNullability: false to get the enum type name without '?' suffix - final enumTypeName = field.type.getDisplayString(withNullability: false); - - // Generate Ack.enumValues(EnumType.values) - // This preserves the actual enum type through validation - return 'Ack.enumValues<$enumTypeName>($enumTypeName.values)'; - } - - String _buildGenericSchema(FieldInfo field) { - // Generic types are treated as dynamic/any since we can't know the actual type at generation time - return 'Ack.any()'; - } - String _applyConstraint(String schema, ConstraintInfo constraint) { - final generator = _constraints[constraint.name]; + final generator = _constraintBuilders[constraint.name]; if (generator != null) { return generator(schema, constraint.arguments); } - // Unknown constraints are intentionally ignored to allow custom extensions. - // Log at fine level for diagnostics without noisy build warnings. log.fine( 'Unknown constraint "${constraint.name}" ignored. ' 'Check spelling or ensure constraint is registered.', @@ -259,15 +95,10 @@ class FieldBuilder { /// Uses jsonEncode to properly handle all special characters (backslashes, /// newlines, unicode, etc.) then converts to single-quote format. String _escapeForSingleQuotedString(String value) { - // Use jsonEncode to get proper escaping for special chars final jsonStr = jsonEncode(value); - // Remove outer double quotes: "content" -> content var escaped = jsonStr.substring(1, jsonStr.length - 1); - // Unescape double quotes: \" -> " escaped = escaped.replaceAll(r'\"', '"'); - // Escape single quotes: ' -> \' escaped = escaped.replaceAll("'", r"\'"); - // Escape dollar signs to prevent string interpolation: $ -> \$ escaped = escaped.replaceAll(r'$', r'\$'); return escaped; } @@ -278,16 +109,11 @@ class FieldBuilder { /// - Raw triple-quoted string if pattern doesn't contain `'''` /// - Double-quoted string with escaping otherwise (including `$` escaping) static String _buildRegexLiteral(String pattern) { - // If pattern doesn't contain triple quotes, use raw triple-quoted string if (!pattern.contains("'''")) { return "r'''$pattern'''"; } - // Otherwise, use jsonEncode to get a safely escaped double-quoted string. - // jsonEncode handles special characters but NOT `$`, which would trigger - // Dart string interpolation in generated code. We must escape it manually. final encoded = jsonEncode(pattern); - // Escape $ as \$ to prevent string interpolation in generated Dart code return encoded.replaceAll(r'$', r'\$'); } } diff --git a/packages/ack_generator/lib/src/builders/schema_builder.dart b/packages/ack_generator/lib/src/builders/schema_builder.dart index 54eb89aa..101405c2 100644 --- a/packages/ack_generator/lib/src/builders/schema_builder.dart +++ b/packages/ack_generator/lib/src/builders/schema_builder.dart @@ -2,6 +2,7 @@ import 'package:code_builder/code_builder.dart'; import 'package:dart_style/dart_style.dart'; import '../models/model_info.dart'; +import '../utils/annotation_utils.dart'; import 'field_builder.dart' as fb; /// Builds schema functions using code_builder @@ -41,13 +42,13 @@ class SchemaBuilder { } Field buildSchemaField(ModelInfo model) { - // Convert schema class name to camelCase variable name - // e.g., "UserSchema" -> "userSchema", "CustomUserSchema" -> "customUserSchema" - final variableName = _toCamelCase(model.schemaClassName); + final schemaVariableName = schemaVariableNameForSchemaClassName( + model.schemaClassName, + ); return Field( (b) => b - ..name = variableName + ..name = schemaVariableName ..modifier = FieldModifier.final$ ..assignment = Code(_buildSchemaDefinition(model)) ..docs.addAll([ @@ -57,24 +58,10 @@ class SchemaBuilder { ); } - String _toCamelCase(String text) { - if (text.isEmpty) return text; - return text[0].toLowerCase() + text.substring(1); - } - String _buildSchemaDefinition(ModelInfo model) { - // Check if this is a discriminated base class - if (model.isDiscriminatedBaseDefinition) { - return _buildDiscriminatedSchema(model); - } - - // Subtypes need the model passed to the field builder to generate discriminator literals - if (model.isDiscriminatedSubtype) { - return _buildObjectSchema(model, passModelToFieldBuilder: true); - } - - // Regular objects don't need model context - return _buildObjectSchema(model); + return model.isDiscriminatedBaseDefinition + ? _buildDiscriminatedSchema(model) + : _buildObjectSchema(model); } /// Builds a discriminated schema for base classes @@ -87,23 +74,21 @@ class SchemaBuilder { buffer.write(' discriminatorKey: \'$discriminatorKey\',\n'); buffer.write(' schemas: {\n'); - // Generate schema references for each subtype final schemaRefs = []; for (final entry in subtypeNames.entries) { final discriminatorValue = entry.key; final subtypeClassName = entry.value; - // Look up the subtype's ModelInfo to get its custom schemaClassName - // This handles cases where the subtype has a custom schemaName annotation final subtypeModelInfo = _allModels.cast().firstWhere( (m) => m?.className == subtypeClassName, orElse: () => null, ); - // Use the subtype's schemaClassName if found, otherwise fall back to default final subtypeSchemaName = subtypeModelInfo != null - ? _toCamelCase(subtypeModelInfo.schemaClassName) - : _toCamelCase('${subtypeClassName}Schema'); + ? schemaVariableNameForSchemaClassName( + subtypeModelInfo.schemaClassName, + ) + : schemaVariableNameForSchemaClassName('${subtypeClassName}Schema'); schemaRefs.add(' \'$discriminatorValue\': $subtypeSchemaName'); } @@ -115,53 +100,39 @@ class SchemaBuilder { return buffer.toString(); } - /// Common logic for building object schemas - /// - /// Extracted from _buildSubtypeSchema and _buildRegularObjectSchema to eliminate duplication. - /// The only difference is whether the model is passed to the field builder (for subtypes). - String _buildObjectSchema( - ModelInfo model, { - bool passModelToFieldBuilder = false, - }) { + /// Builds an object schema for both regular models and discriminated leaves. + String _buildObjectSchema(ModelInfo model) { final buffer = StringBuffer(); + final fieldEntries = []; - // Build field definitions with descriptions - final fieldDefs = []; - - // For discriminated subtypes, add the discriminator field first if (model.isDiscriminatedSubtype && model.discriminatorKey != null && model.discriminatorValue != null) { - fieldDefs.add( + fieldEntries.add( "'${model.discriminatorKey}': Ack.literal('${model.discriminatorValue}')", ); } for (final field in model.fields) { - // Skip the discriminator field for subtypes - it was already added above - // This prevents duplicate keys in the generated schema if (model.isDiscriminatedSubtype && model.discriminatorKey != null && field.jsonKey == model.discriminatorKey) { continue; } - final fieldSchema = passModelToFieldBuilder - ? _fieldBuilder.buildFieldSchema(field, model) - : _fieldBuilder.buildFieldSchema(field); + final fieldSchema = _fieldBuilder.buildFieldSchema(field, model); - fieldDefs.add("'${field.jsonKey}': $fieldSchema"); + fieldEntries.add("'${field.jsonKey}': $fieldSchema"); } buffer.write('Ack.object({'); - if (fieldDefs.isNotEmpty) { + if (fieldEntries.isNotEmpty) { buffer.write('\n '); - buffer.write(fieldDefs.join(',\n ')); + buffer.write(fieldEntries.join(',\n ')); buffer.write(',\n'); } buffer.write('}'); - // Add additionalProperties if enabled if (model.additionalProperties) { buffer.write(', additionalProperties: true'); } diff --git a/packages/ack_generator/lib/src/builders/type_builder.dart b/packages/ack_generator/lib/src/builders/type_builder.dart index 09511b40..d00832e7 100644 --- a/packages/ack_generator/lib/src/builders/type_builder.dart +++ b/packages/ack_generator/lib/src/builders/type_builder.dart @@ -1013,10 +1013,10 @@ ${cases.join(',\n')}, Set _extractDependencies(ModelInfo model, _ModelLookups lookups) { final dependencies = {}; - final discriminatedBaseClassName = model.discriminatedBaseClassName; - if (discriminatedBaseClassName != null && - lookups.byClassName.containsKey(discriminatedBaseClassName)) { - dependencies.add(discriminatedBaseClassName); + final discriminatorBaseClassName = model.discriminatorBaseClassName; + if (discriminatorBaseClassName != null && + lookups.byClassName.containsKey(discriminatorBaseClassName)) { + dependencies.add(discriminatorBaseClassName); } for (final field in model.fields) { diff --git a/packages/ack_generator/lib/src/generator.dart b/packages/ack_generator/lib/src/generator.dart index 689f5bc3..970a03a4 100644 --- a/packages/ack_generator/lib/src/generator.dart +++ b/packages/ack_generator/lib/src/generator.dart @@ -13,13 +13,14 @@ import 'analyzer/schema_ast_analyzer.dart'; import 'builders/schema_builder.dart'; import 'builders/type_builder.dart'; import 'models/model_info.dart'; +import 'utils/annotation_utils.dart'; import 'validation/code_validator.dart'; import 'validation/model_validator.dart'; /// Logger for schema generation warnings and diagnostics. final _log = Logger('AckSchemaGenerator'); -/// Generates schemas for classes annotated with @AckModel +/// Generates schemas for classes annotated with `@Schemable`. /// /// This generator processes all annotated classes in a source file together to create /// a single .g.dart file with schema variables. @@ -34,7 +35,7 @@ class AckSchemaGenerator extends Generator { final annotatedVariables = []; final annotatedGetters = []; - // Find all classes annotated with @AckModel and variables annotated with @AckType + // Find all classes annotated with @Schemable and variables annotated with @AckType for (final element in library.allElements) { if (element is ClassElement2) { if (_hasAckTypeAnnotation(element)) { @@ -45,9 +46,7 @@ class AckSchemaGenerator extends Generator { 'Remove @AckType from the class and annotate a schema variable instead.', ); } - final annotation = TypeChecker.typeNamed( - AckModel, - ).firstAnnotationOf(element); + final annotation = firstSchemableAnnotationOf(element); if (annotation != null) { annotatedElements.add(element); } @@ -71,6 +70,10 @@ class AckSchemaGenerator extends Generator { return ''; } + if (annotatedElements.isNotEmpty) { + _validateAckImportForSchemable(library, annotatedElements.first); + } + // Generate all schema fields and extension types for this file final schemaFields = []; final helperMethods = []; @@ -81,16 +84,14 @@ class AckSchemaGenerator extends Generator { final typeBuilder = TypeBuilder(); typeBuilder.setAckImportPrefix(_resolveAckImportPrefix(library)); - // First pass: Analyze all models individually (from @AckModel classes) + // First pass: analyze all schemable models individually. final modelInfos = []; for (final element in annotatedElements) { try { // Validate element can be processed _validateElement(element); - final annotation = TypeChecker.typeNamed( - AckModel, - ).firstAnnotationOf(element)!; + final annotation = firstSchemableAnnotationOf(element)!; final annotationReader = ConstantReader(annotation); // Analyze the model @@ -98,7 +99,7 @@ class AckSchemaGenerator extends Generator { modelInfos.add(modelInfo); } catch (e) { throw InvalidGenerationSourceError( - 'Invalid @AckModel annotation on class ${element.name3}: $e', + 'Invalid @Schemable annotation on class ${element.name3}: $e', element: element, todo: 'Check annotation syntax. See: https://docs.page/btwld/ack/annotations', @@ -150,8 +151,8 @@ class AckSchemaGenerator extends Generator { // bases with their branch models and enforce single ownership. final linkedModelInfos = _linkSchemaVariableDiscriminatedModels(modelInfos); - // Third pass (class-based path): build discriminator relationships - // for @AckModel hierarchies only. + // Third pass (class-based path): build discriminator relationships for + // schemable class hierarchies only. final finalModelInfos = analyzer.buildDiscriminatorRelationships( linkedModelInfos, annotatedElements, @@ -262,22 +263,54 @@ class AckSchemaGenerator extends Generator { return formattedCode; } + void _validateAckImportForSchemable( + LibraryReader library, + ClassElement2 exampleElement, + ) { + final fragment = library.element.firstFragment; + if (fragment.partIncludes.isEmpty) { + return; + } + + final ackImports = fragment.libraryImports2.where(_isAckImport).toList(); + if (ackImports.isEmpty) { + throw InvalidGenerationSourceError( + '@Schemable models with a part directive require ' + "import 'package:ack/ack.dart'; because generated code references Ack.", + element: exampleElement, + todo: "Add: import 'package:ack/ack.dart';", + ); + } + + final hasUnprefixedAckImport = ackImports.any((import) { + final prefix = import.prefix2?.element.name3; + return prefix == null || prefix.isEmpty; + }); + + if (!hasUnprefixedAckImport) { + throw InvalidGenerationSourceError( + '@Schemable models currently require an unprefixed ' + "import 'package:ack/ack.dart'; in the source library.", + element: exampleElement, + todo: + "Add an unprefixed import alongside prefixed imports: import 'package:ack/ack.dart';", + ); + } + } + /// Validates that the element can be processed by the generator void _validateElement(ClassElement2 element) { - // Check if this is a discriminated base class (abstract is allowed for discriminated types) - final annotation = TypeChecker.typeNamed( - AckModel, - ).firstAnnotationOf(element); + final annotation = firstSchemableAnnotationOf(element); if (annotation != null) { final annotationReader = ConstantReader(annotation); - final discriminatedKey = annotationReader.read('discriminatedKey').isNull + final discriminatorKey = annotationReader.read('discriminatorKey').isNull ? null - : annotationReader.read('discriminatedKey').stringValue; + : annotationReader.read('discriminatorKey').stringValue; - // Allow abstract classes only if they have discriminatedKey - if (element.isAbstract && discriminatedKey == null) { + // Non-discriminated abstract schemable classes are not supported. + if (element.isAbstract && discriminatorKey == null) { throw InvalidGenerationSourceError( - '@AckModel cannot be applied to abstract classes unless discriminatedKey is specified.', + '@Schemable cannot be applied to abstract classes unless discriminatorKey is specified.', element: element, ); } @@ -517,7 +550,7 @@ class AckSchemaGenerator extends Generator { discriminatorValue: discriminatorValue, // For @AckType flows this references the generated base type name // (e.g. `PetType`) used by dependency sorting. - discriminatedBaseClassName: baseModel.className, + discriminatorBaseClassName: baseModel.className, ); } } @@ -530,7 +563,7 @@ class AckSchemaGenerator extends Generator { String? discriminatorKey, String? discriminatorValue, Map? subtypeNames, - String? discriminatedBaseClassName, + String? discriminatorBaseClassName, }) { return ModelInfo( className: model.className, @@ -539,12 +572,13 @@ class AckSchemaGenerator extends Generator { fields: model.fields, additionalProperties: model.additionalProperties, additionalPropertiesField: model.additionalPropertiesField, + typeProviders: model.typeProviders, discriminatorKey: discriminatorKey ?? model.discriminatorKey, discriminatorValue: discriminatorValue ?? model.discriminatorValue, subtypeNames: subtypeNames ?? model.subtypeNames, schemaIdentity: model.schemaIdentity, - discriminatedBaseClassName: - discriminatedBaseClassName ?? model.discriminatedBaseClassName, + discriminatorBaseClassName: + discriminatorBaseClassName ?? model.discriminatorBaseClassName, isFromSchemaVariable: model.isFromSchemaVariable, representationType: model.representationType, isNullableSchema: model.isNullableSchema, diff --git a/packages/ack_generator/lib/src/models/field_info.dart b/packages/ack_generator/lib/src/models/field_info.dart index 81f44fe9..f1785a97 100644 --- a/packages/ack_generator/lib/src/models/field_info.dart +++ b/packages/ack_generator/lib/src/models/field_info.dart @@ -16,6 +16,7 @@ class FieldInfo { final bool isNullable; final List constraints; final String? description; + final String? schemaExpressionOverride; /// For list/set fields containing schema variable references (e.g., `Ack.list(addressSchema)`), /// this stores the schema variable name so the type builder can generate @@ -52,6 +53,7 @@ class FieldInfo { required this.isNullable, required this.constraints, this.description, + this.schemaExpressionOverride, this.listElementSchemaRef, this.nestedSchemaRef, this.displayTypeOverride, @@ -61,6 +63,49 @@ class FieldInfo { this.nestedSchemaCastTypeOverride, }); + FieldInfo copyWith({ + String? name, + String? jsonKey, + DartType? type, + bool? isRequired, + bool? isNullable, + List? constraints, + String? description, + String? schemaExpressionOverride, + String? listElementSchemaRef, + String? nestedSchemaRef, + String? displayTypeOverride, + String? collectionElementDisplayTypeOverride, + String? collectionElementCastTypeOverride, + bool? collectionElementIsCustomType, + String? nestedSchemaCastTypeOverride, + }) { + return FieldInfo( + name: name ?? this.name, + jsonKey: jsonKey ?? this.jsonKey, + type: type ?? this.type, + isRequired: isRequired ?? this.isRequired, + isNullable: isNullable ?? this.isNullable, + constraints: constraints ?? this.constraints, + description: description ?? this.description, + schemaExpressionOverride: + schemaExpressionOverride ?? this.schemaExpressionOverride, + listElementSchemaRef: listElementSchemaRef ?? this.listElementSchemaRef, + nestedSchemaRef: nestedSchemaRef ?? this.nestedSchemaRef, + displayTypeOverride: displayTypeOverride ?? this.displayTypeOverride, + collectionElementDisplayTypeOverride: + collectionElementDisplayTypeOverride ?? + this.collectionElementDisplayTypeOverride, + collectionElementCastTypeOverride: + collectionElementCastTypeOverride ?? + this.collectionElementCastTypeOverride, + collectionElementIsCustomType: + collectionElementIsCustomType ?? this.collectionElementIsCustomType, + nestedSchemaCastTypeOverride: + nestedSchemaCastTypeOverride ?? this.nestedSchemaCastTypeOverride, + ); + } + /// Whether this field references another schema model bool get isNestedSchema => !isPrimitive && !isList && !isMap && !isSet && !isEnum && !isGeneric; @@ -96,26 +141,13 @@ class FieldInfo { /// Get enum values if this is an enum type List get enumValues { if (!isEnum) return []; - final element = type.element3; - if (element == null) return []; - - // For enums, get the enum constants using the analyzer API - if (element is EnumElement2) { - try { - final enumConstants = element.constants2 - .map((field) => field.name3!) - .toList(); - - return enumConstants; - } catch (e) { - // If there's any issue with the analyzer API, fall back to empty list - // This maintains backward compatibility with manual @EnumString annotations - _log.warning('Could not extract enum values for ${element.name3}: $e'); - return []; - } + final element = type.element3 as EnumElement2; + try { + return element.constants2.map((field) => field.name3!).toList(); + } catch (e) { + _log.warning('Could not extract enum values for ${element.name3}: $e'); + return []; } - - return []; } /// Whether this is a List type diff --git a/packages/ack_generator/lib/src/models/model_info.dart b/packages/ack_generator/lib/src/models/model_info.dart index 6913b471..1ca40890 100644 --- a/packages/ack_generator/lib/src/models/model_info.dart +++ b/packages/ack_generator/lib/src/models/model_info.dart @@ -1,4 +1,5 @@ import 'field_info.dart'; +import 'type_provider_info.dart'; /// Default representation type for object schemas const String kMapType = 'Map'; @@ -11,6 +12,7 @@ class ModelInfo { final List fields; final bool additionalProperties; final String? additionalPropertiesField; + final List typeProviders; /// Computed property: returns list of required field JSON keys List get requiredFields => @@ -26,7 +28,7 @@ class ModelInfo { final String? discriminatorValue; /// Map of discriminator values to subtype identifiers (only for base classes). - /// For @AckModel: discriminator value → className (e.g., 'cat' → 'Cat') + /// For @Schemable: discriminator value → className (e.g., 'cat' → 'Cat') /// For @AckType: discriminator value → schemaClassName (e.g., 'cat' → 'catSchema') final Map? subtypeNames; @@ -39,7 +41,7 @@ class ModelInfo { /// /// For @AckType schema-variable subtypes this stores the generated base type /// name (for example, `PetType`), not the schema variable name. - final String? discriminatedBaseClassName; + final String? discriminatorBaseClassName; /// Computed property: Whether this model has a discriminator key. /// @@ -50,7 +52,7 @@ class ModelInfo { bool get isDiscriminatedBaseDefinition => discriminatorKey != null && subtypeNames != null; - /// Computed property: Whether this class is a discriminated subtype (has discriminatedValue) + /// Computed property: Whether this class is a discriminated subtype (has discriminatorValue) bool get isDiscriminatedSubtype => discriminatorValue != null; /// Whether this ModelInfo was created from a schema variable (not a class) @@ -61,7 +63,7 @@ class ModelInfo { /// Whether the schema variable is nullable via `.nullable()`. /// - /// This only applies to @AckType schema variables (not @AckModel classes). + /// This only applies to @AckType schema variables (not @Schemable classes). final bool isNullableSchema; const ModelInfo({ @@ -71,11 +73,12 @@ class ModelInfo { required this.fields, this.additionalProperties = false, this.additionalPropertiesField, + this.typeProviders = const [], this.discriminatorKey, this.discriminatorValue, this.subtypeNames, this.schemaIdentity, - this.discriminatedBaseClassName, + this.discriminatorBaseClassName, this.isFromSchemaVariable = false, this.representationType = kMapType, this.isNullableSchema = false, diff --git a/packages/ack_generator/lib/src/models/type_provider_info.dart b/packages/ack_generator/lib/src/models/type_provider_info.dart new file mode 100644 index 00000000..be3d14ae --- /dev/null +++ b/packages/ack_generator/lib/src/models/type_provider_info.dart @@ -0,0 +1,41 @@ +import 'package:analyzer/dart/element/type.dart'; + +String typeIdentityKey(DartType type) { + final baseIdentity = _baseTypeIdentity(type); + + if (type is ParameterizedType && type.typeArguments.isNotEmpty) { + final typeArguments = type.typeArguments.map(typeIdentityKey).join(','); + return '$baseIdentity<$typeArguments>'; + } + + return baseIdentity; +} + +String _baseTypeIdentity(DartType type) { + final element = type.element3; + final libraryUri = element?.library2?.uri.toString(); + final elementName = element?.name3; + + if (libraryUri != null && elementName != null) { + return '$libraryUri::$elementName'; + } + + return type.getDisplayString(withNullability: false); +} + +class TypeProviderInfo { + final String providerTypeName; + final DartType targetType; + final String accessor; + + const TypeProviderInfo({ + required this.providerTypeName, + required this.targetType, + required this.accessor, + }); + + String get targetTypeIdentityKey => typeIdentityKey(targetType); + + String get targetTypeName => + targetType.getDisplayString(withNullability: false); +} diff --git a/packages/ack_generator/lib/src/utils/annotation_utils.dart b/packages/ack_generator/lib/src/utils/annotation_utils.dart new file mode 100644 index 00000000..55542982 --- /dev/null +++ b/packages/ack_generator/lib/src/utils/annotation_utils.dart @@ -0,0 +1,77 @@ +import 'package:ack_annotations/ack_annotations.dart'; +import 'package:analyzer/dart/constant/value.dart'; +import 'package:analyzer/dart/element/element2.dart'; +import 'package:source_gen/source_gen.dart'; + +final TypeChecker schemableChecker = TypeChecker.typeNamed(Schemable); +final TypeChecker ackModelChecker = TypeChecker.typeNamed(AckModel); +final TypeChecker schemaConstructorChecker = TypeChecker.typeNamed( + SchemaConstructor, +); +final TypeChecker schemaKeyChecker = TypeChecker.typeNamed(SchemaKey); +final TypeChecker descriptionChecker = TypeChecker.typeNamed(Description); + +DartObject? firstSchemableAnnotationOf(Annotatable element) { + return schemableChecker.firstAnnotationOf(element) ?? + ackModelChecker.firstAnnotationOf(element); +} + +String schemaClassNameForElement(InterfaceElement2 element) { + final annotation = firstSchemableAnnotationOf(element); + if (annotation == null) { + return '${element.name3}Schema'; + } + + final reader = ConstantReader(annotation); + final schemaNameReader = reader.peek('schemaName'); + if (schemaNameReader != null && !schemaNameReader.isNull) { + return schemaNameReader.stringValue; + } + + return '${element.name3}Schema'; +} + +String schemaVariableNameForElement(InterfaceElement2 element) { + return schemaVariableNameForSchemaClassName( + schemaClassNameForElement(element), + ); +} + +String schemaVariableNameForSchemaClassName(String schemaClassName) { + return schemaClassName[0].toLowerCase() + schemaClassName.substring(1); +} + +String? importPrefixForElement( + LibraryElement2? currentLibrary, + InterfaceElement2 targetElement, +) { + if (currentLibrary == null) { + return null; + } + + final targetName = targetElement.name3; + if (targetName == null || targetName.isEmpty) { + return null; + } + + for (final import in currentLibrary.firstFragment.libraryImports2) { + final prefix = import.prefix2?.element.name3; + if (prefix == null || prefix.isEmpty) { + continue; + } + + final importedElement = import.namespace.get2(targetName); + if (importedElement == targetElement) { + return prefix; + } + + final exportedElement = import.importedLibrary2?.exportNamespace.get2( + targetName, + ); + if (exportedElement == targetElement) { + return prefix; + } + } + + return null; +} diff --git a/packages/ack_generator/lib/src/utils/case_style_utils.dart b/packages/ack_generator/lib/src/utils/case_style_utils.dart new file mode 100644 index 00000000..dca13e79 --- /dev/null +++ b/packages/ack_generator/lib/src/utils/case_style_utils.dart @@ -0,0 +1,58 @@ +import 'package:ack_annotations/ack_annotations.dart'; + +String applyCaseStyle(CaseStyle style, String input) { + return switch (style) { + CaseStyle.none => input, + CaseStyle.camelCase => _toCamelCase(input), + CaseStyle.pascalCase => _toPascalCase(input), + CaseStyle.snakeCase => _joinWords(input, '_'), + CaseStyle.paramCase => _joinWords(input, '-'), + }; +} + +String _toCamelCase(String input) { + final words = _splitWords(input); + if (words.isEmpty) return input; + + final tail = words.skip(1).map(_capitalize).join(); + return words.first.toLowerCase() + tail; +} + +String _toPascalCase(String input) { + return _splitWords(input).map(_capitalize).join(); +} + +String _joinWords(String input, String separator) { + final words = _splitWords(input); + if (words.isEmpty) return input; + return words.map((word) => word.toLowerCase()).join(separator); +} + +List _splitWords(String input) { + if (input.isEmpty) return const []; + + final normalized = input + .replaceAllMapped( + RegExp(r'([A-Z]+)([A-Z][a-z])'), + (match) => '${match.group(1)} ${match.group(2)}', + ) + .replaceAllMapped( + RegExp(r'([a-z0-9])([A-Z])'), + (match) => '${match.group(1)} ${match.group(2)}', + ) + .replaceAll(RegExp(r'[_\-\s]+'), ' ') + .trim(); + + if (normalized.isEmpty) return const []; + + return normalized + .split(' ') + .where((word) => word.isNotEmpty) + .toList(growable: false); +} + +String _capitalize(String input) { + if (input.isEmpty) return input; + final lower = input.toLowerCase(); + return lower[0].toUpperCase() + lower.substring(1); +} diff --git a/packages/ack_generator/lib/src/utils/type_resolver.dart b/packages/ack_generator/lib/src/utils/type_resolver.dart new file mode 100644 index 00000000..1402be7a --- /dev/null +++ b/packages/ack_generator/lib/src/utils/type_resolver.dart @@ -0,0 +1,201 @@ +import 'package:analyzer/dart/element/element2.dart'; +import 'package:analyzer/dart/element/type.dart'; + +import '../models/model_info.dart'; +import '../models/type_provider_info.dart'; +import 'annotation_utils.dart'; + +class UnsupportedSchemaTypeError implements Exception { + final String typeName; + + const UnsupportedSchemaTypeError(this.typeName); + + @override + String toString() => 'Unsupported schema type: $typeName'; +} + +class SchemableTypeResolver { + final List allModels; + final List typeProviders; + final LibraryElement2? currentLibrary; + + const SchemableTypeResolver({ + this.allModels = const [], + this.typeProviders = const [], + this.currentLibrary, + }); + + String schemaExpressionFor(DartType type) { + final typeName = type.getDisplayString(withNullability: false); + + final primitiveSchema = _primitiveSchemaFor(typeName); + if (primitiveSchema != null) { + return primitiveSchema; + } + + final specialSchema = _specialSchemaFor(type); + if (specialSchema != null) { + return specialSchema; + } + + if (type is TypeParameterType || + type.isDartCoreObject || + typeName == 'dynamic') { + return 'Ack.any()'; + } + + if (_isEnum(type)) { + return 'Ack.enumValues<$typeName>($typeName.values)'; + } + + if (type.isDartCoreList) { + final itemType = _firstTypeArgument(type); + if (itemType == null) { + return 'Ack.list(Ack.any())'; + } + + return 'Ack.list(${schemaExpressionFor(itemType)})'; + } + + if (type.isDartCoreMap) { + return 'Ack.object({}, additionalProperties: true)'; + } + + if (type.isDartCoreSet) { + final itemType = _firstTypeArgument(type); + if (itemType == null) { + return 'Ack.list(Ack.any()).unique()'; + } + + return 'Ack.list(${schemaExpressionFor(itemType)}).unique()'; + } + + final schemableSchema = _schemableSchemaReferenceFor(type); + if (schemableSchema != null) { + return schemableSchema; + } + + final provider = _providerFor(type); + if (provider != null) { + return '(${provider.accessor}.schema as AckSchema)'; + } + + throw UnsupportedSchemaTypeError(typeName); + } + + String? schemableSchemaReferenceFor(DartType type) { + return _schemableSchemaReferenceFor(type); + } + + TypeProviderInfo? _providerFor(DartType type) { + final targetTypeKey = typeIdentityKey(type); + for (final provider in typeProviders) { + if (provider.targetTypeIdentityKey == targetTypeKey) { + return provider; + } + } + return null; + } + + String? _primitiveSchemaFor(String typeName) { + return switch (typeName) { + 'String' => 'Ack.string()', + 'int' => 'Ack.integer()', + 'double' => 'Ack.double()', + 'bool' => 'Ack.boolean()', + 'num' => 'Ack.double()', + _ => null, + }; + } + + String? _specialSchemaFor(DartType type) { + if (_isDartCoreType(type, 'DateTime')) { + return 'Ack.string().datetime()'; + } + if (_isDartCoreType(type, 'Uri')) { + return 'Ack.string().uri()'; + } + if (_isDartCoreType(type, 'Duration')) { + return 'Ack.integer()'; + } + return null; + } + + bool _isEnum(DartType type) => type.element3 is EnumElement2; + + String? _schemableSchemaReferenceFor(DartType type) { + final typeName = type.getDisplayString(withNullability: false); + final element = type.element3; + if (element is InterfaceElement2) { + final annotation = firstSchemableAnnotationOf(element); + if (annotation == null) { + return null; + } + + final schemaVariableName = schemaVariableNameForElement(element); + final prefix = + importPrefixForElement(currentLibrary, element) ?? + _prefixForDisplayName(typeName, element.name3); + if (prefix == null) { + return schemaVariableName; + } + + return '$prefix.$schemaVariableName'; + } + + final knownModel = _knownModelForDisplayType(typeName); + if (knownModel != null) { + return schemaVariableNameForSchemaClassName(knownModel.schemaClassName); + } + return null; + } + + ModelInfo? _knownModelForDisplayType(String displayType) { + final baseTypeName = _baseTypeName(displayType); + return allModels.cast().firstWhere( + (model) => model?.className == baseTypeName, + orElse: () => null, + ); + } + + String _baseTypeName(String displayType) { + final withoutGenerics = _stripGenerics(displayType); + final segments = withoutGenerics.split('.'); + return segments.isEmpty ? withoutGenerics : segments.last; + } + + String? _prefixForDisplayName(String displayName, String? elementName) { + if (elementName == null) return null; + + final trimmedDisplayName = _stripGenerics(displayName); + + final suffix = '.$elementName'; + if (!trimmedDisplayName.endsWith(suffix)) { + return null; + } + + return trimmedDisplayName.substring( + 0, + trimmedDisplayName.length - suffix.length, + ); + } + + String _stripGenerics(String s) { + final i = s.indexOf('<'); + return i == -1 ? s : s.substring(0, i); + } + + DartType? _firstTypeArgument(DartType type) { + if (type is! ParameterizedType || type.typeArguments.isEmpty) { + return null; + } + + return type.typeArguments.first; + } + + bool _isDartCoreType(DartType type, String typeName) { + final element = type.element3; + return element?.name3 == typeName && + element?.library2?.name3 == 'dart.core'; + } +} diff --git a/packages/ack_generator/test/ack_field_comprehensive_test.dart b/packages/ack_generator/test/ack_field_comprehensive_test.dart index 37734bae..450f58f9 100644 --- a/packages/ack_generator/test/ack_field_comprehensive_test.dart +++ b/packages/ack_generator/test/ack_field_comprehensive_test.dart @@ -6,466 +6,319 @@ import 'package:test/test.dart'; import 'test_utils/test_assets.dart'; void main() { - group('AckField Annotation Comprehensive Tests', () { - group('Basic AckField Usage', () { - test('should handle required field annotation', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/required_fields.dart': ''' + group('Schemable constructor contract', () { + test('maps required, nullable, and defaulted named parameters', () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/retry_policy.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel() -class RequiredFieldsModel { - @AckField(requiredMode: AckFieldRequiredMode.required) - final String mandatoryField; - - @AckField(requiredMode: AckFieldRequiredMode.optional) - final String? optionalField; - - final String defaultField; // No annotation - - RequiredFieldsModel({ - required this.mandatoryField, - this.optionalField, - required this.defaultField, +@Schemable() +class RetryPolicy { + final String name; + final String? alias; + final String? nickname; + final int retries; + + const RetryPolicy({ + required this.name, + required this.alias, + this.nickname, + this.retries = 3, }); } ''', - }, - outputs: { - 'test_pkg|lib/required_fields.g.dart': decodedMatches( - allOf([ - contains('final requiredFieldsModelSchema = Ack.object({'), - contains("'mandatoryField': Ack.string()"), - contains("'optionalField': Ack.string().optional().nullable()"), - contains("'defaultField': Ack.string()"), - ]), - ), - }, - ); - }); + }, + outputs: { + 'test_pkg|lib/retry_policy.g.dart': decodedMatches( + allOf([ + contains("'name': Ack.string()"), + contains("'alias': Ack.string().nullable()"), + contains("'nickname': Ack.string().optional().nullable()"), + contains("'retries': Ack.integer().optional()"), + ]), + ), + }, + ); + }); - test('should handle custom jsonKey annotation', () async { - final builder = ackGenerator(BuilderOptions.empty); + test('applies caseStyle and SchemaKey overrides from parameters', () async { + final builder = ackGenerator(BuilderOptions.empty); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/json_key.dart': ''' + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/api_payload.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel() -class JsonKeyModel { - @AckField(jsonKey: 'user_name') - final String userName; - - @AckField(jsonKey: 'email_address') - final String email; - - @AckField(jsonKey: 'phone-number') - final String phone; - - JsonKeyModel({ - required this.userName, - required this.email, - required this.phone, +@Schemable(caseStyle: CaseStyle.snakeCase) +class ApiPayload { + final String userId; + final String createdAt; + final String? fullName; + + const ApiPayload({ + required this.userId, + @SchemaKey('created-at') required this.createdAt, + this.fullName, }); } ''', - }, - outputs: { - 'test_pkg|lib/json_key.g.dart': decodedMatches( - allOf([ - contains('final jsonKeyModelSchema = Ack.object({'), - contains("'user_name': Ack.string()"), - contains("'email_address': Ack.string()"), - contains("'phone-number': Ack.string()"), - ]), - ), - }, - ); - }); + }, + outputs: { + 'test_pkg|lib/api_payload.g.dart': decodedMatches( + allOf([ + contains("'user_id': Ack.string()"), + contains("'created-at': Ack.string()"), + contains("'full_name': Ack.string().optional().nullable()"), + ]), + ), + }, + ); + }); - test('should handle field description annotation', () async { - final builder = ackGenerator(BuilderOptions.empty); + test('supports parameter descriptions and decorator constraints', () async { + final builder = ackGenerator(BuilderOptions.empty); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/field_descriptions.dart': ''' + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/signup.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel() -class FieldDescriptionsModel { - @AckField(description: 'The user\\'s full name') - final String name; - - @AckField(description: 'Age in years') +@Schemable(description: 'Public signup payload') +class Signup { + final String email; + final String? displayName; final int age; - - @AckField(description: 'Optional contact email') - final String? email; - - FieldDescriptionsModel({ - required this.name, + + const Signup({ + @Description('Primary email address') + @Email() + required this.email, + @Description('Display name shown in profiles') + @MinLength(3) + @MaxLength(20) + this.displayName, + @Min(13) + @Max(120) required this.age, - this.email, }); } ''', - }, - outputs: { - 'test_pkg|lib/field_descriptions.g.dart': decodedMatches( - allOf([ - contains('final fieldDescriptionsModelSchema = Ack.object({'), - contains("'name': Ack.string()"), - contains("'age': Ack.integer()"), - contains("'email': Ack.string().optional().nullable()"), - ]), - ), - }, - ); - }); + }, + outputs: { + 'test_pkg|lib/signup.g.dart': decodedMatches( + allOf([ + contains('/// Public signup payload'), + contains( + "'email': Ack.string().email().describe('Primary email address')", + ), + contains('.minLength(3)'), + contains('.maxLength(20)'), + contains('.optional()'), + contains('.nullable()'), + contains('Display name shown in profiles'), + contains("'age': Ack.integer().min(13).max(120)"), + ]), + ), + }, + ); + }); - test('should handle field constraints annotation', () async { + test( + 'supports explicit custom providers for custom and collection types', + () async { final builder = ackGenerator(BuilderOptions.empty); await testBuilder( builder, { ...allAssets, - 'test_pkg|lib/field_constraints.dart': ''' + 'test_pkg|lib/invoice.dart': ''' +import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -@AckModel() -class FieldConstraintsModel { - @AckField(constraints: ['min(18)', 'max(100)']) - final int age; - - @AckField(constraints: ['minLength(2)', 'maxLength(50)']) - final String name; - - @AckField(constraints: ['email']) - final String email; - - FieldConstraintsModel({ - required this.age, - required this.name, - required this.email, - }); +class Money { + final int cents; + const Money(this.cents); } -''', - }, - outputs: { - 'test_pkg|lib/field_constraints.g.dart': decodedMatches( - allOf([ - contains('final fieldConstraintsModelSchema = Ack.object({'), - contains("'age': Ack.integer()"), - contains("'name': Ack.string()"), - contains("'email': Ack.string()"), - ]), - ), - }, - ); - }); - }); - group('AckField Combination Tests', () { - test('should handle all AckField options together', () async { - final builder = ackGenerator(BuilderOptions.empty); +class MoneySchemaProvider implements SchemaProvider { + const MoneySchemaProvider(); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/all_options.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; + @override + AckSchema get schema => Ack.object({ + 'cents': Ack.integer(), + }).transform((value) => Money(value!['cents'] as int)); +} -@AckModel() -class AllOptionsModel { - @AckField( - requiredMode: AckFieldRequiredMode.required, - jsonKey: 'user_email', - description: 'User\\'s primary email address', - constraints: ['email', 'notEmpty'] - ) - final String email; - - @AckField( - requiredMode: AckFieldRequiredMode.optional, - jsonKey: 'display_name', - description: 'Optional display name', - constraints: ['minLength(1)', 'maxLength(100)'] - ) - final String? displayName; - - AllOptionsModel({ - required this.email, - this.displayName, +@Schemable(useProviders: const [MoneySchemaProvider]) +class Invoice { + final Money total; + final List lineItems; + + const Invoice({ + required this.total, + required this.lineItems, }); } ''', }, outputs: { - 'test_pkg|lib/all_options.g.dart': decodedMatches( + 'test_pkg|lib/invoice.g.dart': decodedMatches( allOf([ - contains('final allOptionsModelSchema = Ack.object({'), - contains("'user_email': Ack.string().email().notEmpty()"), - contains("'display_name': Ack.string()"), - contains('.minLength(1)'), - contains('.maxLength(100)'), - contains('.optional()'), - contains('.nullable()'), + contains( + "'total': (const MoneySchemaProvider().schema as AckSchema)", + ), + contains( + "'lineItems': Ack.list((const MoneySchemaProvider().schema as AckSchema))", + ), ]), ), }, ); - }); - - test( - 'should handle AckField with complex jsonKey and constraints', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/field_with_model.dart': ''' + }, + ); + + test('fails when useProviders contains an abstract provider', () async { + final builder = ackGenerator(BuilderOptions.empty); + var sawExpectedError = false; + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/invoice.dart': ''' +import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -@AckModel() -class FieldWithModelGeneration { - @AckField(jsonKey: 'full_name', description: 'Complete name') - final String name; +class Money { + final int cents; + const Money(this.cents); +} - @AckField(jsonKey: 'user_age', constraints: ['min(0)', 'max(150)']) - final int age; +abstract class MoneySchemaProvider implements SchemaProvider { + const MoneySchemaProvider(); - FieldWithModelGeneration({ - required this.name, - required this.age, - }); + @override + AckSchema get schema; +} + +@Schemable(useProviders: const [MoneySchemaProvider]) +class Invoice { + final Money total; + + const Invoice({required this.total}); } ''', - }, - outputs: { - 'test_pkg|lib/field_with_model.g.dart': decodedMatches( - allOf([ - // Schema generation - contains( - 'final fieldWithModelGenerationSchema = Ack.object({', - ), - contains("'full_name': Ack.string()"), - contains("'user_age': Ack.integer().min(0).max(150)"), - ]), - ), - }, - ); + }, + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE' && + log.message.contains( + 'must be a concrete class and cannot be abstract', + )) { + sawExpectedError = true; + } }, ); + + expect(sawExpectedError, isTrue); }); - group('AckField Edge Cases', () { - test('should handle empty constraints list', () async { - final builder = ackGenerator(BuilderOptions.empty); + test('fails when useProviders targets a schemable type', () async { + final builder = ackGenerator(BuilderOptions.empty); + var sawExpectedError = false; - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/empty_constraints.dart': ''' + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/invoice.dart': ''' +import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -@AckModel() -class EmptyConstraintsModel { - @AckField(constraints: []) - final String emptyConstraints; - - @AckField(constraints: ['']) - final String emptyStringConstraint; - - EmptyConstraintsModel({ - required this.emptyConstraints, - required this.emptyStringConstraint, - }); -} -''', - }, - outputs: { - 'test_pkg|lib/empty_constraints.g.dart': decodedMatches( - allOf([ - contains('final emptyConstraintsModelSchema = Ack.object({'), - contains("'emptyConstraints': Ack.string()"), - contains("'emptyStringConstraint': Ack.string()"), - ]), - ), - }, - ); - }); +@Schemable() +class Money { + final int cents; - test('should handle special characters in jsonKey', () async { - final builder = ackGenerator(BuilderOptions.empty); + const Money({required this.cents}); +} - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/special_json_keys.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; +class MoneySchemaProvider implements SchemaProvider { + const MoneySchemaProvider(); -@AckModel() -class SpecialJsonKeysModel { - @AckField(jsonKey: 'field-with-dashes') - final String dashedField; - - @AckField(jsonKey: 'field_with_underscores') - final String underscoredField; - - @AckField(jsonKey: 'field.with.dots') - final String dottedField; - - @AckField(jsonKey: 'field with spaces') - final String spacedField; - - SpecialJsonKeysModel({ - required this.dashedField, - required this.underscoredField, - required this.dottedField, - required this.spacedField, - }); + @override + AckSchema get schema => Ack.object({ + 'cents': Ack.integer(), + }).transform((value) => Money(cents: value!['cents'] as int)); } -''', - }, - outputs: { - 'test_pkg|lib/special_json_keys.g.dart': decodedMatches( - allOf([ - contains('final specialJsonKeysModelSchema = Ack.object({'), - contains("'field-with-dashes': Ack.string()"), - contains("'field_with_underscores': Ack.string()"), - contains("'field.with.dots': Ack.string()"), - contains("'field with spaces': Ack.string()"), - ]), - ), - }, - ); - }); - test('should handle complex constraint strings', () async { - final builder = ackGenerator(BuilderOptions.empty); +@Schemable(useProviders: const [MoneySchemaProvider]) +class Invoice { + final Money total; - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/complex_constraints.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -@AckModel() -class ComplexConstraintsModel { - @AckField(constraints: ['regex(^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})']) - final String emailRegex; - - @AckField(constraints: ['oneOf(active,inactive,pending)']) - final String status; - - @AckField(constraints: ['custom(complex, nested, constraint)']) - final String complexConstraint; - - ComplexConstraintsModel({ - required this.emailRegex, - required this.status, - required this.complexConstraint, - }); + const Invoice({required this.total}); } ''', - }, - outputs: { - 'test_pkg|lib/complex_constraints.g.dart': decodedMatches( - allOf([ - contains('final complexConstraintsModelSchema = Ack.object({'), - contains("'emailRegex': Ack.string()"), - contains("'status': Ack.string()"), - contains("'complexConstraint': Ack.string()"), - ]), - ), - }, - ); - }); + }, + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE' && + log.message.contains( + 'cannot target Money because Money already has a generated schema', + )) { + sawExpectedError = true; + } + }, + ); + + expect(sawExpectedError, isTrue); }); - group('AckField with Different Field Types', () { - test('should handle AckField on various field types', () async { - final builder = ackGenerator(BuilderOptions.empty); + test('fails with a helpful error for unresolved custom types', () async { + final builder = ackGenerator(BuilderOptions.empty); + var sawExpectedError = false; - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/various_types.dart': ''' + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/secret_doc.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -enum Status { active, inactive } - -@AckModel() -class VariousTypesModel { - @AckField(jsonKey: 'text_field') - final String textField; - - @AckField(jsonKey: 'number_field') - final int numberField; - - @AckField(jsonKey: 'decimal_field') - final double decimalField; - - @AckField(jsonKey: 'boolean_field') - final bool booleanField; - - @AckField(jsonKey: 'enum_field') - final Status enumField; - - @AckField(jsonKey: 'list_field') - final List listField; - - @AckField(jsonKey: 'nullable_field') - final String? nullableField; - - VariousTypesModel({ - required this.textField, - required this.numberField, - required this.decimalField, - required this.booleanField, - required this.enumField, - required this.listField, - this.nullableField, - }); +class SecretToken { + const SecretToken(); +} + +@Schemable() +class SecretDoc { + final SecretToken token; + + const SecretDoc({required this.token}); } ''', - }, - outputs: { - 'test_pkg|lib/various_types.g.dart': decodedMatches( - allOf([ - contains('final variousTypesModelSchema = Ack.object({'), - contains("'text_field': Ack.string()"), - contains("'number_field': Ack.integer()"), - contains("'decimal_field': Ack.double()"), - contains("'boolean_field': Ack.boolean()"), - contains("'enum_field': Ack.enumValues(Status.values)"), - contains("'list_field': Ack.list(Ack.string())"), - contains( - "'nullable_field': Ack.string().optional().nullable()", - ), - ]), - ), - }, - ); - }); + }, + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE' && + log.message.contains('Unsupported type "SecretToken"') && + log.message.contains( + 'Annotate the type with @Schemable() or register a schema provider', + )) { + sawExpectedError = true; + } + }, + ); + + expect(sawExpectedError, isTrue); }); }); } diff --git a/packages/ack_generator/test/annotation_combination_edge_cases_test.dart b/packages/ack_generator/test/annotation_combination_edge_cases_test.dart index e7be6b18..b92c3f82 100644 --- a/packages/ack_generator/test/annotation_combination_edge_cases_test.dart +++ b/packages/ack_generator/test/annotation_combination_edge_cases_test.dart @@ -6,105 +6,88 @@ import 'package:test/test.dart'; import 'test_utils/test_assets.dart'; void main() { - group('Annotation Combination Edge Cases', () { - group('AckModel Complex Combinations', () { - test( - 'should handle model + additionalProperties + custom schema name', - () async { - final builder = ackGenerator(BuilderOptions.empty); + group('Schemable edge cases', () { + test( + 'supports additionalProperties with constructor-driven models', + () async { + final builder = ackGenerator(BuilderOptions.empty); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/complex_combo.dart': ''' + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/flexible_user.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel( +@Schemable( schemaName: 'FlexibleUserSchema', description: 'A flexible user model with additional properties', + caseStyle: CaseStyle.snakeCase, additionalProperties: true, - additionalPropertiesField: 'metadata' + additionalPropertiesField: 'metadata', ) class FlexibleUser { - final String name; + final String fullName; final int age; final Map metadata; - FlexibleUser({ - required this.name, + const FlexibleUser({ + required this.fullName, required this.age, required this.metadata, }); } ''', - }, - outputs: { - 'test_pkg|lib/complex_combo.g.dart': decodedMatches( - allOf([ - // Custom schema name - contains('final flexibleUserSchema = Ack.object({'), - contains('/// Generated schema for FlexibleUser'), - contains( - '/// A flexible user model with additional properties', - ), - - // Additional properties - contains('}, additionalProperties: true)'), - ]), - ), - }, - ); - }, - ); + }, + outputs: { + 'test_pkg|lib/flexible_user.g.dart': decodedMatches( + allOf([ + contains('final flexibleUserSchema = Ack.object({'), + contains("'full_name': Ack.string()"), + contains("'age': Ack.integer()"), + isNot(contains("'metadata':")), + contains('}, additionalProperties: true)'), + ]), + ), + }, + ); + }, + ); - test('should handle discriminated types with schemas', () async { + test( + 'requires sealed discriminated roots and emits subtype schemas', + () async { final builder = ackGenerator(BuilderOptions.empty); await testBuilder( builder, { ...allAssets, - 'test_pkg|lib/discriminated_with_model.dart': ''' + 'test_pkg|lib/notifications.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel( - discriminatedKey: 'type', - description: 'Base notification class' -) -abstract class Notification { - String get type; +@Schemable(discriminatorKey: 'type') +sealed class Notification { final String message; - Notification({required this.message}); + + const Notification({required this.message}); } -@AckModel( - discriminatedValue: 'email', - description: 'Email notification' -) +@Schemable(discriminatorValue: 'email') class EmailNotification extends Notification { - @override - String get type => 'email'; - final String recipient; - EmailNotification({ + const EmailNotification({ required super.message, required this.recipient, }); } -@AckModel( - discriminatedValue: 'sms', - description: 'SMS notification' -) +@Schemable(discriminatorValue: 'sms') class SmsNotification extends Notification { - @override - String get type => 'sms'; - final String phoneNumber; - SmsNotification({ + const SmsNotification({ required super.message, required this.phoneNumber, }); @@ -112,316 +95,94 @@ class SmsNotification extends Notification { ''', }, outputs: { - 'test_pkg|lib/discriminated_with_model.g.dart': decodedMatches( + 'test_pkg|lib/notifications.g.dart': decodedMatches( allOf([ - // Discriminated schema contains('final notificationSchema = Ack.discriminated('), contains("discriminatorKey: 'type'"), - - // Individual schemas - contains('final emailNotificationSchema = Ack.object({'), - contains('final smsNotificationSchema = Ack.object({'), + contains("'email': emailNotificationSchema"), + contains("'sms': smsNotificationSchema"), + contains("'type': Ack.literal('email')"), + contains("'type': Ack.literal('sms')"), ]), ), }, ); - }); - }); + }, + ); - group('Field and Model Annotation Combinations', () { - test( - 'should handle AckField with AckModel additionalProperties', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/field_with_additional.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -@AckModel( - additionalProperties: true, - additionalPropertiesField: 'extras' -) -class FieldWithAdditionalProps { - @AckField(jsonKey: 'user_name', requiredMode: AckFieldRequiredMode.required) - final String name; - - @AckField(jsonKey: 'user_email', constraints: ['email']) - final String email; - - final Map extras; - - FieldWithAdditionalProps({ - required this.name, - required this.email, - required this.extras, - }); -} -''', - }, - outputs: { - 'test_pkg|lib/field_with_additional.g.dart': decodedMatches( - allOf([ - // Schema with custom keys - contains("'user_name': Ack.string()"), - contains("'user_email': Ack.string()"), - contains('}, additionalProperties: true)'), - ]), - ), - }, - ); - }, - ); - - test('should handle nested models with AckField annotations', () async { + test( + 'uses @SchemaConstructor when the unnamed constructor is not the contract', + () async { final builder = ackGenerator(BuilderOptions.empty); await testBuilder( builder, { ...allAssets, - 'test_pkg|lib/nested_with_fields.dart': ''' + 'test_pkg|lib/selected_constructor.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel() -class Address { - @AckField(jsonKey: 'street_address') - final String street; - - @AckField(jsonKey: 'city_name') - final String city; - - @AckField(jsonKey: 'postal_code') - final String? postalCode; - - Address({ - required this.street, - required this.city, - this.postalCode, - }); -} - -@AckModel(description: 'User with address information') -class UserWithAddress { - @AckField(jsonKey: 'full_name', constraints: ['notEmpty']) - final String name; - - @AckField(jsonKey: 'home_address') - final Address homeAddress; +@Schemable() +class SelectedConstructor { + final String id; + final String? nickname; - @AckField(jsonKey: 'work_address') - final Address? workAddress; + const SelectedConstructor._(this.id, this.nickname); - UserWithAddress({ - required this.name, - required this.homeAddress, - this.workAddress, + @SchemaConstructor() + const SelectedConstructor.fromApi({ + required this.id, + this.nickname, }); } ''', }, outputs: { - 'test_pkg|lib/nested_with_fields.g.dart': decodedMatches( + 'test_pkg|lib/selected_constructor.g.dart': decodedMatches( allOf([ - // Address schema with custom keys - contains("'street_address': Ack.string()"), - contains("'city_name': Ack.string()"), - contains("'postal_code': Ack.string().optional().nullable()"), - - // User schema with nested references - contains("'full_name': Ack.string()"), - contains("'home_address': addressSchema"), - contains("'work_address': addressSchema.optional().nullable()"), + contains("'id': Ack.string()"), + contains("'nickname': Ack.string().optional().nullable()"), ]), ), }, ); - }); - }); + }, + ); - group('Error Cases and Validation', () { - test( - 'should handle contradictory AckField settings gracefully', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - // This should succeed despite the logical contradiction - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/contradictory.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -@AckModel() -class ContradictoryModel { - @AckField(requiredMode: AckFieldRequiredMode.required) // Marked required but type is nullable - final String? contradictoryField; - - ContradictoryModel({this.contradictoryField}); -} -''', - }, - outputs: { - 'test_pkg|lib/contradictory.g.dart': decodedMatches( - allOf([ - contains('final contradictoryModelSchema = Ack.object({'), - contains("'contradictoryField': Ack.string().nullable()"), - ]), - ), - }, - ); - }, - ); - - test('should handle very long annotation values', () async { + test( + 'fails when the selected constructor uses positional parameters', + () async { final builder = ackGenerator(BuilderOptions.empty); + var sawExpectedError = false; await testBuilder( builder, { ...allAssets, - 'test_pkg|lib/long_values.dart': ''' + 'test_pkg|lib/invalid_model.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel( - schemaName: 'VeryLongSchemaNameThatExceedsNormalLengthExpectationsAndTestsEdgeCasesForNameGeneration', - description: 'This is a very long description that tests how the generator handles extremely verbose documentation strings that might span multiple lines and contain various special characters and formatting requirements' -) -class LongValuesModel { - @AckField( - jsonKey: 'extremely_long_json_key_name_that_tests_limits', - description: 'This field has an extremely long description that tests the generators ability to handle verbose field documentation', - constraints: ['minLength(1)', 'maxLength(1000)', 'pattern(^[a-zA-Z0-9\\s\\.,!?-]*)', 'custom(very, long, constraint, list)'] - ) - final String longField; - - LongValuesModel({required this.longField}); -} -''', - }, - outputs: { - 'test_pkg|lib/long_values.g.dart': decodedMatches( - allOf([ - contains( - 'final veryLongSchemaNameThatExceedsNormalLengthExpectationsAndTestsEdgeCasesForNameGeneration', - ), - contains('Ack.object({'), - contains("'extremely_long_json_key_name_that_tests_limits'"), - contains('/// This is a very long description'), - ]), - ), - }, - ); - }); - }); - - group('Real-World Scenarios', () { - test('should handle realistic API model with all features', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/api_model.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -enum UserRole { admin, user, guest } -enum SubscriptionStatus { active, inactive, pending, cancelled } - -@AckModel( - schemaName: 'ApiUserSchema', - description: 'Complete user model for API responses', - additionalProperties: true, - additionalPropertiesField: 'customFields' -) -class ApiUser { - @AckField(jsonKey: 'user_id', description: 'Unique user identifier') +@Schemable() +class InvalidModel { final String id; - @AckField( - jsonKey: 'email_address', - description: 'User email address', - constraints: ['email', 'notEmpty'], - requiredMode: AckFieldRequiredMode.required - ) - final String email; - - @AckField( - jsonKey: 'display_name', - description: 'User display name', - constraints: ['minLength(1)', 'maxLength(100)'] - ) - final String? displayName; - - @AckField(jsonKey: 'user_role') - final UserRole role; - - @AckField(jsonKey: 'subscription_status') - final SubscriptionStatus? subscriptionStatus; - - @AckField(jsonKey: 'profile_tags') - final List tags; - - @AckField(jsonKey: 'user_preferences') - final Map preferences; - - final Map customFields; - - ApiUser({ - required this.id, - required this.email, - this.displayName, - required this.role, - this.subscriptionStatus, - required this.tags, - required this.preferences, - required this.customFields, - }); + const InvalidModel(this.id); } ''', }, - outputs: { - 'test_pkg|lib/api_model.g.dart': decodedMatches( - allOf([ - // Custom schema name - contains('final apiUserSchema = Ack.object({'), - contains('/// Complete user model for API responses'), - - // All field types with custom keys - contains("'user_id': Ack.string()"), - contains("'email_address': Ack.string()"), - contains("'display_name': Ack.string()"), - contains('.minLength(1)'), - contains('.maxLength(100)'), - contains('.optional()'), - contains('.nullable()'), - contains( - "'user_role': Ack.enumValues(UserRole.values)", - ), - // subscription_status is formatted across lines, check each part - contains( - "'subscription_status': Ack.enumValues", - ), - contains('SubscriptionStatus.values'), - contains('.optional()'), - contains('.nullable()'), - contains("'profile_tags': Ack.list(Ack.string())"), - contains( - "'user_preferences': Ack.object({}, additionalProperties: true)", - ), - - // Additional properties - contains('}, additionalProperties: true)'), - ]), - ), + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE' && + log.message.contains( + 'Only named constructor parameters are supported', + )) { + sawExpectedError = true; + } }, ); - }); - }); + + expect(sawExpectedError, isTrue); + }, + ); }); } diff --git a/packages/ack_generator/test/correctness_fixes_test.dart b/packages/ack_generator/test/correctness_fixes_test.dart index a2bd060f..0c0dc771 100644 --- a/packages/ack_generator/test/correctness_fixes_test.dart +++ b/packages/ack_generator/test/correctness_fixes_test.dart @@ -73,7 +73,7 @@ enum Status { active, inactive, pending } class User { final String name; final Status status; - User(this.name, this.status); + User({required this.name, required this.status}); } ''', }, @@ -139,17 +139,15 @@ final personSchema = Ack.object({ 'test_pkg|lib/schema.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel(discriminatedKey: 'type') -abstract class Shape { - String get type; +@AckModel(discriminatorKey: 'type') +sealed class Shape { + const Shape(); } -@AckModel(discriminatedValue: 'circle') +@AckModel(discriminatorValue: 'circle') class Circle extends Shape { - @override - String get type => 'circle'; final double radius; - Circle(this.radius); + Circle({required this.radius}); } ''', }, @@ -179,6 +177,63 @@ class Circle extends Shape { ); }); + test( + 'discriminator keys follow transformed field keys in discriminated subtypes', + () async { + final builder = SharedPartBuilder([AckSchemaGenerator()], 'ack'); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; + +@AckModel(discriminatorKey: 'eventType') +sealed class Event { + const Event(); +} + +@AckModel(discriminatorValue: 'created', caseStyle: CaseStyle.snakeCase) +class CreatedEvent extends Event { + final String eventType; + final String payload; + + CreatedEvent({ + required this.eventType, + required this.payload, + }); +} +''', + }, + outputs: { + 'test_pkg|lib/schema.ack.g.part': decodedMatches( + allOf([ + contains("discriminatorKey: 'event_type'"), + contains("'event_type': Ack.literal('created')"), + predicate((content) { + final schemaMatch = RegExp( + r'final createdEventSchema = Ack\.object\(\{([^}]+)\}\)', + dotAll: true, + ).firstMatch(content); + if (schemaMatch == null) return false; + + final schemaBody = schemaMatch.group(1)!; + final transformedKeyCount = RegExp( + r"'event_type'\s*:", + ).allMatches(schemaBody).length; + final rawKeyCount = RegExp( + r"'eventType'\s*:", + ).allMatches(schemaBody).length; + return transformedKeyCount == 1 && rawKeyCount == 0; + }, 'uses only transformed discriminator key in subtype schema'), + ]), + ), + }, + ); + }, + ); + test('discriminated base uses custom schemaName from subtypes', () async { final builder = SharedPartBuilder([AckSchemaGenerator()], 'ack'); @@ -189,25 +244,21 @@ class Circle extends Shape { 'test_pkg|lib/schema.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel(discriminatedKey: 'kind') -abstract class Animal { - String get kind; +@AckModel(discriminatorKey: 'kind') +sealed class Animal { + const Animal(); } -@AckModel(discriminatedValue: 'cat', schemaName: 'CatDataSchema') +@AckModel(discriminatorValue: 'cat', schemaName: 'CatDataSchema') class Cat extends Animal { - @override - String get kind => 'cat'; final String name; - Cat(this.name); + Cat({required this.name}); } -@AckModel(discriminatedValue: 'dog', schemaName: 'DogInfoSchema') +@AckModel(discriminatorValue: 'dog', schemaName: 'DogInfoSchema') class Dog extends Animal { - @override - String get kind => 'dog'; final String breed; - Dog(this.breed); + Dog({required this.breed}); } ''', }, @@ -242,16 +293,17 @@ import 'package:ack_annotations/ack_annotations.dart'; @AckModel() class Quoted { - @AckField(description: "Contains 'single quotes'") final String singleQuoted; - @AckField(description: 'Contains "double quotes"') final String doubleQuoted; - @AckField(description: 'Contains \\ backslash') final String backslash; - Quoted(this.singleQuoted, this.doubleQuoted, this.backslash); + Quoted({ + @Description("Contains 'single quotes'") required this.singleQuoted, + @Description('Contains "double quotes"') required this.doubleQuoted, + @Description('Contains \\ backslash') required this.backslash, + }); } ''', }, @@ -329,10 +381,9 @@ final userSchema = Ack.object({ '\n' '@AckModel()\n' 'class Price {\n' - " @AckField(description: 'Price is \\\$100 USD')\n" ' final int amount;\n' '\n' - ' Price(this.amount);\n' + " Price({@Description('Price is \\\$100 USD') required this.amount});\n" '}\n', }, outputs: { @@ -361,10 +412,9 @@ import 'package:ack_annotations/ack_annotations.dart'; @AckModel() class Document { - @AckField(constraints: ["matches(test'pattern)"]) final String content; - Document(this.content); + Document({@Pattern("test'pattern") required this.content}); } ''', }, diff --git a/packages/ack_generator/test/description_generation_test.dart b/packages/ack_generator/test/description_generation_test.dart index ed45baf5..2ca07dfc 100644 --- a/packages/ack_generator/test/description_generation_test.dart +++ b/packages/ack_generator/test/description_generation_test.dart @@ -1,543 +1,113 @@ -import 'package:test/test.dart'; -import 'package:build_test/build_test.dart'; -import 'package:build/build.dart'; import 'package:ack_generator/builder.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:test/test.dart'; import 'test_utils/test_assets.dart'; void main() { - group('Description Generation Tests', () { - test('generates class-level descriptions in schema comments', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/test.dart': ''' + group('Description generation', () { + test( + 'uses class descriptions and parameter @Description annotations', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/test.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel(description: 'A comprehensive user model for testing') +@Schemable(description: 'User payload for public APIs') class User { - final String name; - final String email; - - User({required this.name, required this.email}); -} -''', - }, - outputs: { - 'test_pkg|lib/test.g.dart': decodedMatches( - allOf([ - contains('Generated schema for User'), - contains('A comprehensive user model for testing'), - contains('final userSchema = Ack.object({'), - ]), - ), - }, - ); - }); - - test('generates field-level descriptions when @AckField is used', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/test.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -@AckModel(description: 'User model with documented fields') -class User { - @AckField(description: 'The user\\'s full name') - final String name; - - @AckField(description: 'User\\'s primary email address') - final String email; - - final int age; - - User({required this.name, required this.email, required this.age}); -} -''', - }, - outputs: { - 'test_pkg|lib/test.g.dart': decodedMatches( - allOf([ - contains('Generated schema for User'), - contains('User model with documented fields'), - contains('final userSchema = Ack.object({'), - // Verify describe() is generated for fields with descriptions - // Note: Generated code may be multi-line formatted, so check parts separately - contains('.describe('), - contains("The user\\'s full name"), - contains("User\\'s primary email address"), - // Verify fields without description don't have describe - isNot(contains("'age': Ack.integer().describe(")), - ]), - ), - }, - ); - }); - - test('works with both class and field descriptions', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/test.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -@AckModel( - description: 'Product model for e-commerce platform', -) -class Product { - @AckField(description: 'Unique product identifier') final String id; + final String email; + final String? displayName; - @AckField(description: 'Product display name') - final String name; - - @AckField(description: 'Current product price in USD') - final double price; - - final String? description; - - Product({ - required this.id, - required this.name, - required this.price, - this.description - }); -} -''', - }, - outputs: { - 'test_pkg|lib/test.g.dart': decodedMatches( - allOf([ - contains('Generated schema for Product'), - contains('Product model for e-commerce platform'), - contains('final productSchema = Ack.object({'), - // Verify describe() is generated for fields with descriptions - // Note: Generated code may be multi-line formatted, so check parts separately - contains('.describe('), - contains('Unique product identifier'), - contains('Product display name'), - contains('Current product price in USD'), - // Verify field without description doesn't have describe - isNot(contains("'description': Ack.string().describe(")), - ]), - ), - }, - ); - }); - - test('handles missing descriptions gracefully', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/test.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -@AckModel() -class SimpleModel { - final String name; - - SimpleModel({required this.name}); -} -''', - }, - outputs: { - 'test_pkg|lib/test.g.dart': decodedMatches( - allOf([ - contains('Generated schema for SimpleModel'), - // Should have the standard generated comment but no additional description - isNot( - contains('/// A'), - ), // No additional description starting with "/// A" - isNot( - contains('/// This'), - ), // No additional description starting with "/// This" - contains('final simpleModelSchema = Ack.object({'), - // Verify no describe() is generated when no field descriptions - isNot(contains('.describe(')), - ]), - ), - }, - ); - }); - - test('extracts descriptions with special characters correctly', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/test.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -@AckModel(description: 'Model with "quotes" and \\special\\ characters') -class SpecialModel { - final String name; - - SpecialModel({required this.name}); -} -''', - }, - outputs: { - 'test_pkg|lib/test.g.dart': decodedMatches( - allOf([ - contains('Generated schema for SpecialModel'), - contains('Model with "quotes" and special characters'), - contains('final specialModelSchema = Ack.object({'), - ]), - ), - }, - ); - }); + const User({ + @Description('Public user identifier') required this.id, + @Description('Primary email address') required this.email, + this.displayName, }); - - group('Doc Comment Description Tests', () { - test('generates field descriptions from doc comments', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/test.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -@AckModel() -class User { - /// The unique identifier for the user - final String id; - - /// User full name - final String name; - - final int age; - - User({required this.id, required this.name, required this.age}); -} -''', - }, - outputs: { - 'test_pkg|lib/test.g.dart': decodedMatches( - allOf([ - contains('final userSchema = Ack.object({'), - // Verify describe() is generated from doc comments - contains('.describe('), - contains('The unique identifier for the user'), - contains('User full name'), - // Verify field without doc comment doesn't have describe - isNot(contains("'age': Ack.integer().describe(")), - ]), - ), - }, - ); - }); - - test('generates class description from doc comment', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/test.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -/// A user model with profile information -@AckModel() -class User { - final String name; - - User({required this.name}); -} -''', - }, - outputs: { - 'test_pkg|lib/test.g.dart': decodedMatches( - allOf([ - contains('Generated schema for User'), - contains('A user model with profile information'), - contains('final userSchema = Ack.object({'), - ]), - ), - }, - ); - }); - - test('annotation description overrides doc comment for fields', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/test.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -@AckModel() -class User { - /// Internal identifier used for database operations - @AckField(description: 'Public user ID') - final String id; - - User({required this.id}); } ''', - }, - outputs: { - 'test_pkg|lib/test.g.dart': decodedMatches( - allOf([ - contains('final userSchema = Ack.object({'), - // Verify annotation description is used, not doc comment - contains('Public user ID'), - isNot( - contains('Internal identifier used for database operations'), - ), - ]), - ), - }, - ); - }); - - test('annotation description overrides doc comment for class', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/test.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -/// Internal user representation -@AckModel(description: 'Public API user model') -class User { - final String name; - - User({required this.name}); -} -''', - }, - outputs: { - 'test_pkg|lib/test.g.dart': decodedMatches( - allOf([ - contains('Generated schema for User'), - // Verify annotation description is used, not doc comment - contains('Public API user model'), - isNot(contains('Internal user representation')), - ]), - ), - }, - ); - }); - - test('handles multi-line doc comments', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/test.dart': ''' + }, + outputs: { + 'test_pkg|lib/test.g.dart': decodedMatches( + allOf([ + contains('/// Generated schema for User'), + contains('/// User payload for public APIs'), + contains('Public user identifier'), + contains('Primary email address'), + contains('.describe('), + ]), + ), + }, + ); + }, + ); + + test( + 'falls back to class doc comments when description is omitted', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/test.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel() -class User { - /// The unique identifier - /// for the user account +/// Audit event payload used by internal tooling. +@Schemable() +class AuditEvent { final String id; - User({required this.id}); + const AuditEvent({required this.id}); } ''', - }, - outputs: { - 'test_pkg|lib/test.g.dart': decodedMatches( - allOf([ - contains('final userSchema = Ack.object({'), - contains('.describe('), - // Multi-line comments should be joined with spaces - contains('The unique identifier for the user account'), - ]), - ), - }, - ); - }); - - test('handles doc comments with special characters', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/test.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -@AckModel() -class User { - /// Full name of the user (required field) - final String name; - - User({required this.name}); -} -''', - }, - outputs: { - 'test_pkg|lib/test.g.dart': decodedMatches( - allOf([ - contains('final userSchema = Ack.object({'), - contains('.describe('), - contains('Full name of the user (required field)'), - ]), - ), - }, - ); - }); - - test('works with multiple fields having doc comments', () async { + }, + outputs: { + 'test_pkg|lib/test.g.dart': decodedMatches( + allOf([ + contains('/// Generated schema for AuditEvent'), + contains('/// Audit event payload used by internal tooling.'), + ]), + ), + }, + ); + }, + ); + + test('escapes special characters in descriptions safely', () async { final builder = ackGenerator(BuilderOptions.empty); await testBuilder( builder, { ...allAssets, - 'test_pkg|lib/test.dart': ''' + 'test_pkg|lib/test.dart': r''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel() -class Product { - /// Product SKU code used for inventory tracking - final String sku; - - /// Product display name shown to customers - final String name; - - /// Current price in USD - final double price; +@Schemable(description: 'Model with "quotes" and \\slashes\\') +class PriceTag { + final int amount; - Product({required this.sku, required this.name, required this.price}); -} -''', - }, - outputs: { - 'test_pkg|lib/test.g.dart': decodedMatches( - allOf([ - contains('final productSchema = Ack.object({'), - contains('.describe('), - contains('Product SKU code used for inventory tracking'), - contains('Product display name shown to customers'), - contains('Current price in USD'), - ]), - ), - }, - ); - }); + const PriceTag({ + @Description('Price is \$100 "fixed"') + required this.amount, }); - - group('Block Comment Description Tests', () { - test('generates field descriptions from block comments', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/test.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -@AckModel() -class User { - /** The unique user identifier */ - final String id; - - User({required this.id}); -} -''', - }, - outputs: { - 'test_pkg|lib/test.g.dart': decodedMatches( - allOf([ - contains('final userSchema = Ack.object({'), - contains('.describe('), - contains('The unique user identifier'), - ]), - ), - }, - ); - }); - - test('generates class description from block comment', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/test.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -/** A user account in the system */ -@AckModel() -class User { - final String name; - - User({required this.name}); -} -''', - }, - outputs: { - 'test_pkg|lib/test.g.dart': decodedMatches( - allOf([ - contains('Generated schema for User'), - contains('A user account in the system'), - contains('final userSchema = Ack.object({'), - ]), - ), - }, - ); - }); - - test('handles multi-line block comments', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/test.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -@AckModel() -class User { - /** - * The unique identifier - * for this user account - */ - final String id; - - User({required this.id}); } ''', }, outputs: { 'test_pkg|lib/test.g.dart': decodedMatches( allOf([ - contains('final userSchema = Ack.object({'), - contains('.describe('), - contains('The unique identifier for this user account'), + contains('Model with "quotes" and \\slashes\\'), + contains(r'Price is \$100 "fixed"'), + contains('priceTagSchema'), ]), ), }, diff --git a/packages/ack_generator/test/discriminated_types_comprehensive_test.dart b/packages/ack_generator/test/discriminated_types_comprehensive_test.dart index 48dee8f2..9bba984f 100644 --- a/packages/ack_generator/test/discriminated_types_comprehensive_test.dart +++ b/packages/ack_generator/test/discriminated_types_comprehensive_test.dart @@ -6,472 +6,745 @@ import 'package:test/test.dart'; import 'test_utils/test_assets.dart'; void main() { - group('Discriminated Types Comprehensive Tests', () { - group('Basic Discriminated Schema Generation', () { - test( - 'should generate discriminated schema for simple hierarchy', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/animals.dart': ''' + group('Discriminated types', () { + test( + 'generates discriminated schema for a simple sealed hierarchy', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/animals.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack/ack.dart'; part 'animals.g.dart'; -@AckModel(discriminatedKey: 'type') -abstract class Animal { - String get type; +@Schemable(discriminatorKey: 'type') +sealed class Animal { + const Animal(); } -@AckModel(discriminatedValue: 'cat') +@Schemable(discriminatorValue: 'cat') class Cat extends Animal { - @override - String get type => 'cat'; - final bool meow; final int lives; - - Cat({required this.meow, this.lives = 9}); + + const Cat({required this.meow, this.lives = 9}); } -@AckModel(discriminatedValue: 'dog') +@Schemable(discriminatorValue: 'dog') class Dog extends Animal { - @override - String get type => 'dog'; - final bool bark; final String breed; - - Dog({required this.bark, required this.breed}); + + const Dog({required this.bark, required this.breed}); } ''', - }, - outputs: { - 'test_pkg|lib/animals.g.dart': decodedMatches( - allOf([ - // Discriminated schema generation - contains('final animalSchema = Ack.discriminated('), - contains("discriminatorKey: 'type'"), - contains("schemas: {'cat': catSchema, 'dog': dogSchema}"), - - // Individual schemas - contains('final catSchema = Ack.object({'), - contains("'meow': Ack.boolean()"), - contains("'lives': Ack.integer()"), - - contains('final dogSchema = Ack.object({'), - contains("'bark': Ack.boolean()"), - contains("'breed': Ack.string()"), - ]), - ), - }, - ); - }, - ); + }, + outputs: { + 'test_pkg|lib/animals.g.dart': decodedMatches( + allOf([ + contains('final animalSchema = Ack.discriminated('), + contains("discriminatorKey: 'type'"), + contains("'cat': catSchema"), + contains("'dog': dogSchema"), + contains("'type': Ack.literal('cat')"), + contains("'type': Ack.literal('dog')"), + ]), + ), + }, + ); + }, + ); - test( - 'should handle multiple discriminated hierarchies in same file', - () async { - final builder = ackGenerator(BuilderOptions.empty); + test( + 'handles multiple discriminated hierarchies in the same file', + () async { + final builder = ackGenerator(BuilderOptions.empty); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/multi_hierarchy.dart': ''' + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/multi_hierarchy.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack/ack.dart'; part 'multi_hierarchy.g.dart'; -// First hierarchy: Animals -@AckModel(discriminatedKey: 'type') -abstract class Animal { - String get type; +@Schemable(discriminatorKey: 'type') +sealed class Animal { + const Animal(); } -@AckModel(discriminatedValue: 'cat') +@Schemable(discriminatorValue: 'cat') class Cat extends Animal { - @override - String get type => 'cat'; final bool meow; - Cat({required this.meow}); + + const Cat({required this.meow}); } -// Second hierarchy: Shapes -@AckModel(discriminatedKey: 'kind') -abstract class Shape { - String get kind; +@Schemable(discriminatorKey: 'kind') +sealed class Shape { + const Shape(); } -@AckModel(discriminatedValue: 'circle') +@Schemable(discriminatorValue: 'circle') class Circle extends Shape { - @override - String get kind => 'circle'; final double radius; - Circle({required this.radius}); + + const Circle({required this.radius}); } ''', - }, - outputs: { - 'test_pkg|lib/multi_hierarchy.g.dart': decodedMatches( - allOf([ - // Two separate discriminated schemas - contains('final animalSchema = Ack.discriminated('), - contains("discriminatorKey: 'type'"), - contains("schemas: {'cat': catSchema}"), - - contains('final shapeSchema = Ack.discriminated('), - contains("discriminatorKey: 'kind'"), - contains("schemas: {'circle': circleSchema}"), - - // Individual schemas for each type - contains('final catSchema = Ack.object({'), - contains("'meow': Ack.boolean()"), - - contains('final circleSchema = Ack.object({'), - contains("'radius': Ack.double()"), - ]), - ), - }, - ); - }, - ); - }); + }, + outputs: { + 'test_pkg|lib/multi_hierarchy.g.dart': decodedMatches( + allOf([ + contains('final animalSchema = Ack.discriminated('), + contains("discriminatorKey: 'type'"), + contains("'cat': catSchema"), + contains('final shapeSchema = Ack.discriminated('), + contains("discriminatorKey: 'kind'"), + contains("'circle': circleSchema"), + ]), + ), + }, + ); + }, + ); - group('Complex Discriminated Types', () { - test('should handle discriminated types with nested models', () async { - final builder = ackGenerator(BuilderOptions.empty); + test('supports nested models inside discriminated leaves', () async { + final builder = ackGenerator(BuilderOptions.empty); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/complex_discriminated.dart': ''' + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/complex_discriminated.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack/ack.dart'; part 'complex_discriminated.g.dart'; -@AckModel() +@Schemable() class Address { final String street; final String city; - Address({required this.street, required this.city}); + + const Address({required this.street, required this.city}); } -@AckModel(discriminatedKey: 'personType') -abstract class Person { - String get personType; - final String name; - final Address address; - Person({required this.name, required this.address}); +@Schemable(discriminatorKey: 'personType') +sealed class Person { + const Person(); } -@AckModel(discriminatedValue: 'employee') +@Schemable(discriminatorValue: 'employee') class Employee extends Person { - @override - String get personType => 'employee'; - + final String name; + final Address address; final String employeeId; final double salary; - - Employee({ - required super.name, - required super.address, + + const Employee({ + required this.name, + required this.address, required this.employeeId, required this.salary, }); } -@AckModel(discriminatedValue: 'customer') +@Schemable(discriminatorValue: 'customer') class Customer extends Person { - @override - String get personType => 'customer'; - + final String name; + final Address address; final String customerId; final List preferences; - - Customer({ - required super.name, - required super.address, + + const Customer({ + required this.name, + required this.address, required this.customerId, required this.preferences, }); } +''', + }, + outputs: { + 'test_pkg|lib/complex_discriminated.g.dart': decodedMatches( + allOf([ + contains('final personSchema = Ack.discriminated('), + contains("'employee': employeeSchema"), + contains("'customer': customerSchema"), + contains("'address': addressSchema"), + contains("'preferences': Ack.list(Ack.string())"), + ]), + ), + }, + ); + }); + + test('supports nested sealed discriminated roots', () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/deep_hierarchy.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack/ack.dart'; + +part 'deep_hierarchy.g.dart'; + +@Schemable(discriminatorKey: 'vehicleType') +sealed class Vehicle { + const Vehicle(); +} + +@Schemable(discriminatorKey: 'landType') +sealed class LandVehicle extends Vehicle { + const LandVehicle(); +} + +@Schemable(discriminatorValue: 'car') +class Car extends LandVehicle { + final int doors; + final String fuelType; + + const Car({required this.doors, required this.fuelType}); +} + +@Schemable(discriminatorValue: 'motorcycle') +class Motorcycle extends LandVehicle { + final bool hasSidecar; + final int engineSize; + + const Motorcycle({required this.hasSidecar, required this.engineSize}); +} + +@Schemable(discriminatorValue: 'boat') +class Boat extends Vehicle { + final double length; + final String propulsionType; + + const Boat({required this.length, required this.propulsionType}); +} +''', + }, + outputs: { + 'test_pkg|lib/deep_hierarchy.g.dart': decodedMatches( + allOf([ + contains('final vehicleSchema = Ack.discriminated('), + contains("discriminatorKey: 'vehicleType'"), + contains("'car': carSchema"), + contains("'motorcycle': motorcycleSchema"), + contains("'boat': boatSchema"), + contains('final landVehicleSchema = Ack.discriminated('), + contains("discriminatorKey: 'landType'"), + ]), + ), + }, + ); + }); + + test( + 'canonicalizes transformed discriminator keys across aligned subtypes', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/transformed_discriminator.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack/ack.dart'; + +part 'transformed_discriminator.g.dart'; + +@Schemable(discriminatorKey: 'eventType') +sealed class Event { + const Event(); +} + +@Schemable(discriminatorValue: 'created', caseStyle: CaseStyle.snakeCase) +class CreatedEvent extends Event { + final String eventType; + final String payload; + + const CreatedEvent({ + required this.eventType, + required this.payload, + }); +} + +@Schemable(discriminatorValue: 'updated', caseStyle: CaseStyle.snakeCase) +class UpdatedEvent extends Event { + final String eventType; + final int version; + + const UpdatedEvent({ + required this.eventType, + required this.version, + }); +} ''', }, outputs: { - 'test_pkg|lib/complex_discriminated.g.dart': decodedMatches( + 'test_pkg|lib/transformed_discriminator.g.dart': decodedMatches( allOf([ - // Address schema (dependency) - contains('final addressSchema = Ack.object({'), - contains("'street': Ack.string()"), - contains("'city': Ack.string()"), - - // Discriminated person schema - contains('final personSchema = Ack.discriminated('), - contains("discriminatorKey: 'personType'"), - contains( - "schemas: {'employee': employeeSchema, 'customer': customerSchema}", - ), - - // Employee schema with nested address - contains('final employeeSchema = Ack.object({'), - contains("'name': Ack.string()"), - contains("'address': addressSchema"), - contains("'employeeId': Ack.string()"), - contains("'salary': Ack.double()"), - - // Customer schema with list - contains('final customerSchema = Ack.object({'), - contains("'customerId': Ack.string()"), - contains("'preferences': Ack.list(Ack.string())"), + contains('final eventSchema = Ack.discriminated('), + contains("discriminatorKey: 'event_type'"), + contains("'event_type': Ack.literal('created')"), + contains("'event_type': Ack.literal('updated')"), + predicate((content) { + final createdSchemaMatch = RegExp( + r'final createdEventSchema = Ack\.object\(\{([^}]+)\}\)', + dotAll: true, + ).firstMatch(content); + final updatedSchemaMatch = RegExp( + r'final updatedEventSchema = Ack\.object\(\{([^}]+)\}\)', + dotAll: true, + ).firstMatch(content); + if (createdSchemaMatch == null || + updatedSchemaMatch == null) { + return false; + } + + final createdSchema = createdSchemaMatch.group(1)!; + final updatedSchema = updatedSchemaMatch.group(1)!; + final transformedKeyCount = + RegExp( + r"'event_type'\s*:", + ).allMatches(createdSchema).length + + RegExp( + r"'event_type'\s*:", + ).allMatches(updatedSchema).length; + final rawKeyCount = + RegExp( + r"'eventType'\s*:", + ).allMatches(createdSchema).length + + RegExp( + r"'eventType'\s*:", + ).allMatches(updatedSchema).length; + + return transformedKeyCount == 2 && rawKeyCount == 0; + }, 'uses only canonical transformed discriminator keys'), ]), ), }, ); - }); + }, + ); - test('should handle deeply nested discriminated hierarchies', () async { + test( + 'canonicalizes transformed keys across getter-only and transformed leaves', + () async { final builder = ackGenerator(BuilderOptions.empty); await testBuilder( builder, { ...allAssets, - 'test_pkg|lib/deep_hierarchy.dart': ''' + 'test_pkg|lib/getter_and_transformed_discriminator.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack/ack.dart'; -part 'deep_hierarchy.g.dart'; +part 'getter_and_transformed_discriminator.g.dart'; -@AckModel(discriminatedKey: 'vehicleType') -abstract class Vehicle { - String get vehicleType; +@Schemable(discriminatorKey: 'eventType') +sealed class Event { + const Event(); + + String get eventType; } -@AckModel(discriminatedKey: 'landType') -abstract class LandVehicle extends Vehicle { +@Schemable(discriminatorValue: 'created') +class CreatedEvent extends Event { @override - String get vehicleType => 'land'; - String get landType; + String get eventType => 'created'; + + final String payload; + + const CreatedEvent({required this.payload}); } -@AckModel(discriminatedValue: 'car') -class Car extends LandVehicle { - @override - String get vehicleType => 'land'; - @override - String get landType => 'car'; - final int doors; - final String fuelType; - Car({required this.doors, required this.fuelType}); +@Schemable(discriminatorValue: 'updated', caseStyle: CaseStyle.snakeCase) +class UpdatedEvent extends Event { + final String eventType; + final int version; + + const UpdatedEvent({ + required this.eventType, + required this.version, + }); } +''', + }, + outputs: { + 'test_pkg|lib/getter_and_transformed_discriminator.g.dart': + decodedMatches( + allOf([ + contains('final eventSchema = Ack.discriminated('), + contains("discriminatorKey: 'event_type'"), + contains("'event_type': Ack.literal('created')"), + contains("'event_type': Ack.literal('updated')"), + predicate((content) { + final createdSchemaMatch = RegExp( + r'final createdEventSchema = Ack\.object\(\{([^}]+)\}\)', + dotAll: true, + ).firstMatch(content); + final updatedSchemaMatch = RegExp( + r'final updatedEventSchema = Ack\.object\(\{([^}]+)\}\)', + dotAll: true, + ).firstMatch(content); + if (createdSchemaMatch == null || + updatedSchemaMatch == null) { + return false; + } + + final createdSchema = createdSchemaMatch.group(1)!; + final updatedSchema = updatedSchemaMatch.group(1)!; + final transformedKeyCount = + RegExp( + r"'event_type'\s*:", + ).allMatches(createdSchema).length + + RegExp( + r"'event_type'\s*:", + ).allMatches(updatedSchema).length; + final rawKeyCount = + RegExp( + r"'eventType'\s*:", + ).allMatches(createdSchema).length + + RegExp( + r"'eventType'\s*:", + ).allMatches(updatedSchema).length; + + return transformedKeyCount == 2 && rawKeyCount == 0; + }, 'uses only canonical transformed discriminator keys'), + ]), + ), + }, + ); + }, + ); -@AckModel(discriminatedValue: 'motorcycle') -class Motorcycle extends LandVehicle { + test( + 'canonicalizes transformed keys across getter-only and annotated leaves', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/getter_and_annotated_discriminator.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack/ack.dart'; + +part 'getter_and_annotated_discriminator.g.dart'; + +@Schemable(discriminatorKey: 'eventType') +sealed class Event { + const Event(); + + String get eventType; +} + +@Schemable(discriminatorValue: 'created') +class CreatedEvent extends Event { @override - String get vehicleType => 'land'; + String get eventType => 'created'; + + final String payload; + + const CreatedEvent({required this.payload}); +} + +@Schemable(discriminatorValue: 'updated') +class UpdatedEvent extends Event { + final String eventType; + final int version; + + const UpdatedEvent({ + @SchemaKey('event_type') required this.eventType, + required this.version, + }); +} +''', + }, + outputs: { + 'test_pkg|lib/getter_and_annotated_discriminator.g.dart': + decodedMatches( + allOf([ + contains('final eventSchema = Ack.discriminated('), + contains("discriminatorKey: 'event_type'"), + contains("'event_type': Ack.literal('created')"), + contains("'event_type': Ack.literal('updated')"), + isNot(contains("'eventType': Ack.literal('created')")), + isNot(contains("'eventType': Ack.literal('updated')")), + ]), + ), + }, + ); + }, + ); + + test( + 'preserves declared discriminator key when all leaves are getter-only', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/getter_only_discriminator.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack/ack.dart'; + +part 'getter_only_discriminator.g.dart'; + +@Schemable(discriminatorKey: 'eventType') +sealed class Event { + const Event(); + + String get eventType; +} + +@Schemable(discriminatorValue: 'created') +class CreatedEvent extends Event { @override - String get landType => 'motorcycle'; - final bool hasSidecar; - final int engineSize; - Motorcycle({required this.hasSidecar, required this.engineSize}); + String get eventType => 'created'; + + final String payload; + + const CreatedEvent({required this.payload}); } -@AckModel(discriminatedValue: 'boat') -class Boat extends Vehicle { +@Schemable(discriminatorValue: 'updated') +class UpdatedEvent extends Event { @override - String get vehicleType => 'boat'; - final double length; - final String propulsionType; - Boat({required this.length, required this.propulsionType}); + String get eventType => 'updated'; + + final int version; + + const UpdatedEvent({required this.version}); } ''', }, outputs: { - 'test_pkg|lib/deep_hierarchy.g.dart': decodedMatches( + 'test_pkg|lib/getter_only_discriminator.g.dart': decodedMatches( allOf([ - // Top-level discriminated schema - contains('final vehicleSchema = Ack.discriminated('), - contains("discriminatorKey: 'vehicleType'"), - contains("'car': carSchema"), - contains("'motorcycle': motorcycleSchema"), - contains("'boat': boatSchema"), - - // Nested discriminated schema for land vehicles - contains('final landVehicleSchema = Ack.discriminated('), - contains("discriminatorKey: 'landType'"), - contains("'car': carSchema, 'motorcycle': motorcycleSchema"), - - // Leaf schemas - contains('final carSchema = Ack.object({'), - contains("'doors': Ack.integer()"), - contains("'fuelType': Ack.string()"), - - contains('final motorcycleSchema = Ack.object({'), - contains("'hasSidecar': Ack.boolean()"), - contains("'engineSize': Ack.integer()"), - - contains('final boatSchema = Ack.object({'), - contains("'length': Ack.double()"), - contains("'propulsionType': Ack.string()"), + contains('final eventSchema = Ack.discriminated('), + contains("discriminatorKey: 'eventType'"), + contains("'eventType': Ack.literal('created')"), + contains("'eventType': Ack.literal('updated')"), + isNot(contains("'event_type': Ack.literal('created')")), + isNot(contains("'event_type': Ack.literal('updated')")), ]), ), }, ); - }); + }, + ); + + test('rejects conflicting transformed discriminator keys', () async { + final builder = ackGenerator(BuilderOptions.empty); + var sawExpectedError = false; + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/invalid.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack/ack.dart'; + +@Schemable(discriminatorKey: 'eventType') +sealed class Event { + const Event(); +} + +@Schemable(discriminatorValue: 'created', caseStyle: CaseStyle.snakeCase) +class CreatedEvent extends Event { + final String eventType; + final String payload; + + const CreatedEvent({ + required this.eventType, + required this.payload, + }); +} + +@Schemable(discriminatorValue: 'deleted') +class DeletedEvent extends Event { + final String eventType; + final String reason; + + const DeletedEvent({ + @SchemaKey('event-type') required this.eventType, + required this.reason, + }); +} +''', + }, + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE' && + log.message.contains('conflicting discriminator keys') && + log.message.contains('event_type') && + log.message.contains('event-type')) { + sawExpectedError = true; + } + }, + ); + + expect(sawExpectedError, isTrue); }); - group('Discriminated Types Validation', () { - test( - 'should validate discriminatedKey and discriminatedValue are mutually exclusive', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/invalid.dart': ''' + test('rejects mutually exclusive discriminator configuration', () async { + final builder = ackGenerator(BuilderOptions.empty); + + await expectLater( + () => testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/invalid.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack/ack.dart'; -@AckModel(discriminatedKey: 'type', discriminatedValue: 'invalid') +@Schemable(discriminatorKey: 'type', discriminatorValue: 'invalid') class InvalidModel { final String name; - InvalidModel({required this.name}); + + const InvalidModel({required this.name}); } ''', - }, - outputs: {'test_pkg|lib/invalid.g.dart': anything}, - ), - throwsA(isA()), - ); - }, + }, + outputs: {'test_pkg|lib/invalid.g.dart': anything}, + ), + throwsA(isA()), ); + }); - test( - 'should validate discriminatedKey only on abstract classes', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/invalid.dart': ''' + test('requires discriminated roots to be sealed', () async { + final builder = ackGenerator(BuilderOptions.empty); + + await expectLater( + () => testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/invalid.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack/ack.dart'; -@AckModel(discriminatedKey: 'type') -class ConcreteWithKey { // Concrete class with discriminatedKey - invalid +@Schemable(discriminatorKey: 'type') +class ConcreteWithKey { final String name; - ConcreteWithKey({required this.name}); + + const ConcreteWithKey({required this.name}); } ''', - }, - outputs: {'test_pkg|lib/invalid.g.dart': anything}, - ), - throwsA(isA()), - ); - }, + }, + outputs: {'test_pkg|lib/invalid.g.dart': anything}, + ), + throwsA(isA()), ); + }); - test( - 'should validate discriminatedValue only on concrete classes', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/invalid.dart': ''' + test('requires discriminated leaves to be concrete', () async { + final builder = ackGenerator(BuilderOptions.empty); + + await expectLater( + () => testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/invalid.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack/ack.dart'; -@AckModel(discriminatedValue: 'abstract') -abstract class AbstractWithValue { // Abstract class with discriminatedValue - invalid +@Schemable(discriminatorValue: 'abstract') +abstract class AbstractWithValue { final String name; - AbstractWithValue({required this.name}); + + const AbstractWithValue({required this.name}); } ''', - }, - outputs: {'test_pkg|lib/invalid.g.dart': anything}, - ), - throwsA(isA()), - ); - }, + }, + outputs: {'test_pkg|lib/invalid.g.dart': anything}, + ), + throwsA(isA()), ); + }); + + test('rejects sealed roots without annotated leaves', () async { + final builder = ackGenerator(BuilderOptions.empty); - test( - 'should validate discriminator field exists in base class', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/invalid.dart': ''' + await expectLater( + () => testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/invalid.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack/ack.dart'; -@AckModel(discriminatedKey: 'missingField') -abstract class BaseWithMissingField { // No field named 'missingField' - final String name; - BaseWithMissingField({required this.name}); +@Schemable(discriminatorKey: 'type') +sealed class Base { + const Base(); } ''', - }, - outputs: {'test_pkg|lib/invalid.g.dart': anything}, - ), - throwsA(isA()), - ); - }, + }, + outputs: {'test_pkg|lib/invalid.g.dart': anything}, + ), + throwsA(isA()), ); + }); - test('should validate duplicate discriminator values', () async { - final builder = ackGenerator(BuilderOptions.empty); + test('rejects duplicate discriminator values', () async { + final builder = ackGenerator(BuilderOptions.empty); - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/invalid.dart': ''' + await expectLater( + () => testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/invalid.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack/ack.dart'; -@AckModel(discriminatedKey: 'type') -abstract class Base { - String get type; +@Schemable(discriminatorKey: 'type') +sealed class Base { + const Base(); } -@AckModel(discriminatedValue: 'duplicate') +@Schemable(discriminatorValue: 'duplicate') class First extends Base { - @override - String get type => 'duplicate'; final String name; - First({required this.name}); + + const First({required this.name}); } -@AckModel(discriminatedValue: 'duplicate') // Duplicate value - invalid +@Schemable(discriminatorValue: 'duplicate') class Second extends Base { - @override - String get type => 'duplicate'; final String description; - Second({required this.description}); + + const Second({required this.description}); } ''', - }, - outputs: {'test_pkg|lib/invalid.g.dart': anything}, - ), - throwsA(isA()), - ); - }); + }, + outputs: {'test_pkg|lib/invalid.g.dart': anything}, + ), + throwsA(isA()), + ); }); }); } diff --git a/packages/ack_generator/test/enhanced_error_messages_test.dart b/packages/ack_generator/test/enhanced_error_messages_test.dart index f24c66b2..71d8734e 100644 --- a/packages/ack_generator/test/enhanced_error_messages_test.dart +++ b/packages/ack_generator/test/enhanced_error_messages_test.dart @@ -65,7 +65,7 @@ class TestModel { test('should fail build with missing model field', () async { final builder = ackGenerator(BuilderOptions.empty); - // Build should fail when using old annotation format with model: true + // Build should fail when using the removed legacy annotation shape. await expectLater( () => testBuilder( builder, @@ -135,8 +135,8 @@ class AckModel { final bool additionalProperties; final String? additionalPropertiesField; final bool model; - final String? discriminatedKey; - final String? discriminatedValue; + final String? discriminatorKey; + final String? discriminatorValue; const AckModel({ this.schemaName, @@ -144,8 +144,8 @@ class AckModel { this.additionalProperties = false, this.additionalPropertiesField, this.model = false, - this.discriminatedKey, - this.discriminatedValue, + this.discriminatorKey, + this.discriminatorValue, }); } ''', @@ -174,10 +174,10 @@ class TestModel { }); group('Validation Error Messages', () { - test('should fail for abstract class without discriminatedKey', () async { + test('should fail for abstract class without discriminatorKey', () async { final builder = ackGenerator(BuilderOptions.empty); - // Abstract class without discriminatedKey should fail + // Abstract class without discriminatorKey should fail await expectLater( () => testBuilder( builder, @@ -192,8 +192,8 @@ class AckModel { final bool additionalProperties; final String? additionalPropertiesField; final bool model; - final String? discriminatedKey; - final String? discriminatedValue; + final String? discriminatorKey; + final String? discriminatorValue; const AckModel({ this.schemaName, @@ -201,8 +201,8 @@ class AckModel { this.additionalProperties = false, this.additionalPropertiesField, this.model = false, - this.discriminatedKey, - this.discriminatedValue, + this.discriminatorKey, + this.discriminatorValue, }); } ''', @@ -218,7 +218,7 @@ class StringSchema {} import 'package:ack_annotations/ack_annotations.dart'; @AckModel() -abstract class AbstractModel { // Abstract without discriminatedKey +abstract class AbstractModel { // Abstract without discriminatorKey final String name; AbstractModel({required this.name}); } diff --git a/packages/ack_generator/test/integration/complex_model_test.dart b/packages/ack_generator/test/integration/complex_model_test.dart index f85ec0ff..1de2434b 100644 --- a/packages/ack_generator/test/integration/complex_model_test.dart +++ b/packages/ack_generator/test/integration/complex_model_test.dart @@ -7,7 +7,7 @@ import '../test_utils/test_assets.dart'; void main() { group('Complex Model Integration Tests', () { - test('generates schema with field constraints', () async { + test('generates schema with parameter constraints', () async { final builder = ackGenerator(BuilderOptions.empty); await testBuilder( @@ -17,28 +17,20 @@ void main() { 'test_pkg|lib/user.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel() +@Schemable() class User { - @AckField( - requiredMode: AckFieldRequiredMode.required, - constraints: ['notEmpty()', 'minLength(3)', 'maxLength(50)'], - ) final String username; - - @AckField( - requiredMode: AckFieldRequiredMode.required, - constraints: ['email()'], - ) final String email; - - @AckField( - constraints: ['positive()', 'max(150)'], - ) final int? age; - - User({ + + const User({ + @MinLength(3) + @MaxLength(50) required this.username, + @Email() required this.email, + @Positive() + @Max(150) this.age, }); } @@ -47,19 +39,20 @@ class User { outputs: { 'test_pkg|lib/user.g.dart': decodedMatches( allOf([ - contains('final userSchema = Ack.object('), - contains( - "'username': Ack.string().notEmpty().minLength(3).maxLength(50)", - ), + contains("'username': Ack.string().minLength(3).maxLength(50)"), contains("'email': Ack.string().email()"), - contains("'age': Ack.integer().positive().max(150).optional()"), + contains("'age': Ack.integer()"), + contains('.max(150)'), + contains('.positive()'), + contains('.optional()'), + contains('.nullable()'), ]), ), }, ); }); - test('generates schema with custom JSON keys', () async { + test('generates schema with custom parameter keys', () async { final builder = ackGenerator(BuilderOptions.empty); await testBuilder( @@ -69,21 +62,16 @@ class User { 'test_pkg|lib/api_model.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel() +@Schemable() class ApiResponse { - @AckField(jsonKey: 'response_id') final String id; - - @AckField(jsonKey: 'created_at') final String createdAt; - - @AckField(jsonKey: 'is_successful') final bool isSuccessful; - - ApiResponse({ - required this.id, - required this.createdAt, - required this.isSuccessful, + + const ApiResponse({ + @SchemaKey('response_id') required this.id, + @SchemaKey('created_at') required this.createdAt, + @SchemaKey('is_successful') required this.isSuccessful, }); } ''', @@ -91,7 +79,6 @@ class ApiResponse { outputs: { 'test_pkg|lib/api_model.g.dart': decodedMatches( allOf([ - contains('final apiResponseSchema = Ack.object('), contains("'response_id': Ack.string()"), contains("'created_at': Ack.string()"), contains("'is_successful': Ack.boolean()"), @@ -101,44 +88,7 @@ class ApiResponse { ); }); - test('generates schema with lists', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/collection.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -@AckModel() -class Collection { - final List tags; - final List scores; - final List? categories; - - Collection({ - required this.tags, - required this.scores, - this.categories, - }); -} -''', - }, - outputs: { - 'test_pkg|lib/collection.g.dart': decodedMatches( - allOf([ - contains('final collectionSchema = Ack.object('), - contains("'tags': Ack.list(Ack.string())"), - contains("'scores': Ack.list(Ack.integer())"), - contains("'categories': Ack.list(Ack.string()).optional()"), - ]), - ), - }, - ); - }); - - test('handles mixed required and optional fields', () async { + test('treats defaulted named parameters as optional', () async { final builder = ackGenerator(BuilderOptions.empty); await testBuilder( @@ -148,7 +98,7 @@ class Collection { 'test_pkg|lib/product.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel() +@Schemable() class Product { final String id; final String name; @@ -157,8 +107,8 @@ class Product { final int? stock; final List? tags; final bool isActive; - - Product({ + + const Product({ required this.id, required this.name, required this.price, @@ -173,14 +123,13 @@ class Product { outputs: { 'test_pkg|lib/product.g.dart': decodedMatches( allOf([ - contains('final productSchema = Ack.object('), contains("'id': Ack.string()"), contains("'name': Ack.string()"), contains("'price': Ack.double()"), - contains("'isActive': Ack.boolean()"), - contains("'description': Ack.string().optional()"), - contains("'stock': Ack.integer().optional()"), - contains("'tags': Ack.list(Ack.string()).optional()"), + contains("'description': Ack.string().optional().nullable()"), + contains("'stock': Ack.integer().optional().nullable()"), + contains("'tags': Ack.list(Ack.string()).optional().nullable()"), + contains("'isActive': Ack.boolean().optional()"), ]), ), }, diff --git a/packages/ack_generator/test/integration/dart_core_types_test.dart b/packages/ack_generator/test/integration/dart_core_types_test.dart index 0221965b..7ac4a39a 100644 --- a/packages/ack_generator/test/integration/dart_core_types_test.dart +++ b/packages/ack_generator/test/integration/dart_core_types_test.dart @@ -16,8 +16,8 @@ class AckModel { final bool additionalProperties; final String? additionalPropertiesField; final bool model; - final String? discriminatedKey; - final String? discriminatedValue; + final String? discriminatorKey; + final String? discriminatorValue; const AckModel({ this.schemaName, @@ -25,8 +25,8 @@ class AckModel { this.additionalProperties = false, this.additionalPropertiesField, this.model = false, - this.discriminatedKey, - this.discriminatedValue, + this.discriminatorKey, + this.discriminatorValue, }); } ''', diff --git a/packages/ack_generator/test/integration/schemable_cross_file_resolution_test.dart b/packages/ack_generator/test/integration/schemable_cross_file_resolution_test.dart new file mode 100644 index 00000000..58bea0e2 --- /dev/null +++ b/packages/ack_generator/test/integration/schemable_cross_file_resolution_test.dart @@ -0,0 +1,111 @@ +import 'package:ack_generator/builder.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:test/test.dart'; + +import '../test_utils/test_assets.dart'; + +void main() { + group('@Schemable cross-file type resolution', () { + test('resolves nested schemable types imported with a prefix', () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/remote_models.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; + +@Schemable() +class Address { + final String city; + + const Address({required this.city}); +} +''', + 'test_pkg|lib/shipment.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; +import 'remote_models.dart' as remote; + +@Schemable() +class Shipment { + final remote.Address destination; + + const Shipment({required this.destination}); +} +''', + }, + outputs: { + 'test_pkg|lib/remote_models.g.dart': decodedMatches( + contains('final addressSchema = Ack.object('), + ), + 'test_pkg|lib/shipment.g.dart': decodedMatches( + allOf([ + contains('final shipmentSchema = Ack.object('), + contains("'destination': remote.addressSchema"), + ]), + ), + }, + ); + }); + + test( + 'keeps prefixed nested types distinct from same-named local schemables', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/remote_models.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; + +@Schemable() +class Address { + final String city; + + const Address({required this.city}); +} +''', + 'test_pkg|lib/shipment.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; +import 'remote_models.dart' as remote; + +@Schemable(schemaName: 'LocalAddressSchema') +class Address { + final String street; + + const Address({required this.street}); +} + +@Schemable() +class Shipment { + final Address origin; + final remote.Address destination; + + const Shipment({ + required this.origin, + required this.destination, + }); +} +''', + }, + outputs: { + 'test_pkg|lib/remote_models.g.dart': decodedMatches( + contains('final addressSchema = Ack.object('), + ), + 'test_pkg|lib/shipment.g.dart': decodedMatches( + allOf([ + contains('final localAddressSchema = Ack.object('), + contains("'origin': localAddressSchema"), + contains("'destination': remote.addressSchema"), + ]), + ), + }, + ); + }, + ); + }); +} diff --git a/packages/ack_generator/test/integration/schemable_provider_resolution_test.dart b/packages/ack_generator/test/integration/schemable_provider_resolution_test.dart new file mode 100644 index 00000000..23ed49d7 --- /dev/null +++ b/packages/ack_generator/test/integration/schemable_provider_resolution_test.dart @@ -0,0 +1,326 @@ +import 'package:ack_generator/builder.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:test/test.dart'; + +import '../test_utils/test_assets.dart'; + +void main() { + group('@Schemable schema provider resolution', () { + test('uses same-file provider registrations', () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/invoice.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +class Money { + final int cents; + const Money(this.cents); +} + +class MoneySchemaProvider implements SchemaProvider { + const MoneySchemaProvider(); + + @override + AckSchema get schema => Ack.object({ + 'cents': Ack.integer(), + }).transform((value) => Money(value!['cents'] as int)); +} + +@Schemable(useProviders: const [MoneySchemaProvider]) +class Invoice { + final Money total; + + const Invoice({required this.total}); +} +''', + }, + outputs: { + 'test_pkg|lib/invoice.g.dart': decodedMatches( + contains( + "'total': (const MoneySchemaProvider().schema as AckSchema)", + ), + ), + }, + ); + }); + + test( + 'accepts providers that inherit schema getter from a generic base class', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/invoice.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +class Money { + final int cents; + const Money(this.cents); +} + +abstract class BaseSchemaProvider + implements SchemaProvider { + const BaseSchemaProvider(); + + AckSchema createSchema(); + + @override + AckSchema get schema => createSchema(); +} + +class MoneySchemaProvider extends BaseSchemaProvider { + const MoneySchemaProvider(); + + @override + AckSchema createSchema() => Ack.object({ + 'cents': Ack.integer(), + }).transform((value) => Money(value!['cents'] as int)); +} + +@Schemable(useProviders: const [MoneySchemaProvider]) +class Invoice { + final Money total; + + const Invoice({required this.total}); +} +''', + }, + outputs: { + 'test_pkg|lib/invoice.g.dart': decodedMatches( + contains( + "'total': (const MoneySchemaProvider().schema as AckSchema)", + ), + ), + }, + ); + }, + ); + + test('accepts providers that inherit schema getter from a mixin', () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/invoice.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +class Money { + final int cents; + const Money(this.cents); +} + +mixin MoneySchemaProviderMixin implements SchemaProvider { + @override + AckSchema get schema => Ack.object({ + 'cents': Ack.integer(), + }).transform((value) => Money(value!['cents'] as int)); +} + +class MoneySchemaProvider + with MoneySchemaProviderMixin + implements SchemaProvider { + const MoneySchemaProvider(); +} + +@Schemable(useProviders: const [MoneySchemaProvider]) +class Invoice { + final Money total; + + const Invoice({required this.total}); +} +''', + }, + outputs: { + 'test_pkg|lib/invoice.g.dart': decodedMatches( + contains( + "'total': (const MoneySchemaProvider().schema as AckSchema)", + ), + ), + }, + ); + }); + + test('uses unprefixed imported provider registrations', () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/money.dart': ''' +class Money { + final int cents; + const Money(this.cents); +} +''', + 'test_pkg|lib/money_provider.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'money.dart'; + +class MoneySchemaProvider implements SchemaProvider { + const MoneySchemaProvider(); + + @override + AckSchema get schema => Ack.object({ + 'cents': Ack.integer(), + }).transform((value) => Money(value!['cents'] as int)); +} +''', + 'test_pkg|lib/invoice.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; +import 'money.dart'; +import 'money_provider.dart'; + +@Schemable(useProviders: const [MoneySchemaProvider]) +class Invoice { + final Money total; + + const Invoice({required this.total}); +} +''', + }, + outputs: { + 'test_pkg|lib/invoice.g.dart': decodedMatches( + contains( + "'total': (const MoneySchemaProvider().schema as AckSchema)", + ), + ), + }, + ); + }); + + test('uses prefixed imported provider registrations', () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/money.dart': ''' +class Money { + final int cents; + const Money(this.cents); +} +''', + 'test_pkg|lib/money_provider.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'money.dart'; + +class MoneySchemaProvider implements SchemaProvider { + const MoneySchemaProvider(); + + @override + AckSchema get schema => Ack.object({ + 'cents': Ack.integer(), + }).transform((value) => Money(value!['cents'] as int)); +} +''', + 'test_pkg|lib/invoice.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; +import 'money.dart'; +import 'money_provider.dart' as money; + +@Schemable(useProviders: const [money.MoneySchemaProvider]) +class Invoice { + final Money total; + + const Invoice({required this.total}); +} +''', + }, + outputs: { + 'test_pkg|lib/invoice.g.dart': decodedMatches( + contains( + "'total': (const money.MoneySchemaProvider().schema as AckSchema)", + ), + ), + }, + ); + }); + + test( + 'allows non-schemable wrapper providers to compose schemable leaves', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/response.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +sealed class ResponseData { + const ResponseData(); +} + +class ResponseDataSchemaProvider implements SchemaProvider { + const ResponseDataSchemaProvider(); + + @override + AckSchema get schema => Ack.anyOf([ + userResponseSchema, + errorResponseSchema, + ]).transform( + (value) => switch (value) { + {'id': final String id} => UserResponse(id: id), + {'message': final String message} => ErrorResponse(message: message), + _ => throw StateError('Unsupported payload: \$value'), + }, + ); +} + +@Schemable(useProviders: const [ResponseDataSchemaProvider]) +class ApiResponse { + final ResponseData data; + + const ApiResponse({required this.data}); +} + +@Schemable() +class UserResponse extends ResponseData { + final String id; + + const UserResponse({required this.id}); +} + +@Schemable() +class ErrorResponse extends ResponseData { + final String message; + + const ErrorResponse({required this.message}); +} +''', + }, + outputs: { + 'test_pkg|lib/response.g.dart': decodedMatches( + allOf([ + contains( + "'data': (const ResponseDataSchemaProvider().schema as AckSchema)", + ), + contains('final userResponseSchema = Ack.object({'), + contains('final errorResponseSchema = Ack.object({'), + ]), + ), + }, + ); + }, + ); + }); +} diff --git a/packages/ack_generator/test/src/analyzer/field_analyzer_test.dart b/packages/ack_generator/test/src/analyzer/field_analyzer_test.dart index 8057461d..bb31ba17 100644 --- a/packages/ack_generator/test/src/analyzer/field_analyzer_test.dart +++ b/packages/ack_generator/test/src/analyzer/field_analyzer_test.dart @@ -1,7 +1,8 @@ -import 'package:test/test.dart'; -import 'package:build_test/build_test.dart'; -import 'package:build/build.dart'; +import 'package:ack_annotations/ack_annotations.dart'; import 'package:ack_generator/src/analyzer/field_analyzer.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:test/test.dart'; import '../../test_utils/test_assets.dart'; @@ -13,86 +14,75 @@ void main() { analyzer = FieldAnalyzer(); }); - test('extracts field with AckField annotation', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/model.dart': ''' + test( + 'extracts SchemaKey and requiredness from constructor parameters', + () async { + final assets = { + ...allAssets, + 'test_pkg|lib/model.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel() +@Schemable() class User { - @AckField(requiredMode: AckFieldRequiredMode.required, jsonKey: 'user_name') final String name; - - User(this.name); -} -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/model.dart'), - ); - final classElement = library.classes.firstWhere( - (e) => e.name3 == 'User', - ); - final field = classElement.fields2.firstWhere((f) => f.name3 == 'name'); + final String? nickname; - final fieldInfo = analyzer.analyze(field); - - expect(fieldInfo.name, equals('name')); - expect(fieldInfo.jsonKey, equals('user_name')); - expect(fieldInfo.isRequired, isTrue); - }); - }); - - test('uses field name as json key when not specified', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/model.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -@AckModel() -class User { - final String firstName; - - User(this.firstName); + const User({ + @SchemaKey('user_name') required this.name, + this.nickname, + }); } ''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/model.dart'), - ); - final classElement = library.classes.firstWhere( - (e) => e.name3 == 'User', - ); - final field = classElement.fields2.firstWhere( - (f) => f.name3 == 'firstName', - ); - - final fieldInfo = analyzer.analyze(field); - - expect(fieldInfo.jsonKey, equals('firstName')); - }); - }); - - test('uses auto required inference for bare AckField()', () async { + }; + + await resolveSources(assets, (resolver) async { + final library = await resolver.libraryFor( + AssetId('test_pkg', 'lib/model.dart'), + ); + final classElement = library.classes.firstWhere( + (e) => e.name3 == 'User', + ); + final constructor = classElement.constructors2.firstWhere( + (c) => c.name3 == 'new', + ); + + final nameParameter = constructor.formalParameters.firstWhere( + (p) => p.name3 == 'name', + ); + final nicknameParameter = constructor.formalParameters.firstWhere( + (p) => p.name3 == 'nickname', + ); + + final nameInfo = analyzer.analyze( + nameParameter, + caseStyle: CaseStyle.none, + ); + final nicknameInfo = analyzer.analyze( + nicknameParameter, + caseStyle: CaseStyle.none, + ); + + expect(nameInfo.name, equals('name')); + expect(nameInfo.jsonKey, equals('user_name')); + expect(nameInfo.isRequired, isTrue); + expect(nameInfo.isNullable, isFalse); + expect(nicknameInfo.isRequired, isFalse); + expect(nicknameInfo.isNullable, isTrue); + }); + }, + ); + + test('applies caseStyle when SchemaKey is not present', () async { final assets = { ...allAssets, 'test_pkg|lib/model.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel() -class RequiredInference { - @AckField() - final String mustStayRequired; +@Schemable(caseStyle: CaseStyle.snakeCase) +class ApiPayload { + final String userId; - @AckField() - final String? nullableField; - - RequiredInference(this.mustStayRequired, this.nullableField); + const ApiPayload({required this.userId}); } ''', }; @@ -102,273 +92,137 @@ class RequiredInference { AssetId('test_pkg', 'lib/model.dart'), ); final classElement = library.classes.firstWhere( - (e) => e.name3 == 'RequiredInference', - ); - - final requiredField = classElement.fields2.firstWhere( - (f) => f.name3 == 'mustStayRequired', - ); - final nullableField = classElement.fields2.firstWhere( - (f) => f.name3 == 'nullableField', - ); - - expect(analyzer.analyze(requiredField).isRequired, isTrue); - expect(analyzer.analyze(nullableField).isRequired, isFalse); - }); - }); - - test('supports explicit requiredMode overrides', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/model.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -@AckModel() -class RequiredOverrides { - @AckField(requiredMode: AckFieldRequiredMode.optional, jsonKey: 'optional_name') - final String optionalWithJsonKey; - - @AckField(requiredMode: AckFieldRequiredMode.optional) - final String modeOptional; - - @AckField(requiredMode: AckFieldRequiredMode.required) - final String modeWins; - - RequiredOverrides(this.optionalWithJsonKey, this.modeOptional, this.modeWins); -} -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/model.dart'), + (e) => e.name3 == 'ApiPayload', ); - final classElement = library.classes.firstWhere( - (e) => e.name3 == 'RequiredOverrides', + final constructor = classElement.constructors2.firstWhere( + (c) => c.name3 == 'new', ); + final parameter = constructor.formalParameters.single; - final optionalWithJsonKey = classElement.fields2.firstWhere( - (f) => f.name3 == 'optionalWithJsonKey', - ); - final modeOptional = classElement.fields2.firstWhere( - (f) => f.name3 == 'modeOptional', - ); - final modeWins = classElement.fields2.firstWhere( - (f) => f.name3 == 'modeWins', + final fieldInfo = analyzer.analyze( + parameter, + caseStyle: CaseStyle.snakeCase, ); - expect(analyzer.analyze(optionalWithJsonKey).isRequired, isFalse); - expect(analyzer.analyze(modeOptional).isRequired, isFalse); - expect(analyzer.analyze(modeWins).isRequired, isTrue); + expect(fieldInfo.jsonKey, equals('user_id')); }); }); - test('identifies nullable fields', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/model.dart': ''' + test( + 'reads decorator constraints and descriptions from parameters', + () async { + final assets = { + ...allAssets, + 'test_pkg|lib/model.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel() -class User { - final String name; - final String? email; - final int? age; - - User({required this.name, this.email, this.age}); -} -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/model.dart'), - ); - final classElement = library.classes.firstWhere( - (e) => e.name3 == 'User', - ); - - final nameField = classElement.fields2.firstWhere( - (f) => f.name3 == 'name', - ); - final emailField = classElement.fields2.firstWhere( - (f) => f.name3 == 'email', - ); - final ageField = classElement.fields2.firstWhere( - (f) => f.name3 == 'age', - ); - - expect(analyzer.analyze(nameField).isNullable, isFalse); - expect(analyzer.analyze(emailField).isNullable, isTrue); - expect(analyzer.analyze(ageField).isNullable, isTrue); - }); - }); - - test('parses constraint strings correctly', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/model.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -@AckModel() -class User { - @AckField(constraints: ['email()', 'minLength(5)', 'maxLength(100)']) +@Schemable() +class Signup { final String email; - - @AckField(constraints: ['positive', 'max(150)']) + final String? displayName; final int age; - - User({required this.email, required this.age}); -} -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/model.dart'), - ); - final classElement = library.classes.firstWhere( - (e) => e.name3 == 'User', - ); - - final emailField = classElement.fields2.firstWhere( - (f) => f.name3 == 'email', - ); - final emailInfo = analyzer.analyze(emailField); - - expect(emailInfo.constraints.length, equals(3)); - expect(emailInfo.constraints[0].name, equals('email')); - expect(emailInfo.constraints[0].arguments, isEmpty); - expect(emailInfo.constraints[1].name, equals('minLength')); - expect(emailInfo.constraints[1].arguments, equals(['5'])); - expect(emailInfo.constraints[2].name, equals('maxLength')); - expect(emailInfo.constraints[2].arguments, equals(['100'])); - - final ageField = classElement.fields2.firstWhere( - (f) => f.name3 == 'age', - ); - final ageInfo = analyzer.analyze(ageField); - expect(ageInfo.constraints.length, equals(2)); - expect(ageInfo.constraints[0].name, equals('positive')); - expect(ageInfo.constraints[0].arguments, isEmpty); - expect(ageInfo.constraints[1].name, equals('max')); - expect(ageInfo.constraints[1].arguments, equals(['150'])); - }); - }); - - test('handles fields with default values', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/model.dart': ''' -import 'package:ack_annotations/ack_annotations.dart'; - -@AckModel() -class Settings { - final bool enabled = true; - final int retryCount = 3; - final String theme = 'light'; - - Settings(); + const Signup({ + @Description('Primary email address') + @Email() + required this.email, + @MinLength(3) + @MaxLength(20) + this.displayName, + @Min(13) + @Max(120) + required this.age, + }); } ''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/model.dart'), - ); - final classElement = library.classes.firstWhere( - (e) => e.name3 == 'Settings', - ); - - final enabledField = classElement.fields2.firstWhere( - (f) => f.name3 == 'enabled', - ); - final retryField = classElement.fields2.firstWhere( - (f) => f.name3 == 'retryCount', - ); - final themeField = classElement.fields2.firstWhere( - (f) => f.name3 == 'theme', - ); - - // Note: In actual implementation, extracting default values from AST - // requires more complex analysis. For testing, we verify the field exists - expect(analyzer.analyze(enabledField).name, equals('enabled')); - expect(analyzer.analyze(retryField).name, equals('retryCount')); - expect(analyzer.analyze(themeField).name, equals('theme')); - }); - }); - - test('correctly identifies field types', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/model.dart': ''' + }; + + await resolveSources(assets, (resolver) async { + final library = await resolver.libraryFor( + AssetId('test_pkg', 'lib/model.dart'), + ); + final classElement = library.classes.firstWhere( + (e) => e.name3 == 'Signup', + ); + final constructor = classElement.constructors2.firstWhere( + (c) => c.name3 == 'new', + ); + + final emailParameter = constructor.formalParameters.firstWhere( + (p) => p.name3 == 'email', + ); + final displayNameParameter = constructor.formalParameters.firstWhere( + (p) => p.name3 == 'displayName', + ); + final ageParameter = constructor.formalParameters.firstWhere( + (p) => p.name3 == 'age', + ); + + final emailInfo = analyzer.analyze( + emailParameter, + caseStyle: CaseStyle.none, + ); + final displayNameInfo = analyzer.analyze( + displayNameParameter, + caseStyle: CaseStyle.none, + ); + final ageInfo = analyzer.analyze( + ageParameter, + caseStyle: CaseStyle.none, + ); + + expect(emailInfo.description, equals('Primary email address')); + expect(emailInfo.constraints.map((c) => c.name), contains('email')); + expect( + displayNameInfo.constraints.map((c) => c.name), + containsAll(['minLength', 'maxLength']), + ); + expect( + ageInfo.constraints.map((c) => c.name), + containsAll(['min', 'max']), + ); + }); + }, + ); + + test( + 'marks defaulted named parameters as optional and non-nullable', + () async { + final assets = { + ...allAssets, + 'test_pkg|lib/model.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel() -class TypeTest { - final String text; - final int count; - final double price; - final bool active; - final List tags; - final Map metadata; - - TypeTest({ - required this.text, - required this.count, - required this.price, - required this.active, - required this.tags, - required this.metadata, - }); +@Schemable() +class RetryPolicy { + final int retries; + + const RetryPolicy({this.retries = 3}); } ''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/model.dart'), - ); - final classElement = library.classes.firstWhere( - (e) => e.name3 == 'TypeTest', - ); - - final fields = classElement.fields2.where((f) => !f.isSynthetic); - - for (final field in fields) { - final fieldInfo = analyzer.analyze(field); - final typeName = fieldInfo.type.getDisplayString(); - - switch (fieldInfo.name) { - case 'text': - expect(typeName, equals('String')); - expect(fieldInfo.isPrimitive, isTrue); - break; - case 'count': - expect(typeName, equals('int')); - expect(fieldInfo.isPrimitive, isTrue); - break; - case 'price': - expect(typeName, equals('double')); - expect(fieldInfo.isPrimitive, isTrue); - break; - case 'active': - expect(typeName, equals('bool')); - expect(fieldInfo.isPrimitive, isTrue); - break; - case 'tags': - expect(typeName, equals('List')); - expect(fieldInfo.isList, isTrue); - break; - case 'metadata': - expect(typeName, equals('Map')); - expect(fieldInfo.isMap, isTrue); - break; - } - } - }); - }); + }; + + await resolveSources(assets, (resolver) async { + final library = await resolver.libraryFor( + AssetId('test_pkg', 'lib/model.dart'), + ); + final classElement = library.classes.firstWhere( + (e) => e.name3 == 'RetryPolicy', + ); + final constructor = classElement.constructors2.firstWhere( + (c) => c.name3 == 'new', + ); + final retriesParameter = constructor.formalParameters.single; + + final fieldInfo = analyzer.analyze( + retriesParameter, + caseStyle: CaseStyle.none, + ); + + expect(fieldInfo.isRequired, isFalse); + expect(fieldInfo.isNullable, isFalse); + }); + }, + ); }); } diff --git a/packages/ack_generator/test/src/analyzer/model_analyzer_test.dart b/packages/ack_generator/test/src/analyzer/model_analyzer_test.dart index 988c60cf..ae66e3e6 100644 --- a/packages/ack_generator/test/src/analyzer/model_analyzer_test.dart +++ b/packages/ack_generator/test/src/analyzer/model_analyzer_test.dart @@ -1,9 +1,9 @@ import 'package:ack_annotations/ack_annotations.dart'; -import 'package:test/test.dart'; -import 'package:build_test/build_test.dart'; +import 'package:ack_generator/src/analyzer/model_analyzer.dart'; import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; import 'package:source_gen/source_gen.dart'; -import 'package:ack_generator/src/analyzer/model_analyzer.dart'; +import 'package:test/test.dart'; import '../../test_utils/test_assets.dart'; @@ -21,10 +21,11 @@ void main() { 'test_pkg|lib/model.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel(schemaName: 'CustomSchema') +@Schemable(schemaName: 'CustomSchema') class TestModel { final String name; - TestModel(this.name); + + const TestModel({required this.name}); } ''', }; @@ -34,11 +35,11 @@ class TestModel { AssetId('test_pkg', 'lib/model.dart'), ); final classElement = library.classes.firstWhere( - (e) => e.name3 == 'TestModel', + (element) => element.name3 == 'TestModel', ); final annotation = TypeChecker.typeNamed( - AckModel, + Schemable, ).firstAnnotationOfExact(classElement)!; final reader = ConstantReader(annotation); @@ -48,16 +49,71 @@ class TestModel { }); }); - test('generates default schema name when not specified', () async { + test( + 'extracts description from annotation or class doc comments', + () async { + final assets = { + ...allAssets, + 'test_pkg|lib/model.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; + +/// Fallback description from a doc comment. +@Schemable() +class User { + final String name; + + const User({required this.name}); +} +''', + }; + + await resolveSources(assets, (resolver) async { + final library = await resolver.libraryFor( + AssetId('test_pkg', 'lib/model.dart'), + ); + final classElement = library.classes.firstWhere( + (element) => element.name3 == 'User', + ); + + final annotation = TypeChecker.typeNamed( + Schemable, + ).firstAnnotationOfExact(classElement)!; + final reader = ConstantReader(annotation); + + final modelInfo = analyzer.analyze(classElement, reader); + + expect( + modelInfo.description, + equals('Fallback description from a doc comment.'), + ); + }); + }, + ); + + test('uses the selected constructor rather than scanning fields', () async { final assets = { ...allAssets, 'test_pkg|lib/model.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel() -class UserProfile { +class BaseModel { + final String id; + + const BaseModel({required this.id}); +} + +@Schemable() +class ExtendedModel extends BaseModel { final String name; - UserProfile(this.name); + final int age; + + const ExtendedModel._({required super.id, required this.name, required this.age}); + + @SchemaConstructor() + const ExtendedModel.fromApi({ + required super.id, + required this.name, + }) : age = 0; } ''', }; @@ -67,30 +123,39 @@ class UserProfile { AssetId('test_pkg', 'lib/model.dart'), ); final classElement = library.classes.firstWhere( - (e) => e.name3 == 'UserProfile', + (element) => element.name3 == 'ExtendedModel', ); final annotation = TypeChecker.typeNamed( - AckModel, + Schemable, ).firstAnnotationOfExact(classElement)!; final reader = ConstantReader(annotation); final modelInfo = analyzer.analyze(classElement, reader); - expect(modelInfo.schemaClassName, equals('UserProfileSchema')); + expect(modelInfo.fields.map((field) => field.name), ['id', 'name']); }); }); - test('extracts description from annotation', () async { + test('identifies required fields from constructor parameters', () async { final assets = { ...allAssets, 'test_pkg|lib/model.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; -@AckModel(description: 'A user model for testing') -class User { +@Schemable() +class Product { final String name; - User(this.name); + final double price; + final String? description; + final int retries; + + const Product({ + required this.name, + required this.price, + this.description, + this.retries = 3, + }); } ''', }; @@ -100,37 +165,52 @@ class User { AssetId('test_pkg', 'lib/model.dart'), ); final classElement = library.classes.firstWhere( - (e) => e.name3 == 'User', + (element) => element.name3 == 'Product', ); final annotation = TypeChecker.typeNamed( - AckModel, + Schemable, ).firstAnnotationOfExact(classElement)!; final reader = ConstantReader(annotation); final modelInfo = analyzer.analyze(classElement, reader); - expect(modelInfo.description, equals('A user model for testing')); + expect(modelInfo.requiredFields, containsAll(['name', 'price'])); + expect(modelInfo.requiredFields, isNot(contains('description'))); + expect(modelInfo.requiredFields, isNot(contains('retries'))); }); }); - test('analyzes all fields including inherited ones', () async { + test('reads class-level caseStyle and provider registrations', () async { final assets = { ...allAssets, 'test_pkg|lib/model.dart': ''' +import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -class BaseModel { - final String id; - BaseModel(this.id); +class Money { + final int cents; + const Money(this.cents); } -@AckModel() -class ExtendedModel extends BaseModel { - final String name; - final int age; +class MoneySchemaProvider implements SchemaProvider { + const MoneySchemaProvider(); + + @override + AckSchema get schema => + Ack.object({'cents': Ack.integer()}).transform( + (value) => Money(value!['cents'] as int), + ); +} + +@Schemable( + caseStyle: CaseStyle.snakeCase, + useProviders: const [MoneySchemaProvider], +) +class Invoice { + final Money totalAmount; - ExtendedModel(String id, this.name, this.age) : super(id); + const Invoice({required this.totalAmount}); } ''', }; @@ -140,37 +220,121 @@ class ExtendedModel extends BaseModel { AssetId('test_pkg', 'lib/model.dart'), ); final classElement = library.classes.firstWhere( - (e) => e.name3 == 'ExtendedModel', + (element) => element.name3 == 'Invoice', ); final annotation = TypeChecker.typeNamed( - AckModel, + Schemable, ).firstAnnotationOfExact(classElement)!; final reader = ConstantReader(annotation); final modelInfo = analyzer.analyze(classElement, reader); - expect(modelInfo.fields.length, equals(3)); + expect(modelInfo.fields.single.jsonKey, equals('total_amount')); expect( - modelInfo.fields.map((f) => f.name), - containsAll(['id', 'name', 'age']), + modelInfo.typeProviders.single.providerTypeName, + equals('MoneySchemaProvider'), ); }); }); - test('identifies required fields correctly', () async { + test( + 'accepts providers that inherit schema getter from a generic base class', + () async { + final assets = { + ...allAssets, + 'test_pkg|lib/model.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +class Money { + final int cents; + const Money(this.cents); +} + +abstract class BaseSchemaProvider + implements SchemaProvider { + const BaseSchemaProvider(); + + AckSchema createSchema(); + + @override + AckSchema get schema => createSchema(); +} + +class MoneySchemaProvider extends BaseSchemaProvider { + const MoneySchemaProvider(); + + @override + AckSchema createSchema() => + Ack.object({'cents': Ack.integer()}).transform( + (value) => Money(value!['cents'] as int), + ); +} + +@Schemable(useProviders: const [MoneySchemaProvider]) +class Invoice { + final Money total; + + const Invoice({required this.total}); +} +''', + }; + + await resolveSources(assets, (resolver) async { + final library = await resolver.libraryFor( + AssetId('test_pkg', 'lib/model.dart'), + ); + final classElement = library.classes.firstWhere( + (element) => element.name3 == 'Invoice', + ); + + final annotation = TypeChecker.typeNamed( + Schemable, + ).firstAnnotationOfExact(classElement)!; + final reader = ConstantReader(annotation); + + final modelInfo = analyzer.analyze(classElement, reader); + + expect( + modelInfo.typeProviders.single.providerTypeName, + equals('MoneySchemaProvider'), + ); + }); + }, + ); + + test('accepts providers that inherit schema getter from a mixin', () async { final assets = { ...allAssets, 'test_pkg|lib/model.dart': ''' +import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -@AckModel() -class Product { - final String name; - final double price; - final String? description; +class Money { + final int cents; + const Money(this.cents); +} + +mixin MoneySchemaProviderMixin implements SchemaProvider { + @override + AckSchema get schema => + Ack.object({'cents': Ack.integer()}).transform( + (value) => Money(value!['cents'] as int), + ); +} - Product({required this.name, required this.price, this.description}); +class MoneySchemaProvider + with MoneySchemaProviderMixin + implements SchemaProvider { + const MoneySchemaProvider(); +} + +@Schemable(useProviders: const [MoneySchemaProvider]) +class Invoice { + final Money total; + + const Invoice({required this.total}); } ''', }; @@ -180,18 +344,432 @@ class Product { AssetId('test_pkg', 'lib/model.dart'), ); final classElement = library.classes.firstWhere( - (e) => e.name3 == 'Product', + (element) => element.name3 == 'Invoice', ); final annotation = TypeChecker.typeNamed( - AckModel, + Schemable, ).firstAnnotationOfExact(classElement)!; final reader = ConstantReader(annotation); final modelInfo = analyzer.analyze(classElement, reader); - expect(modelInfo.requiredFields, containsAll(['name', 'price'])); - expect(modelInfo.requiredFields, isNot(contains('description'))); + expect( + modelInfo.typeProviders.single.providerTypeName, + equals('MoneySchemaProvider'), + ); + }); + }); + + test('rejects providers that target schemable types', () async { + final assets = { + ...allAssets, + 'test_pkg|lib/model.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +@Schemable() +class Money { + final int cents; + + const Money({required this.cents}); +} + +class MoneySchemaProvider implements SchemaProvider { + const MoneySchemaProvider(); + + @override + AckSchema get schema => Ack.object({ + 'cents': Ack.integer(), + }).transform((value) => Money(cents: value!['cents'] as int)); +} + +@Schemable(useProviders: const [MoneySchemaProvider]) +class Invoice { + final Money total; + + const Invoice({required this.total}); +} +''', + }; + + await resolveSources(assets, (resolver) async { + final library = await resolver.libraryFor( + AssetId('test_pkg', 'lib/model.dart'), + ); + final classElement = library.classes.firstWhere( + (element) => element.name3 == 'Invoice', + ); + + final annotation = TypeChecker.typeNamed( + Schemable, + ).firstAnnotationOfExact(classElement)!; + final reader = ConstantReader(annotation); + + expect( + () => analyzer.analyze(classElement, reader), + throwsA( + isA().having( + (error) => error.message.toString(), + 'message', + allOf([ + contains('MoneySchemaProvider'), + contains('cannot target Money'), + contains('already has a generated schema'), + ]), + ), + ), + ); + }); + }); + + test( + 'canonicalizes discriminator keys from transformed subtype fields', + () async { + final assets = { + ...allAssets, + 'test_pkg|lib/model.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; + +@Schemable(discriminatorKey: 'eventType') +sealed class Event { + const Event(); +} + +@Schemable(discriminatorValue: 'created', caseStyle: CaseStyle.snakeCase) +class CreatedEvent extends Event { + final String eventType; + final String payload; + + const CreatedEvent({ + required this.eventType, + required this.payload, + }); +} + +@Schemable(discriminatorValue: 'updated', caseStyle: CaseStyle.snakeCase) +class UpdatedEvent extends Event { + final String eventType; + final int version; + + const UpdatedEvent({ + required this.eventType, + required this.version, + }); +} +''', + }; + + await resolveSources(assets, (resolver) async { + final library = await resolver.libraryFor( + AssetId('test_pkg', 'lib/model.dart'), + ); + final classElements = library.classes.toList(); + final modelInfos = classElements.map((classElement) { + final annotation = TypeChecker.typeNamed( + Schemable, + ).firstAnnotationOfExact(classElement)!; + return analyzer.analyze(classElement, ConstantReader(annotation)); + }).toList(); + + final linkedModels = analyzer.buildDiscriminatorRelationships( + modelInfos, + classElements, + ); + final eventModel = linkedModels.firstWhere( + (model) => model.className == 'Event', + ); + final createdModel = linkedModels.firstWhere( + (model) => model.className == 'CreatedEvent', + ); + final updatedModel = linkedModels.firstWhere( + (model) => model.className == 'UpdatedEvent', + ); + + expect(eventModel.discriminatorKey, equals('event_type')); + expect(createdModel.discriminatorKey, equals('event_type')); + expect(updatedModel.discriminatorKey, equals('event_type')); + }); + }, + ); + + test( + 'canonicalizes discriminator keys from getter-only and transformed leaves', + () async { + final assets = { + ...allAssets, + 'test_pkg|lib/model.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; + +@Schemable(discriminatorKey: 'eventType') +sealed class Event { + const Event(); + + String get eventType; +} + +@Schemable(discriminatorValue: 'created') +class CreatedEvent extends Event { + @override + String get eventType => 'created'; + + final String payload; + + const CreatedEvent({required this.payload}); +} + +@Schemable(discriminatorValue: 'updated', caseStyle: CaseStyle.snakeCase) +class UpdatedEvent extends Event { + final String eventType; + final int version; + + const UpdatedEvent({ + required this.eventType, + required this.version, + }); +} +''', + }; + + await resolveSources(assets, (resolver) async { + final library = await resolver.libraryFor( + AssetId('test_pkg', 'lib/model.dart'), + ); + final classElements = library.classes.toList(); + final modelInfos = classElements.map((classElement) { + final annotation = TypeChecker.typeNamed( + Schemable, + ).firstAnnotationOfExact(classElement)!; + return analyzer.analyze(classElement, ConstantReader(annotation)); + }).toList(); + + final linkedModels = analyzer.buildDiscriminatorRelationships( + modelInfos, + classElements, + ); + final eventModel = linkedModels.firstWhere( + (model) => model.className == 'Event', + ); + final createdModel = linkedModels.firstWhere( + (model) => model.className == 'CreatedEvent', + ); + final updatedModel = linkedModels.firstWhere( + (model) => model.className == 'UpdatedEvent', + ); + + expect(eventModel.discriminatorKey, equals('event_type')); + expect(createdModel.discriminatorKey, equals('event_type')); + expect(updatedModel.discriminatorKey, equals('event_type')); + }); + }, + ); + + test( + 'canonicalizes discriminator keys from getter-only and annotated leaves', + () async { + final assets = { + ...allAssets, + 'test_pkg|lib/model.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; + +@Schemable(discriminatorKey: 'eventType') +sealed class Event { + const Event(); + + String get eventType; +} + +@Schemable(discriminatorValue: 'created') +class CreatedEvent extends Event { + @override + String get eventType => 'created'; + + final String payload; + + const CreatedEvent({required this.payload}); +} + +@Schemable(discriminatorValue: 'updated') +class UpdatedEvent extends Event { + final String eventType; + final int version; + + const UpdatedEvent({ + @SchemaKey('event_type') required this.eventType, + required this.version, + }); +} +''', + }; + + await resolveSources(assets, (resolver) async { + final library = await resolver.libraryFor( + AssetId('test_pkg', 'lib/model.dart'), + ); + final classElements = library.classes.toList(); + final modelInfos = classElements.map((classElement) { + final annotation = TypeChecker.typeNamed( + Schemable, + ).firstAnnotationOfExact(classElement)!; + return analyzer.analyze(classElement, ConstantReader(annotation)); + }).toList(); + + final linkedModels = analyzer.buildDiscriminatorRelationships( + modelInfos, + classElements, + ); + final eventModel = linkedModels.firstWhere( + (model) => model.className == 'Event', + ); + final createdModel = linkedModels.firstWhere( + (model) => model.className == 'CreatedEvent', + ); + final updatedModel = linkedModels.firstWhere( + (model) => model.className == 'UpdatedEvent', + ); + + expect(eventModel.discriminatorKey, equals('event_type')); + expect(createdModel.discriminatorKey, equals('event_type')); + expect(updatedModel.discriminatorKey, equals('event_type')); + }); + }, + ); + + test( + 'falls back to declared discriminator key for getter-only hierarchies', + () async { + final assets = { + ...allAssets, + 'test_pkg|lib/model.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; + +@Schemable(discriminatorKey: 'eventType') +sealed class Event { + const Event(); + + String get eventType; +} + +@Schemable(discriminatorValue: 'created') +class CreatedEvent extends Event { + @override + String get eventType => 'created'; + + final String payload; + + const CreatedEvent({required this.payload}); +} + +@Schemable(discriminatorValue: 'updated') +class UpdatedEvent extends Event { + @override + String get eventType => 'updated'; + + final int version; + + const UpdatedEvent({required this.version}); +} +''', + }; + + await resolveSources(assets, (resolver) async { + final library = await resolver.libraryFor( + AssetId('test_pkg', 'lib/model.dart'), + ); + final classElements = library.classes.toList(); + final modelInfos = classElements.map((classElement) { + final annotation = TypeChecker.typeNamed( + Schemable, + ).firstAnnotationOfExact(classElement)!; + return analyzer.analyze(classElement, ConstantReader(annotation)); + }).toList(); + + final linkedModels = analyzer.buildDiscriminatorRelationships( + modelInfos, + classElements, + ); + final eventModel = linkedModels.firstWhere( + (model) => model.className == 'Event', + ); + final createdModel = linkedModels.firstWhere( + (model) => model.className == 'CreatedEvent', + ); + final updatedModel = linkedModels.firstWhere( + (model) => model.className == 'UpdatedEvent', + ); + + expect(eventModel.discriminatorKey, equals('eventType')); + expect(createdModel.discriminatorKey, equals('eventType')); + expect(updatedModel.discriminatorKey, equals('eventType')); + }); + }, + ); + + test('rejects conflicting transformed discriminator keys', () async { + final assets = { + ...allAssets, + 'test_pkg|lib/model.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; + +@Schemable(discriminatorKey: 'eventType') +sealed class Event { + const Event(); +} + +@Schemable(discriminatorValue: 'created', caseStyle: CaseStyle.snakeCase) +class CreatedEvent extends Event { + final String eventType; + final String payload; + + const CreatedEvent({ + required this.eventType, + required this.payload, + }); +} + +@Schemable(discriminatorValue: 'deleted') +class DeletedEvent extends Event { + final String eventType; + final String reason; + + const DeletedEvent({ + @SchemaKey('event-type') required this.eventType, + required this.reason, + }); +} +''', + }; + + await resolveSources(assets, (resolver) async { + final library = await resolver.libraryFor( + AssetId('test_pkg', 'lib/model.dart'), + ); + final classElements = library.classes.toList(); + final modelInfos = classElements.map((classElement) { + final annotation = TypeChecker.typeNamed( + Schemable, + ).firstAnnotationOfExact(classElement)!; + return analyzer.analyze(classElement, ConstantReader(annotation)); + }).toList(); + + expect( + () => analyzer.buildDiscriminatorRelationships( + modelInfos, + classElements, + ), + throwsA( + isA().having( + (error) => error.message.toString(), + 'message', + allOf([ + contains('conflicting discriminator keys'), + contains('Event'), + contains('event_type'), + contains('event-type'), + ]), + ), + ), + ); }); }); }); diff --git a/packages/ack_generator/test/src/builders/field_builder_test.dart b/packages/ack_generator/test/src/builders/field_builder_test.dart index 21abc3aa..3e6a618e 100644 --- a/packages/ack_generator/test/src/builders/field_builder_test.dart +++ b/packages/ack_generator/test/src/builders/field_builder_test.dart @@ -1,5 +1,6 @@ import 'package:ack_generator/src/builders/field_builder.dart'; import 'package:ack_generator/src/models/constraint_info.dart'; +import 'package:ack_generator/src/models/model_info.dart'; import 'package:test/test.dart'; import '../test_utilities.dart'; @@ -125,12 +126,26 @@ void main() { group('nested schemas', () { test('builds nested schema reference', () { + builder.setAllModels([ + const ModelInfo( + className: 'Address', + schemaClassName: 'AddressSchema', + fields: [], + ), + ]); final field = createField('address', 'Address', isRequired: true); final schema = builder.buildFieldSchema(field); expect(schema, equals('addressSchema')); }); test('builds optional nested schema', () { + builder.setAllModels([ + const ModelInfo( + className: 'Profile', + schemaClassName: 'ProfileSchema', + fields: [], + ), + ]); final field = createField('profile', 'Profile', isNullable: true); final schema = builder.buildFieldSchema(field); expect(schema, equals('profileSchema.optional().nullable()')); diff --git a/packages/ack_generator/test/src/builders/schema_builder_test.dart b/packages/ack_generator/test/src/builders/schema_builder_test.dart index 1af47e6a..f835c3bd 100644 --- a/packages/ack_generator/test/src/builders/schema_builder_test.dart +++ b/packages/ack_generator/test/src/builders/schema_builder_test.dart @@ -84,6 +84,19 @@ void main() { }); test('generates schema for nested objects', () { + builder.setAllModels([ + const ModelInfo( + className: 'Order', + schemaClassName: 'OrderSchema', + fields: [], + ), + const ModelInfo( + className: 'Customer', + schemaClassName: 'CustomerSchema', + fields: [], + ), + ]); + final model = ModelInfo( className: 'Order', schemaClassName: 'OrderSchema', diff --git a/packages/ack_generator/test/src/generator_test.dart b/packages/ack_generator/test/src/generator_test.dart index 241491c0..cb4edc66 100644 --- a/packages/ack_generator/test/src/generator_test.dart +++ b/packages/ack_generator/test/src/generator_test.dart @@ -33,13 +33,13 @@ import 'package:ack_annotations/ack_annotations.dart'; @AckModel() class User { final String name; - User(this.name); + User({required this.name}); } @AckModel() class Product { final String title; - Product(this.title); + Product({required this.title}); } // Not annotated - should be ignored @@ -74,7 +74,7 @@ import 'package:ack_annotations/ack_annotations.dart'; @AckModel() class Address { final String street; - Address(this.street); + Address({required this.street}); } ''', 'test_pkg|lib/user.dart': ''' @@ -84,7 +84,7 @@ import 'address.dart'; @AckModel() class User { final Address address; - User(this.address); + User({required this.address}); } ''', }, @@ -108,13 +108,14 @@ class User { ...allAssets, 'test_pkg|lib/model.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack/ack.dart'; part 'model.ack.g.dart'; @AckModel() class Model { final String id; - Model(this.id); + Model({required this.id}); } ''', }, @@ -130,6 +131,41 @@ class Model { ); }); + test( + 'fails with a clear error when Ack import is missing for part files', + () async { + final builder = SharedPartBuilder([generator], 'ack'); + var sawExpectedError = false; + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/missing_ack_import.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; + +part 'missing_ack_import.ack.g.dart'; + +@Schemable() +class MissingAckImport { + final String id; + const MissingAckImport({required this.id}); +} +''', + }, + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE' && + log.message.contains("import 'package:ack/ack.dart';")) { + sawExpectedError = true; + } + }, + ); + + expect(sawExpectedError, isTrue); + }, + ); + test('preserves formatting', () async { final builder = SharedPartBuilder([generator], 'ack'); diff --git a/packages/ack_generator/test/src/test_utilities.dart b/packages/ack_generator/test/src/test_utilities.dart index 6aba3864..1d8446b2 100644 --- a/packages/ack_generator/test/src/test_utilities.dart +++ b/packages/ack_generator/test/src/test_utilities.dart @@ -23,6 +23,9 @@ class MockFieldInfo implements FieldInfo { @override final String? description; + @override + final String? schemaExpressionOverride; + @override final bool isPrimitive; @@ -80,6 +83,7 @@ class MockFieldInfo implements FieldInfo { required this.isList, required this.isMap, this.description, + this.schemaExpressionOverride, this.isSet = false, this.isGeneric = false, this.isEnum = false, @@ -106,6 +110,59 @@ class MockFieldInfo implements FieldInfo { keyTypeName: mapKeyTypeName, valueTypeName: mapValueTypeName, ); + + @override + FieldInfo copyWith({ + String? name, + String? jsonKey, + DartType? type, + bool? isRequired, + bool? isNullable, + List? constraints, + String? description, + String? schemaExpressionOverride, + String? listElementSchemaRef, + String? nestedSchemaRef, + String? displayTypeOverride, + String? collectionElementDisplayTypeOverride, + String? collectionElementCastTypeOverride, + bool? collectionElementIsCustomType, + String? nestedSchemaCastTypeOverride, + }) { + return MockFieldInfo( + name: name ?? this.name, + typeName: type?.getDisplayString(withNullability: false) ?? typeName, + isRequired: isRequired ?? this.isRequired, + isNullable: isNullable ?? this.isNullable, + constraints: constraints ?? this.constraints, + description: description ?? this.description, + schemaExpressionOverride: + schemaExpressionOverride ?? this.schemaExpressionOverride, + isSet: isSet, + isGeneric: isGeneric, + isEnum: isEnum, + enumValues: enumValues, + isPrimitive: isPrimitive, + isList: isList, + isMap: isMap, + listItemTypeName: listItemTypeName, + mapKeyTypeName: mapKeyTypeName, + mapValueTypeName: mapValueTypeName, + listElementSchemaRef: listElementSchemaRef ?? this.listElementSchemaRef, + nestedSchemaRef: nestedSchemaRef ?? this.nestedSchemaRef, + displayTypeOverride: displayTypeOverride ?? this.displayTypeOverride, + collectionElementDisplayTypeOverride: + collectionElementDisplayTypeOverride ?? + this.collectionElementDisplayTypeOverride, + collectionElementCastTypeOverride: + collectionElementCastTypeOverride ?? + this.collectionElementCastTypeOverride, + collectionElementIsCustomType: + collectionElementIsCustomType ?? this.collectionElementIsCustomType, + nestedSchemaCastTypeOverride: + nestedSchemaCastTypeOverride ?? this.nestedSchemaCastTypeOverride, + ); + } } // Mock DartType for testing diff --git a/packages/ack_generator/test/test_utils/test_assets.dart b/packages/ack_generator/test/test_utils/test_assets.dart index d4103a69..9a4d17dd 100644 --- a/packages/ack_generator/test/test_utils/test_assets.dart +++ b/packages/ack_generator/test/test_utils/test_assets.dart @@ -6,6 +6,7 @@ const metaAssets = { 'meta|lib/meta_meta.dart': ''' enum TargetKind { classType, + constructor, field, function, getter, @@ -28,35 +29,86 @@ const ackAnnotationsAsset = { 'ack_annotations|lib/ack_annotations.dart': ''' library ack_annotations; +export 'src/schemable.dart'; export 'src/ack_model.dart'; export 'src/ack_field.dart'; export 'src/ack_type.dart'; +export 'src/constraints.dart'; ''', - 'ack_annotations|lib/src/ack_model.dart': ''' + 'ack_annotations|lib/src/schemable.dart': ''' +import 'package:ack/ack.dart'; import 'package:meta/meta_meta.dart'; +enum CaseStyle { + none, + camelCase, + pascalCase, + snakeCase, + paramCase, +} + @Target({TargetKind.classType}) -class AckModel { +class Schemable { final String? schemaName; final String? description; final bool additionalProperties; final String? additionalPropertiesField; - final bool model; - final String? discriminatedKey; - final String? discriminatedValue; + final String? discriminatorKey; + final String? discriminatorValue; + final CaseStyle caseStyle; + final List useProviders; - const AckModel({ + const Schemable({ this.schemaName, this.description, this.additionalProperties = false, this.additionalPropertiesField, - this.model = false, - this.discriminatedKey, - this.discriminatedValue, - }) : assert( - discriminatedKey == null || discriminatedValue == null, - 'discriminatedKey and discriminatedValue cannot be used together', - ); + this.discriminatorKey, + this.discriminatorValue, + this.caseStyle = CaseStyle.none, + this.useProviders = const [], + }); +} + +@Target({TargetKind.constructor}) +class SchemaConstructor { + const SchemaConstructor(); +} + +@Target({TargetKind.parameter}) +class SchemaKey { + final String name; + const SchemaKey(this.name); +} + +@Target({TargetKind.parameter}) +class Description { + final String value; + const Description(this.value); +} + +abstract interface class SchemaProvider { + const SchemaProvider(); + + AckSchema get schema; +} +''', + 'ack_annotations|lib/src/ack_model.dart': ''' +import 'package:meta/meta_meta.dart'; +import 'schemable.dart'; + +@Target({TargetKind.classType}) +class AckModel extends Schemable { + const AckModel({ + super.schemaName, + super.description, + super.additionalProperties = false, + super.additionalPropertiesField, + super.discriminatorKey, + super.discriminatorValue, + super.caseStyle = CaseStyle.none, + super.useProviders = const [], + }); } ''', 'ack_annotations|lib/src/ack_field.dart': ''' @@ -82,6 +134,78 @@ class AckField { this.constraints = const [], }); } +''', + 'ack_annotations|lib/src/constraints.dart': ''' +import 'package:meta/meta_meta.dart'; + +@Target({TargetKind.parameter}) +class MinLength { + final int length; + const MinLength(this.length); +} + +@Target({TargetKind.parameter}) +class MaxLength { + final int length; + const MaxLength(this.length); +} + +@Target({TargetKind.parameter}) +class Email { + const Email(); +} + +@Target({TargetKind.parameter}) +class Url { + const Url(); +} + +@Target({TargetKind.parameter}) +class Pattern { + final String pattern; + const Pattern(this.pattern); +} + +@Target({TargetKind.parameter}) +class Min { + final num value; + const Min(this.value); +} + +@Target({TargetKind.parameter}) +class Max { + final num value; + const Max(this.value); +} + +@Target({TargetKind.parameter}) +class Positive { + const Positive(); +} + +@Target({TargetKind.parameter}) +class MultipleOf { + final num value; + const MultipleOf(this.value); +} + +@Target({TargetKind.parameter}) +class MinItems { + final int count; + const MinItems(this.count); +} + +@Target({TargetKind.parameter}) +class MaxItems { + final int count; + const MaxItems(this.count); +} + +@Target({TargetKind.parameter}) +class EnumString { + final List values; + const EnumString(this.values); +} ''', 'ack_annotations|lib/src/ack_type.dart': ''' import 'package:meta/meta_meta.dart'; @@ -145,16 +269,18 @@ class Ack { } abstract class AckSchema { - TransformedSchema transform(R Function(T value) transformer) => - TransformedSchema(this); + AckSchema nullable() => this; + AckSchema optional() => this; + AckSchema describe(String description) => this; + TransformedSchema transform( + R Function(T value) transformer, + ) => TransformedSchema(this); Map toJsonSchema(); } -class TransformedSchema extends AckSchema { - final AckSchema schema; +class TransformedSchema extends AckSchema { + final AckSchema schema; + TransformedSchema(this.schema); - TransformedSchema nullable() => this; - TransformedSchema optional() => this; - TransformedSchema describe(String description) => this; @override Map toJsonSchema() => schema.toJsonSchema(); @@ -165,6 +291,7 @@ class StringSchema extends AckSchema { StringSchema notEmpty() => this; StringSchema minLength(int length) => this; StringSchema maxLength(int length) => this; + StringSchema matches(Object pattern) => this; StringSchema enumString(List values) => this; StringSchema uri() => this; StringSchema date() => this; @@ -182,6 +309,7 @@ class IntegerSchema extends AckSchema { IntegerSchema min(int value) => this; IntegerSchema max(int value) => this; IntegerSchema positive() => this; + IntegerSchema multipleOf(num value) => this; IntegerSchema nullable() => this; IntegerSchema optional() => this; IntegerSchema describe(String description) => this; @@ -227,6 +355,8 @@ class AnySchema extends AckSchema { class ListSchema extends AckSchema> { final AckSchema itemSchema; const ListSchema(this.itemSchema); + ListSchema minItems(int count) => this; + ListSchema maxItems(int count) => this; ListSchema nullable() => this; ListSchema optional() => this; ListSchema unique() => this;