[{"data":1,"prerenderedAt":5188},["ShallowReactive",2],{"content:\u002F10-databases\u002F01-sql-relational-model":3},{"title":4,"description":5,"path":6,"body":7},"SQL и реляционная модель","Реляционная база данных хранит данные в виде отношений. В повседневной разработке мы почти всегда говорим \"таблица\", \"строка\" и \"колонка\", но полезно помнить исходную идею:","\u002F10-databases\u002F01-sql-relational-model",{"type":8,"value":9,"toc":5143},"minimark",[10,14,17,129,132,141,144,147,152,167,389,392,446,449,521,524,526,530,535,542,644,647,722,732,734,738,741,849,852,1048,1051,1053,1057,1061,1064,1116,1119,1123,1126,1211,1215,1218,1267,1275,1277,1281,1285,1288,1387,1393,1395,1399,1402,1502,1505,1550,1553,1596,1598,1601,1604,1695,1698,1767,1772,1845,1856,2020,2022,2026,2031,2053,2145,2153,2156,2162,2286,2288,2292,2295,2464,2467,2470,2651,2653,2657,2660,2775,2778,2811,2814,2828,2831,2840,2843,2845,2849,2855,2933,2936,2956,2959,3070,3073,3146,3153,3230,3232,3236,3244,3247,3334,3347,3416,3419,3459,3472,3538,3554,3556,3560,3567,3612,3615,3684,3697,3700,3786,3792,3794,3798,3801,3820,3827,3830,3879,3890,3893,4012,4019,4023,4026,4088,4097,4100,4102,4106,4194,4197,4416,4419,4490,4493,4582,4584,4588,4598,4607,4666,4673,4683,4692,4701,4709,4721,4725,4728,4732,4735,4739,4745,4749,4758,4760,4764,4767,4826,4828,4832,4874,4876,4880,4913,4915,4919,4973,5100,5102,5106,5139],[11,12,4],"h1",{"id":13},"sql-и-реляционная-модель",[15,16,5],"p",{},[18,19,20,37],"table",{},[21,22,23],"thead",{},[24,25,26,31,34],"tr",{},[27,28,30],"th",{"align":29},"left","Реляционный термин",[27,32,33],{"align":29},"Практический термин",[27,35,36],{"align":29},"Смысл",[38,39,40,52,63,74,96,107,118],"tbody",{},[24,41,42,46,49],{},[43,44,45],"td",{"align":29},"Relation",[43,47,48],{"align":29},"Таблица",[43,50,51],{"align":29},"Набор строк одинаковой структуры",[24,53,54,57,60],{},[43,55,56],{"align":29},"Tuple",[43,58,59],{"align":29},"Строка",[43,61,62],{"align":29},"Один факт или один экземпляр сущности",[24,64,65,68,71],{},[43,66,67],{"align":29},"Attribute",[43,69,70],{"align":29},"Колонка",[43,72,73],{"align":29},"Именованное свойство строки",[24,75,76,79,82],{},[43,77,78],{"align":29},"Domain",[43,80,81],{"align":29},"Тип и допустимые значения",[43,83,84,85,89,90,89,93],{"align":29},"Например, ",[86,87,88],"code",{},"integer",", ",[86,91,92],{},"text",[86,94,95],{},"timestamp with time zone",[24,97,98,101,104],{},[43,99,100],{"align":29},"Candidate key",[43,102,103],{"align":29},"Возможный ключ",[43,105,106],{"align":29},"Набор колонок, уникально определяющий строку",[24,108,109,112,115],{},[43,110,111],{"align":29},"Primary key",[43,113,114],{"align":29},"Первичный ключ",[43,116,117],{"align":29},"Выбранный основной идентификатор строки",[24,119,120,123,126],{},[43,121,122],{"align":29},"Foreign key",[43,124,125],{"align":29},"Внешний ключ",[43,127,128],{"align":29},"Ссылка на ключ в другой таблице",[15,130,131],{},"Главная сила SQL не в том, что он \"похож на английский\". SQL декларативный: вы описываете, какой результат нужен, а оптимизатор решает, как его получить. Для backend-разработчика это меняет мышление: хороший SQL часто короче и быстрее цикла в Go, потому что база умеет фильтровать, соединять, группировать и сортировать рядом с данными.",[133,134,139],"pre",{"className":135,"code":137,"language":92,"meta":138},[136],"language-text","Go handler\n  |\n  |  SQL: \"дай оплаченные заказы пользователя с суммой\"\n  v\nPostgreSQL optimizer -> plan -> indexes\u002Fscans\u002Fjoins\u002Faggregates\n","",[86,140,137],{"__ignoreMap":138},[15,142,143],{},"В этом уроке примеры ориентированы на PostgreSQL, но базовые идеи SQL и реляционной модели применимы к большинству реляционных СУБД.",[145,146],"hr",{},[148,149,151],"h2",{"id":150},"таблица-как-набор-фактов","Таблица как набор фактов",[15,153,154,155,158,159,162,163,166],{},"Таблица должна описывать один тип фактов. Например, ",[86,156,157],{},"users"," хранит пользователей, ",[86,160,161],{},"orders"," хранит заказы, ",[86,164,165],{},"order_items"," хранит строки заказа.",[133,168,172],{"className":169,"code":170,"language":171,"meta":138,"style":138},"language-sql shiki shiki-themes github-dark","CREATE TABLE users (\n    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n    email       text NOT NULL UNIQUE,\n    name        text NOT NULL,\n    created_at  timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE TABLE orders (\n    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n    user_id     bigint NOT NULL REFERENCES users(id),\n    status      text NOT NULL,\n    total_cents integer NOT NULL CHECK (total_cents >= 0),\n    created_at  timestamptz NOT NULL DEFAULT now()\n);\n","sql",[86,173,174,194,221,237,250,270,276,283,295,314,330,343,369,384],{"__ignoreMap":138},[175,176,179,183,186,190],"span",{"class":177,"line":178},"line",1,[175,180,182],{"class":181},"snl16","CREATE",[175,184,185],{"class":181}," TABLE",[175,187,189],{"class":188},"svObZ"," users",[175,191,193],{"class":192},"s95oV"," (\n",[175,195,197,200,203,206,209,212,215,218],{"class":177,"line":196},2,[175,198,199],{"class":192},"    id          ",[175,201,202],{"class":181},"bigint",[175,204,205],{"class":181}," GENERATED",[175,207,208],{"class":181}," ALWAYS",[175,210,211],{"class":181}," AS",[175,213,214],{"class":181}," IDENTITY",[175,216,217],{"class":181}," PRIMARY KEY",[175,219,220],{"class":192},",\n",[175,222,224,227,229,232,235],{"class":177,"line":223},3,[175,225,226],{"class":192},"    email       ",[175,228,92],{"class":181},[175,230,231],{"class":181}," NOT NULL",[175,233,234],{"class":181}," UNIQUE",[175,236,220],{"class":192},[175,238,240,243,246,248],{"class":177,"line":239},4,[175,241,242],{"class":181},"    name",[175,244,245],{"class":181},"        text",[175,247,231],{"class":181},[175,249,220],{"class":192},[175,251,253,256,259,261,264,267],{"class":177,"line":252},5,[175,254,255],{"class":192},"    created_at  ",[175,257,258],{"class":181},"timestamptz",[175,260,231],{"class":181},[175,262,263],{"class":181}," DEFAULT",[175,265,266],{"class":181}," now",[175,268,269],{"class":192},"()\n",[175,271,273],{"class":177,"line":272},6,[175,274,275],{"class":192},");\n",[175,277,279],{"class":177,"line":278},7,[175,280,282],{"emptyLinePlaceholder":281},true,"\n",[175,284,286,288,290,293],{"class":177,"line":285},8,[175,287,182],{"class":181},[175,289,185],{"class":181},[175,291,292],{"class":188}," orders",[175,294,193],{"class":192},[175,296,298,300,302,304,306,308,310,312],{"class":177,"line":297},9,[175,299,199],{"class":192},[175,301,202],{"class":181},[175,303,205],{"class":181},[175,305,208],{"class":181},[175,307,211],{"class":181},[175,309,214],{"class":181},[175,311,217],{"class":181},[175,313,220],{"class":192},[175,315,317,320,322,324,327],{"class":177,"line":316},10,[175,318,319],{"class":192},"    user_id     ",[175,321,202],{"class":181},[175,323,231],{"class":181},[175,325,326],{"class":181}," REFERENCES",[175,328,329],{"class":192}," users(id),\n",[175,331,333,336,339,341],{"class":177,"line":332},11,[175,334,335],{"class":181},"    status",[175,337,338],{"class":181},"      text",[175,340,231],{"class":181},[175,342,220],{"class":192},[175,344,346,349,351,353,356,359,362,366],{"class":177,"line":345},12,[175,347,348],{"class":192},"    total_cents ",[175,350,88],{"class":181},[175,352,231],{"class":181},[175,354,355],{"class":181}," CHECK",[175,357,358],{"class":192}," (total_cents ",[175,360,361],{"class":181},">=",[175,363,365],{"class":364},"sDLfK"," 0",[175,367,368],{"class":192},"),\n",[175,370,372,374,376,378,380,382],{"class":177,"line":371},13,[175,373,255],{"class":192},[175,375,258],{"class":181},[175,377,231],{"class":181},[175,379,263],{"class":181},[175,381,266],{"class":181},[175,383,269],{"class":192},[175,385,387],{"class":177,"line":386},14,[175,388,275],{"class":192},[15,390,391],{},"Здесь уже есть несколько важных решений:",[393,394,395,406,412,418,427,437],"ul",{},[396,397,398,401,402,405],"li",{},[86,399,400],{},"users.id"," и ",[86,403,404],{},"orders.id"," - surrogate keys, технические идентификаторы;",[396,407,408,411],{},[86,409,410],{},"users.email"," - уникальный бизнес-атрибут, но не primary key;",[396,413,414,417],{},[86,415,416],{},"orders.user_id"," - внешний ключ на владельца заказа;",[396,419,420,423,424,426],{},[86,421,422],{},"status"," пока ",[86,425,92],{},", но ограничение допустимых статусов стоит добавить;",[396,428,429,432,433,436],{},[86,430,431],{},"total_cents"," хранится целым числом, чтобы не ловить ошибки округления денег в ",[86,434,435],{},"float",";",[396,438,439,442,443,445],{},[86,440,441],{},"created_at"," хранится как ",[86,444,258],{},", чтобы момент времени был однозначным.",[15,447,448],{},"В Go-коде похожая модель обычно превращается в repository-методы:",[133,450,455],{"className":451,"code":452,"language":453,"meta":454,"style":138},"language-go shiki shiki-themes github-dark","type Order struct {\n    ID         int64\n    UserID     int64\n    Status     string\n    TotalCents int\n    CreatedAt  time.Time\n}\n","go","no-run",[86,456,457,471,479,486,494,502,516],{"__ignoreMap":138},[175,458,459,462,465,468],{"class":177,"line":178},[175,460,461],{"class":181},"type",[175,463,464],{"class":188}," Order",[175,466,467],{"class":181}," struct",[175,469,470],{"class":192}," {\n",[175,472,473,476],{"class":177,"line":196},[175,474,475],{"class":192},"    ID         ",[175,477,478],{"class":181},"int64\n",[175,480,481,484],{"class":177,"line":223},[175,482,483],{"class":192},"    UserID     ",[175,485,478],{"class":181},[175,487,488,491],{"class":177,"line":239},[175,489,490],{"class":192},"    Status     ",[175,492,493],{"class":181},"string\n",[175,495,496,499],{"class":177,"line":252},[175,497,498],{"class":192},"    TotalCents ",[175,500,501],{"class":181},"int\n",[175,503,504,507,510,513],{"class":177,"line":272},[175,505,506],{"class":192},"    CreatedAt  ",[175,508,509],{"class":188},"time",[175,511,512],{"class":192},".",[175,514,515],{"class":188},"Time\n",[175,517,518],{"class":177,"line":278},[175,519,520],{"class":192},"}\n",[15,522,523],{},"Но база данных не просто \"сериализует struct\". Она проверяет инварианты, поддерживает связи, выполняет конкурентный доступ и строит планы запросов.",[145,525],{},[148,527,529],{"id":528},"схема-ключи-и-связи","Схема, ключи и связи",[531,532,534],"h3",{"id":533},"ключи","Ключи",[15,536,537,538,541],{},"Ключ - это не обязательно одна колонка ",[86,539,540],{},"id",". Ключ - это минимальный набор атрибутов, который уникально определяет строку.",[18,543,544,557],{},[21,545,546],{},[24,547,548,551,554],{},[27,549,550],{"align":29},"Вид ключа",[27,552,553],{"align":29},"Пример",[27,555,556],{"align":29},"Для чего нужен",[38,558,559,571,584,597,609,628],{},[24,560,561,563,568],{},[43,562,111],{"align":29},[43,564,565],{"align":29},[86,566,567],{},"users(id)",[43,569,570],{"align":29},"Главный идентификатор строки",[24,572,573,576,581],{},[43,574,575],{"align":29},"Unique key",[43,577,578],{"align":29},[86,579,580],{},"users(email)",[43,582,583],{"align":29},"Бизнес-уникальность",[24,585,586,589,594],{},[43,587,588],{"align":29},"Composite key",[43,590,591],{"align":29},[86,592,593],{},"(order_id, product_id)",[43,595,596],{"align":29},"Уникальность пары или группы колонок",[24,598,599,601,606],{},[43,600,122],{"align":29},[43,602,603],{"align":29},[86,604,605],{},"orders(user_id) -> users(id)",[43,607,608],{"align":29},"Ссылочная целостность",[24,610,611,614,625],{},[43,612,613],{"align":29},"Natural key",[43,615,616,89,619,89,622],{"align":29},[86,617,618],{},"country_code",[86,620,621],{},"email",[86,623,624],{},"tax_id",[43,626,627],{"align":29},"Идентификатор из предметной области",[24,629,630,633,641],{},[43,631,632],{"align":29},"Surrogate key",[43,634,635,89,638],{"align":29},[86,636,637],{},"bigint identity",[86,639,640],{},"uuid",[43,642,643],{"align":29},"Технический идентификатор",[15,645,646],{},"Composite key часто нужен в таблицах связей:",[133,648,650],{"className":169,"code":649,"language":171,"meta":138,"style":138},"CREATE TABLE course_enrollments (\n    course_id bigint NOT NULL REFERENCES courses(id),\n    user_id   bigint NOT NULL REFERENCES users(id),\n    joined_at timestamptz NOT NULL DEFAULT now(),\n\n    PRIMARY KEY (course_id, user_id)\n);\n",[86,651,652,663,677,690,706,710,718],{"__ignoreMap":138},[175,653,654,656,658,661],{"class":177,"line":178},[175,655,182],{"class":181},[175,657,185],{"class":181},[175,659,660],{"class":188}," course_enrollments",[175,662,193],{"class":192},[175,664,665,668,670,672,674],{"class":177,"line":196},[175,666,667],{"class":192},"    course_id ",[175,669,202],{"class":181},[175,671,231],{"class":181},[175,673,326],{"class":181},[175,675,676],{"class":192}," courses(id),\n",[175,678,679,682,684,686,688],{"class":177,"line":223},[175,680,681],{"class":192},"    user_id   ",[175,683,202],{"class":181},[175,685,231],{"class":181},[175,687,326],{"class":181},[175,689,329],{"class":192},[175,691,692,695,697,699,701,703],{"class":177,"line":239},[175,693,694],{"class":192},"    joined_at ",[175,696,258],{"class":181},[175,698,231],{"class":181},[175,700,263],{"class":181},[175,702,266],{"class":181},[175,704,705],{"class":192},"(),\n",[175,707,708],{"class":177,"line":252},[175,709,282],{"emptyLinePlaceholder":281},[175,711,712,715],{"class":177,"line":272},[175,713,714],{"class":181},"    PRIMARY KEY",[175,716,717],{"class":192}," (course_id, user_id)\n",[175,719,720],{"class":177,"line":278},[175,721,275],{"class":192},[15,723,724,725,727,728,731],{},"Так база сама запретит дважды записать одного пользователя на один курс. Если вместо этого добавить отдельный ",[86,726,540],{},", но забыть ",[86,729,730],{},"UNIQUE (course_id, user_id)",", вы получите дубли и будете чинить их уже в production.",[145,733],{},[531,735,737],{"id":736},"constraints-правила-рядом-с-данными","Constraints: правила рядом с данными",[15,739,740],{},"Ограничения нужны, чтобы база не принимала невозможные состояния.",[18,742,743,755],{},[21,744,745],{},[24,746,747,750,752],{},[27,748,749],{"align":29},"Constraint",[27,751,553],{"align":29},[27,753,754],{"align":29},"Что защищает",[38,756,757,772,787,804,819,834],{},[24,758,759,764,769],{},[43,760,761],{"align":29},[86,762,763],{},"NOT NULL",[43,765,766],{"align":29},[86,767,768],{},"email text NOT NULL",[43,770,771],{"align":29},"Поле обязательно",[24,773,774,779,784],{},[43,775,776],{"align":29},[86,777,778],{},"UNIQUE",[43,780,781],{"align":29},[86,782,783],{},"UNIQUE (email)",[43,785,786],{"align":29},"Значение или комбинация значений уникальна",[24,788,789,794,799],{},[43,790,791],{"align":29},[86,792,793],{},"PRIMARY KEY",[43,795,796],{"align":29},[86,797,798],{},"PRIMARY KEY (id)",[43,800,801,803],{"align":29},[86,802,763],{}," + уникальность + идентификатор",[24,805,806,811,816],{},[43,807,808],{"align":29},[86,809,810],{},"FOREIGN KEY",[43,812,813],{"align":29},[86,814,815],{},"REFERENCES users(id)",[43,817,818],{"align":29},"Ссылка указывает на существующую строку",[24,820,821,826,831],{},[43,822,823],{"align":29},[86,824,825],{},"CHECK",[43,827,828],{"align":29},[86,829,830],{},"CHECK (price_cents >= 0)",[43,832,833],{"align":29},"Простое бизнес-условие",[24,835,836,841,846],{},[43,837,838],{"align":29},[86,839,840],{},"DEFAULT",[43,842,843],{"align":29},[86,844,845],{},"created_at DEFAULT now()",[43,847,848],{"align":29},"Значение по умолчанию",[15,850,851],{},"Пример более строгой таблицы заказов:",[133,853,855],{"className":169,"code":854,"language":171,"meta":138,"style":138},"CREATE TABLE orders (\n    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n    user_id     bigint NOT NULL REFERENCES users(id),\n    status      text NOT NULL CHECK (status IN ('draft', 'paid', 'cancelled')),\n    total_cents integer NOT NULL CHECK (total_cents >= 0),\n    created_at  timestamptz NOT NULL DEFAULT now(),\n    paid_at     timestamptz,\n\n    CHECK (\n        (status = 'paid' AND paid_at IS NOT NULL)\n        OR\n        (status \u003C> 'paid' AND paid_at IS NULL)\n    )\n);\n",[86,856,857,867,885,897,934,952,966,975,979,986,1011,1016,1039,1044],{"__ignoreMap":138},[175,858,859,861,863,865],{"class":177,"line":178},[175,860,182],{"class":181},[175,862,185],{"class":181},[175,864,292],{"class":188},[175,866,193],{"class":192},[175,868,869,871,873,875,877,879,881,883],{"class":177,"line":196},[175,870,199],{"class":192},[175,872,202],{"class":181},[175,874,205],{"class":181},[175,876,208],{"class":181},[175,878,211],{"class":181},[175,880,214],{"class":181},[175,882,217],{"class":181},[175,884,220],{"class":192},[175,886,887,889,891,893,895],{"class":177,"line":223},[175,888,319],{"class":192},[175,890,202],{"class":181},[175,892,231],{"class":181},[175,894,326],{"class":181},[175,896,329],{"class":192},[175,898,899,901,903,905,907,910,912,915,917,921,923,926,928,931],{"class":177,"line":239},[175,900,335],{"class":181},[175,902,338],{"class":181},[175,904,231],{"class":181},[175,906,355],{"class":181},[175,908,909],{"class":192}," (",[175,911,422],{"class":181},[175,913,914],{"class":181}," IN",[175,916,909],{"class":192},[175,918,920],{"class":919},"sU2Wk","'draft'",[175,922,89],{"class":192},[175,924,925],{"class":919},"'paid'",[175,927,89],{"class":192},[175,929,930],{"class":919},"'cancelled'",[175,932,933],{"class":192},")),\n",[175,935,936,938,940,942,944,946,948,950],{"class":177,"line":252},[175,937,348],{"class":192},[175,939,88],{"class":181},[175,941,231],{"class":181},[175,943,355],{"class":181},[175,945,358],{"class":192},[175,947,361],{"class":181},[175,949,365],{"class":364},[175,951,368],{"class":192},[175,953,954,956,958,960,962,964],{"class":177,"line":272},[175,955,255],{"class":192},[175,957,258],{"class":181},[175,959,231],{"class":181},[175,961,263],{"class":181},[175,963,266],{"class":181},[175,965,705],{"class":192},[175,967,968,971,973],{"class":177,"line":278},[175,969,970],{"class":192},"    paid_at     ",[175,972,258],{"class":181},[175,974,220],{"class":192},[175,976,977],{"class":177,"line":285},[175,978,282],{"emptyLinePlaceholder":281},[175,980,981,984],{"class":177,"line":297},[175,982,983],{"class":181},"    CHECK",[175,985,193],{"class":192},[175,987,988,991,993,996,999,1002,1005,1008],{"class":177,"line":316},[175,989,990],{"class":192},"        (",[175,992,422],{"class":181},[175,994,995],{"class":181}," =",[175,997,998],{"class":919}," 'paid'",[175,1000,1001],{"class":181}," AND",[175,1003,1004],{"class":192}," paid_at ",[175,1006,1007],{"class":181},"IS NOT NULL",[175,1009,1010],{"class":192},")\n",[175,1012,1013],{"class":177,"line":332},[175,1014,1015],{"class":181},"        OR\n",[175,1017,1018,1020,1022,1025,1027,1029,1031,1034,1037],{"class":177,"line":345},[175,1019,990],{"class":192},[175,1021,422],{"class":181},[175,1023,1024],{"class":181}," \u003C>",[175,1026,998],{"class":919},[175,1028,1001],{"class":181},[175,1030,1004],{"class":192},[175,1032,1033],{"class":181},"IS",[175,1035,1036],{"class":181}," NULL",[175,1038,1010],{"class":192},[175,1040,1041],{"class":177,"line":371},[175,1042,1043],{"class":192},"    )\n",[175,1045,1046],{"class":177,"line":386},[175,1047,275],{"class":192},[15,1049,1050],{},"Не всё нужно класть в constraints. Сложное доменное правило лучше жить в доменном слое. Но простые инварианты вроде \"цена не отрицательная\", \"email уникален\", \"заказ ссылается на существующего пользователя\" должны быть в базе. Иначе один забытый handler, cron job или SQL-скрипт сможет испортить данные.",[145,1052],{},[531,1054,1056],{"id":1055},"связи-между-таблицами","Связи между таблицами",[531,1058,1060],{"id":1059},"one-to-many","One-to-many",[15,1062,1063],{},"Один пользователь имеет много заказов:",[133,1065,1067],{"className":169,"code":1066,"language":171,"meta":138,"style":138},"CREATE TABLE orders (\n    id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n    user_id bigint NOT NULL REFERENCES users(id)\n);\n",[86,1068,1069,1079,1098,1112],{"__ignoreMap":138},[175,1070,1071,1073,1075,1077],{"class":177,"line":178},[175,1072,182],{"class":181},[175,1074,185],{"class":181},[175,1076,292],{"class":188},[175,1078,193],{"class":192},[175,1080,1081,1084,1086,1088,1090,1092,1094,1096],{"class":177,"line":196},[175,1082,1083],{"class":192},"    id      ",[175,1085,202],{"class":181},[175,1087,205],{"class":181},[175,1089,208],{"class":181},[175,1091,211],{"class":181},[175,1093,214],{"class":181},[175,1095,217],{"class":181},[175,1097,220],{"class":192},[175,1099,1100,1103,1105,1107,1109],{"class":177,"line":223},[175,1101,1102],{"class":192},"    user_id ",[175,1104,202],{"class":181},[175,1106,231],{"class":181},[175,1108,326],{"class":181},[175,1110,1111],{"class":192}," users(id)\n",[175,1113,1114],{"class":177,"line":239},[175,1115,275],{"class":192},[15,1117,1118],{},"Внешний ключ лежит на стороне \"many\".",[531,1120,1122],{"id":1121},"many-to-many","Many-to-many",[15,1124,1125],{},"Пользователь может быть записан на много курсов, курс содержит много пользователей. Нужна таблица связей:",[133,1127,1129],{"className":169,"code":1128,"language":171,"meta":138,"style":138},"CREATE TABLE enrollments (\n    user_id   bigint NOT NULL REFERENCES users(id),\n    course_id bigint NOT NULL REFERENCES courses(id),\n    role      text NOT NULL DEFAULT 'student',\n    joined_at timestamptz NOT NULL DEFAULT now(),\n\n    PRIMARY KEY (user_id, course_id)\n);\n",[86,1130,1131,1142,1154,1166,1182,1196,1200,1207],{"__ignoreMap":138},[175,1132,1133,1135,1137,1140],{"class":177,"line":178},[175,1134,182],{"class":181},[175,1136,185],{"class":181},[175,1138,1139],{"class":188}," enrollments",[175,1141,193],{"class":192},[175,1143,1144,1146,1148,1150,1152],{"class":177,"line":196},[175,1145,681],{"class":192},[175,1147,202],{"class":181},[175,1149,231],{"class":181},[175,1151,326],{"class":181},[175,1153,329],{"class":192},[175,1155,1156,1158,1160,1162,1164],{"class":177,"line":223},[175,1157,667],{"class":192},[175,1159,202],{"class":181},[175,1161,231],{"class":181},[175,1163,326],{"class":181},[175,1165,676],{"class":192},[175,1167,1168,1171,1173,1175,1177,1180],{"class":177,"line":239},[175,1169,1170],{"class":181},"    role",[175,1172,338],{"class":181},[175,1174,231],{"class":181},[175,1176,263],{"class":181},[175,1178,1179],{"class":919}," 'student'",[175,1181,220],{"class":192},[175,1183,1184,1186,1188,1190,1192,1194],{"class":177,"line":252},[175,1185,694],{"class":192},[175,1187,258],{"class":181},[175,1189,231],{"class":181},[175,1191,263],{"class":181},[175,1193,266],{"class":181},[175,1195,705],{"class":192},[175,1197,1198],{"class":177,"line":272},[175,1199,282],{"emptyLinePlaceholder":281},[175,1201,1202,1204],{"class":177,"line":278},[175,1203,714],{"class":181},[175,1205,1206],{"class":192}," (user_id, course_id)\n",[175,1208,1209],{"class":177,"line":285},[175,1210,275],{"class":192},[531,1212,1214],{"id":1213},"one-to-one","One-to-one",[15,1216,1217],{},"Один пользователь имеет один профиль:",[133,1219,1221],{"className":169,"code":1220,"language":171,"meta":138,"style":138},"CREATE TABLE user_profiles (\n    user_id bigint PRIMARY KEY REFERENCES users(id),\n    bio     text,\n    avatar_url text\n);\n",[86,1222,1223,1234,1246,1255,1263],{"__ignoreMap":138},[175,1224,1225,1227,1229,1232],{"class":177,"line":178},[175,1226,182],{"class":181},[175,1228,185],{"class":181},[175,1230,1231],{"class":188}," user_profiles",[175,1233,193],{"class":192},[175,1235,1236,1238,1240,1242,1244],{"class":177,"line":196},[175,1237,1102],{"class":192},[175,1239,202],{"class":181},[175,1241,217],{"class":181},[175,1243,326],{"class":181},[175,1245,329],{"class":192},[175,1247,1248,1251,1253],{"class":177,"line":223},[175,1249,1250],{"class":192},"    bio     ",[175,1252,92],{"class":181},[175,1254,220],{"class":192},[175,1256,1257,1260],{"class":177,"line":239},[175,1258,1259],{"class":192},"    avatar_url ",[175,1261,1262],{"class":181},"text\n",[175,1264,1265],{"class":177,"line":252},[175,1266,275],{"class":192},[15,1268,1269,1272,1273,512],{},[86,1270,1271],{},"PRIMARY KEY (user_id)"," одновременно делает связь уникальной и хранит ссылку на ",[86,1274,157],{},[145,1276],{},[148,1278,1280],{"id":1279},"sql-запросы","SQL-запросы",[531,1282,1284],{"id":1283},"ddl-dml-dcl-tcl","DDL, DML, DCL, TCL",[15,1286,1287],{},"SQL-команды удобно группировать по назначению:",[18,1289,1290,1303],{},[21,1291,1292],{},[24,1293,1294,1297,1300],{},[27,1295,1296],{"align":29},"Группа",[27,1298,1299],{"align":29},"Что делает",[27,1301,1302],{"align":29},"Примеры",[38,1304,1305,1324,1349,1365],{},[24,1306,1307,1310,1313],{},[43,1308,1309],{"align":29},"DDL",[43,1311,1312],{"align":29},"Меняет структуру базы",[43,1314,1315,89,1318,89,1321],{"align":29},[86,1316,1317],{},"CREATE TABLE",[86,1319,1320],{},"ALTER TABLE",[86,1322,1323],{},"DROP INDEX",[24,1325,1326,1329,1332],{},[43,1327,1328],{"align":29},"DML",[43,1330,1331],{"align":29},"Работает с данными",[43,1333,1334,89,1337,89,1340,89,1343,89,1346],{"align":29},[86,1335,1336],{},"SELECT",[86,1338,1339],{},"INSERT",[86,1341,1342],{},"UPDATE",[86,1344,1345],{},"DELETE",[86,1347,1348],{},"MERGE",[24,1350,1351,1354,1357],{},[43,1352,1353],{"align":29},"DCL",[43,1355,1356],{"align":29},"Управляет доступом",[43,1358,1359,89,1362],{"align":29},[86,1360,1361],{},"GRANT",[86,1363,1364],{},"REVOKE",[24,1366,1367,1370,1373],{},[43,1368,1369],{"align":29},"TCL",[43,1371,1372],{"align":29},"Управляет транзакциями",[43,1374,1375,89,1378,89,1381,89,1384],{"align":29},[86,1376,1377],{},"BEGIN",[86,1379,1380],{},"COMMIT",[86,1382,1383],{},"ROLLBACK",[86,1385,1386],{},"SAVEPOINT",[15,1388,1389,1390,1392],{},"Для backend-разработчика важно понимать цену DDL. ",[86,1391,1320],{}," может брать сильные блокировки, перестраивать таблицу или долго валидировать constraint. Поэтому миграции проектируют отдельно от бизнес-кода: маленькими шагами, с учетом rollback, совместимости старой и новой версии приложения.",[145,1394],{},[531,1396,1398],{"id":1397},"select-порядок-мышления","SELECT: порядок мышления",[15,1400,1401],{},"SQL-запрос читается сверху вниз, но логически обрабатывается иначе:",[133,1403,1405],{"className":169,"code":1404,"language":171,"meta":138,"style":138},"SELECT user_id, count(*) AS paid_orders\nFROM orders\nWHERE status = 'paid'\nGROUP BY user_id\nHAVING count(*) >= 3\nORDER BY paid_orders DESC\nLIMIT 10;\n",[86,1406,1407,1432,1440,1453,1461,1480,1491],{"__ignoreMap":138},[175,1408,1409,1411,1414,1417,1420,1423,1426,1429],{"class":177,"line":178},[175,1410,1336],{"class":181},[175,1412,1413],{"class":192}," user_id, ",[175,1415,1416],{"class":364},"count",[175,1418,1419],{"class":192},"(",[175,1421,1422],{"class":181},"*",[175,1424,1425],{"class":192},") ",[175,1427,1428],{"class":181},"AS",[175,1430,1431],{"class":192}," paid_orders\n",[175,1433,1434,1437],{"class":177,"line":196},[175,1435,1436],{"class":181},"FROM",[175,1438,1439],{"class":192}," orders\n",[175,1441,1442,1445,1448,1450],{"class":177,"line":223},[175,1443,1444],{"class":181},"WHERE",[175,1446,1447],{"class":181}," status",[175,1449,995],{"class":181},[175,1451,1452],{"class":919}," 'paid'\n",[175,1454,1455,1458],{"class":177,"line":239},[175,1456,1457],{"class":181},"GROUP BY",[175,1459,1460],{"class":192}," user_id\n",[175,1462,1463,1466,1469,1471,1473,1475,1477],{"class":177,"line":252},[175,1464,1465],{"class":181},"HAVING",[175,1467,1468],{"class":364}," count",[175,1470,1419],{"class":192},[175,1472,1422],{"class":181},[175,1474,1425],{"class":192},[175,1476,361],{"class":181},[175,1478,1479],{"class":364}," 3\n",[175,1481,1482,1485,1488],{"class":177,"line":272},[175,1483,1484],{"class":181},"ORDER BY",[175,1486,1487],{"class":192}," paid_orders ",[175,1489,1490],{"class":181},"DESC\n",[175,1492,1493,1496,1499],{"class":177,"line":278},[175,1494,1495],{"class":181},"LIMIT",[175,1497,1498],{"class":364}," 10",[175,1500,1501],{"class":192},";\n",[15,1503,1504],{},"Упрощенный логический порядок:",[1506,1507,1508,1513,1519,1524,1529,1534,1539,1544],"ol",{},[396,1509,1510,1512],{},[86,1511,1436],{}," - откуда берем строки.",[396,1514,1515,1518],{},[86,1516,1517],{},"JOIN"," - как соединяем источники.",[396,1520,1521,1523],{},[86,1522,1444],{}," - какие строки оставляем до группировки.",[396,1525,1526,1528],{},[86,1527,1457],{}," - как собираем группы.",[396,1530,1531,1533],{},[86,1532,1465],{}," - какие группы оставляем после агрегации.",[396,1535,1536,1538],{},[86,1537,1336],{}," - какие выражения выводим.",[396,1540,1541,1543],{},[86,1542,1484],{}," - как сортируем.",[396,1545,1546,1549],{},[86,1547,1548],{},"LIMIT\u002FOFFSET"," - какую часть результата возвращаем.",[15,1551,1552],{},"Из этого следуют частые ошибки:",[393,1554,1555,1563,1575,1583],{},[396,1556,1557,1558,1560,1561,436],{},"агрегаты нельзя писать в ",[86,1559,1444],{},", для них нужен ",[86,1562,1465],{},[396,1564,1565,1566,1568,1569,1571,1572,1574],{},"alias из ",[86,1567,1336],{}," обычно недоступен в ",[86,1570,1444],{},", потому что ",[86,1573,1444],{}," логически раньше;",[396,1576,1577,1579,1580,1582],{},[86,1578,1495],{}," без стабильного ",[86,1581,1484],{}," не гарантирует повторяемый порядок;",[396,1584,1585,1588,1589,1592,1593,512],{},[86,1586,1587],{},"COUNT(*)"," считает строки, ",[86,1590,1591],{},"COUNT(column)"," считает только строки, где ",[86,1594,1595],{},"column IS NOT NULL",[145,1597],{},[531,1599,1517],{"id":1600},"join",[15,1602,1603],{},"JOIN соединяет строки из нескольких источников.",[133,1605,1607],{"className":169,"code":1606,"language":171,"meta":138,"style":138},"SELECT\n    o.id,\n    o.status,\n    u.email\nFROM orders o\nJOIN users u ON u.id = o.user_id\nWHERE o.status = 'paid';\n",[86,1608,1609,1614,1625,1635,1645,1652,1679],{"__ignoreMap":138},[175,1610,1611],{"class":177,"line":178},[175,1612,1613],{"class":181},"SELECT\n",[175,1615,1616,1619,1621,1623],{"class":177,"line":196},[175,1617,1618],{"class":364},"    o",[175,1620,512],{"class":192},[175,1622,540],{"class":364},[175,1624,220],{"class":192},[175,1626,1627,1629,1631,1633],{"class":177,"line":223},[175,1628,1618],{"class":364},[175,1630,512],{"class":192},[175,1632,422],{"class":364},[175,1634,220],{"class":192},[175,1636,1637,1640,1642],{"class":177,"line":239},[175,1638,1639],{"class":364},"    u",[175,1641,512],{"class":192},[175,1643,1644],{"class":364},"email\n",[175,1646,1647,1649],{"class":177,"line":252},[175,1648,1436],{"class":181},[175,1650,1651],{"class":192}," orders o\n",[175,1653,1654,1656,1659,1662,1665,1667,1669,1671,1674,1676],{"class":177,"line":272},[175,1655,1517],{"class":181},[175,1657,1658],{"class":192}," users u ",[175,1660,1661],{"class":181},"ON",[175,1663,1664],{"class":364}," u",[175,1666,512],{"class":192},[175,1668,540],{"class":364},[175,1670,995],{"class":181},[175,1672,1673],{"class":364}," o",[175,1675,512],{"class":192},[175,1677,1678],{"class":364},"user_id\n",[175,1680,1681,1683,1685,1687,1689,1691,1693],{"class":177,"line":278},[175,1682,1444],{"class":181},[175,1684,1673],{"class":364},[175,1686,512],{"class":192},[175,1688,422],{"class":364},[175,1690,995],{"class":181},[175,1692,998],{"class":919},[175,1694,1501],{"class":192},[15,1696,1697],{},"Основные виды:",[18,1699,1700,1709],{},[21,1701,1702],{},[24,1703,1704,1706],{},[27,1705,1517],{"align":29},[27,1707,1708],{"align":29},"Что возвращает",[38,1710,1711,1724,1737,1747,1757],{},[24,1712,1713,1721],{},[43,1714,1715,1718,1719],{"align":29},[86,1716,1717],{},"INNER JOIN"," или ",[86,1720,1517],{},[43,1722,1723],{"align":29},"Только строки, где есть совпадение с обеих сторон",[24,1725,1726,1731],{},[43,1727,1728],{"align":29},[86,1729,1730],{},"LEFT JOIN",[43,1732,1733,1734],{"align":29},"Все строки слева и совпадения справа, иначе ",[86,1735,1736],{},"NULL",[24,1738,1739,1744],{},[43,1740,1741],{"align":29},[86,1742,1743],{},"RIGHT JOIN",[43,1745,1746],{"align":29},"Все строки справа и совпадения слева",[24,1748,1749,1754],{},[43,1750,1751],{"align":29},[86,1752,1753],{},"FULL JOIN",[43,1755,1756],{"align":29},"Все строки с обеих сторон",[24,1758,1759,1764],{},[43,1760,1761],{"align":29},[86,1762,1763],{},"CROSS JOIN",[43,1765,1766],{"align":29},"Декартово произведение",[15,1768,1769,1771],{},[86,1770,1730],{}," полезен для поиска отсутствующих связей:",[133,1773,1775],{"className":169,"code":1774,"language":171,"meta":138,"style":138},"SELECT u.id, u.email\nFROM users u\nLEFT JOIN orders o ON o.user_id = u.id\nWHERE o.id IS NULL;\n",[86,1776,1777,1796,1803,1828],{"__ignoreMap":138},[175,1778,1779,1781,1783,1785,1787,1789,1792,1794],{"class":177,"line":178},[175,1780,1336],{"class":181},[175,1782,1664],{"class":364},[175,1784,512],{"class":192},[175,1786,540],{"class":364},[175,1788,89],{"class":192},[175,1790,1791],{"class":364},"u",[175,1793,512],{"class":192},[175,1795,1644],{"class":364},[175,1797,1798,1800],{"class":177,"line":196},[175,1799,1436],{"class":181},[175,1801,1802],{"class":192}," users u\n",[175,1804,1805,1807,1810,1812,1814,1816,1819,1821,1823,1825],{"class":177,"line":223},[175,1806,1730],{"class":181},[175,1808,1809],{"class":192}," orders o ",[175,1811,1661],{"class":181},[175,1813,1673],{"class":364},[175,1815,512],{"class":192},[175,1817,1818],{"class":364},"user_id",[175,1820,995],{"class":181},[175,1822,1664],{"class":364},[175,1824,512],{"class":192},[175,1826,1827],{"class":364},"id\n",[175,1829,1830,1832,1834,1836,1838,1841,1843],{"class":177,"line":239},[175,1831,1444],{"class":181},[175,1833,1673],{"class":364},[175,1835,512],{"class":192},[175,1837,540],{"class":364},[175,1839,1840],{"class":181}," IS",[175,1842,1036],{"class":181},[175,1844,1501],{"class":192},[15,1846,1847,1848,1850,1851,1853,1854,512],{},"Этот запрос находит пользователей без заказов. Важно: условие на правую таблицу в ",[86,1849,1444],{}," может случайно превратить ",[86,1852,1730],{}," в ",[86,1855,1717],{},[133,1857,1859],{"className":169,"code":1858,"language":171,"meta":138,"style":138},"-- Ошибка: пользователи без заказов исчезнут, потому что o.status = 'paid'\n-- для NULL-строки не true.\nSELECT u.id, u.email\nFROM users u\nLEFT JOIN orders o ON o.user_id = u.id\nWHERE o.status = 'paid';\n\n-- Правильнее, если нужны все пользователи и только paid-заказы справа.\nSELECT u.id, u.email, o.id AS paid_order_id\nFROM users u\nLEFT JOIN orders o\n    ON o.user_id = u.id AND o.status = 'paid';\n",[86,1860,1861,1867,1872,1890,1896,1918,1934,1938,1943,1975,1981,1987],{"__ignoreMap":138},[175,1862,1863],{"class":177,"line":178},[175,1864,1866],{"class":1865},"sAwPA","-- Ошибка: пользователи без заказов исчезнут, потому что o.status = 'paid'\n",[175,1868,1869],{"class":177,"line":196},[175,1870,1871],{"class":1865},"-- для NULL-строки не true.\n",[175,1873,1874,1876,1878,1880,1882,1884,1886,1888],{"class":177,"line":223},[175,1875,1336],{"class":181},[175,1877,1664],{"class":364},[175,1879,512],{"class":192},[175,1881,540],{"class":364},[175,1883,89],{"class":192},[175,1885,1791],{"class":364},[175,1887,512],{"class":192},[175,1889,1644],{"class":364},[175,1891,1892,1894],{"class":177,"line":239},[175,1893,1436],{"class":181},[175,1895,1802],{"class":192},[175,1897,1898,1900,1902,1904,1906,1908,1910,1912,1914,1916],{"class":177,"line":252},[175,1899,1730],{"class":181},[175,1901,1809],{"class":192},[175,1903,1661],{"class":181},[175,1905,1673],{"class":364},[175,1907,512],{"class":192},[175,1909,1818],{"class":364},[175,1911,995],{"class":181},[175,1913,1664],{"class":364},[175,1915,512],{"class":192},[175,1917,1827],{"class":364},[175,1919,1920,1922,1924,1926,1928,1930,1932],{"class":177,"line":272},[175,1921,1444],{"class":181},[175,1923,1673],{"class":364},[175,1925,512],{"class":192},[175,1927,422],{"class":364},[175,1929,995],{"class":181},[175,1931,998],{"class":919},[175,1933,1501],{"class":192},[175,1935,1936],{"class":177,"line":278},[175,1937,282],{"emptyLinePlaceholder":281},[175,1939,1940],{"class":177,"line":285},[175,1941,1942],{"class":1865},"-- Правильнее, если нужны все пользователи и только paid-заказы справа.\n",[175,1944,1945,1947,1949,1951,1953,1955,1957,1959,1961,1963,1966,1968,1970,1972],{"class":177,"line":297},[175,1946,1336],{"class":181},[175,1948,1664],{"class":364},[175,1950,512],{"class":192},[175,1952,540],{"class":364},[175,1954,89],{"class":192},[175,1956,1791],{"class":364},[175,1958,512],{"class":192},[175,1960,621],{"class":364},[175,1962,89],{"class":192},[175,1964,1965],{"class":364},"o",[175,1967,512],{"class":192},[175,1969,540],{"class":364},[175,1971,211],{"class":181},[175,1973,1974],{"class":192}," paid_order_id\n",[175,1976,1977,1979],{"class":177,"line":316},[175,1978,1436],{"class":181},[175,1980,1802],{"class":192},[175,1982,1983,1985],{"class":177,"line":332},[175,1984,1730],{"class":181},[175,1986,1651],{"class":192},[175,1988,1989,1992,1994,1996,1998,2000,2002,2004,2006,2008,2010,2012,2014,2016,2018],{"class":177,"line":345},[175,1990,1991],{"class":181},"    ON",[175,1993,1673],{"class":364},[175,1995,512],{"class":192},[175,1997,1818],{"class":364},[175,1999,995],{"class":181},[175,2001,1664],{"class":364},[175,2003,512],{"class":192},[175,2005,540],{"class":364},[175,2007,1001],{"class":181},[175,2009,1673],{"class":364},[175,2011,512],{"class":192},[175,2013,422],{"class":364},[175,2015,995],{"class":181},[175,2017,998],{"class":919},[175,2019,1501],{"class":192},[145,2021],{},[531,2023,2025],{"id":2024},"group-by-и-having","GROUP BY и HAVING",[15,2027,2028,2030],{},[86,2029,1457],{}," превращает строки в группы. После группировки можно выбирать:",[393,2032,2033,2038,2050],{},[396,2034,2035,2036,436],{},"колонки из ",[86,2037,1457],{},[396,2039,2040,2041,89,2044,89,2047,436],{},"агрегатные выражения вроде ",[86,2042,2043],{},"count(*)",[86,2045,2046],{},"sum(total_cents)",[86,2048,2049],{},"max(created_at)",[396,2051,2052],{},"выражения от этих значений.",[133,2054,2056],{"className":169,"code":2055,"language":171,"meta":138,"style":138},"SELECT\n    user_id,\n    count(*) AS orders_count,\n    sum(total_cents) AS revenue_cents\nFROM orders\nWHERE status = 'paid'\nGROUP BY user_id\nHAVING sum(total_cents) > 100000\nORDER BY revenue_cents DESC;\n",[86,2057,2058,2062,2067,2083,2096,2102,2112,2118,2133],{"__ignoreMap":138},[175,2059,2060],{"class":177,"line":178},[175,2061,1613],{"class":181},[175,2063,2064],{"class":177,"line":196},[175,2065,2066],{"class":192},"    user_id,\n",[175,2068,2069,2072,2074,2076,2078,2080],{"class":177,"line":223},[175,2070,2071],{"class":364},"    count",[175,2073,1419],{"class":192},[175,2075,1422],{"class":181},[175,2077,1425],{"class":192},[175,2079,1428],{"class":181},[175,2081,2082],{"class":192}," orders_count,\n",[175,2084,2085,2088,2091,2093],{"class":177,"line":239},[175,2086,2087],{"class":364},"    sum",[175,2089,2090],{"class":192},"(total_cents) ",[175,2092,1428],{"class":181},[175,2094,2095],{"class":192}," revenue_cents\n",[175,2097,2098,2100],{"class":177,"line":252},[175,2099,1436],{"class":181},[175,2101,1439],{"class":192},[175,2103,2104,2106,2108,2110],{"class":177,"line":272},[175,2105,1444],{"class":181},[175,2107,1447],{"class":181},[175,2109,995],{"class":181},[175,2111,1452],{"class":919},[175,2113,2114,2116],{"class":177,"line":278},[175,2115,1457],{"class":181},[175,2117,1460],{"class":192},[175,2119,2120,2122,2125,2127,2130],{"class":177,"line":285},[175,2121,1465],{"class":181},[175,2123,2124],{"class":364}," sum",[175,2126,2090],{"class":192},[175,2128,2129],{"class":181},">",[175,2131,2132],{"class":364}," 100000\n",[175,2134,2135,2137,2140,2143],{"class":177,"line":297},[175,2136,1484],{"class":181},[175,2138,2139],{"class":192}," revenue_cents ",[175,2141,2142],{"class":181},"DESC",[175,2144,1501],{"class":192},[15,2146,2147,2149,2150,2152],{},[86,2148,1444],{}," фильтрует строки до группировки. ",[86,2151,1465],{}," фильтрует группы после группировки.",[15,2154,2155],{},"Типичный вопрос на собеседовании:",[2157,2158,2159],"blockquote",{},[15,2160,2161],{},"Найти пользователей, у которых больше трех оплаченных заказов.",[133,2163,2165],{"className":169,"code":2164,"language":171,"meta":138,"style":138},"SELECT u.id, u.email, count(o.id) AS paid_orders\nFROM users u\nJOIN orders o ON o.user_id = u.id\nWHERE o.status = 'paid'\nGROUP BY u.id, u.email\nHAVING count(o.id) > 3;\n",[86,2166,2167,2203,2209,2231,2245,2263],{"__ignoreMap":138},[175,2168,2169,2171,2173,2175,2177,2179,2181,2183,2185,2187,2189,2191,2193,2195,2197,2199,2201],{"class":177,"line":178},[175,2170,1336],{"class":181},[175,2172,1664],{"class":364},[175,2174,512],{"class":192},[175,2176,540],{"class":364},[175,2178,89],{"class":192},[175,2180,1791],{"class":364},[175,2182,512],{"class":192},[175,2184,621],{"class":364},[175,2186,89],{"class":192},[175,2188,1416],{"class":364},[175,2190,1419],{"class":192},[175,2192,1965],{"class":364},[175,2194,512],{"class":192},[175,2196,540],{"class":364},[175,2198,1425],{"class":192},[175,2200,1428],{"class":181},[175,2202,1431],{"class":192},[175,2204,2205,2207],{"class":177,"line":196},[175,2206,1436],{"class":181},[175,2208,1802],{"class":192},[175,2210,2211,2213,2215,2217,2219,2221,2223,2225,2227,2229],{"class":177,"line":223},[175,2212,1517],{"class":181},[175,2214,1809],{"class":192},[175,2216,1661],{"class":181},[175,2218,1673],{"class":364},[175,2220,512],{"class":192},[175,2222,1818],{"class":364},[175,2224,995],{"class":181},[175,2226,1664],{"class":364},[175,2228,512],{"class":192},[175,2230,1827],{"class":364},[175,2232,2233,2235,2237,2239,2241,2243],{"class":177,"line":239},[175,2234,1444],{"class":181},[175,2236,1673],{"class":364},[175,2238,512],{"class":192},[175,2240,422],{"class":364},[175,2242,995],{"class":181},[175,2244,1452],{"class":919},[175,2246,2247,2249,2251,2253,2255,2257,2259,2261],{"class":177,"line":252},[175,2248,1457],{"class":181},[175,2250,1664],{"class":364},[175,2252,512],{"class":192},[175,2254,540],{"class":364},[175,2256,89],{"class":192},[175,2258,1791],{"class":364},[175,2260,512],{"class":192},[175,2262,1644],{"class":364},[175,2264,2265,2267,2269,2271,2273,2275,2277,2279,2281,2284],{"class":177,"line":272},[175,2266,1465],{"class":181},[175,2268,1468],{"class":364},[175,2270,1419],{"class":192},[175,2272,1965],{"class":364},[175,2274,512],{"class":192},[175,2276,540],{"class":364},[175,2278,1425],{"class":192},[175,2280,2129],{"class":181},[175,2282,2283],{"class":364}," 3",[175,2285,1501],{"class":192},[145,2287],{},[531,2289,2291],{"id":2290},"cte-with-запросы","CTE: WITH-запросы",[15,2293,2294],{},"Common Table Expression помогает дать имя подзапросу и сделать большой запрос читаемым.",[133,2296,2298],{"className":169,"code":2297,"language":171,"meta":138,"style":138},"WITH paid_orders AS (\n    SELECT id, user_id, total_cents\n    FROM orders\n    WHERE status = 'paid'\n),\nuser_revenue AS (\n    SELECT user_id, sum(total_cents) AS revenue_cents\n    FROM paid_orders\n    GROUP BY user_id\n)\nSELECT u.email, ur.revenue_cents\nFROM user_revenue ur\nJOIN users u ON u.id = ur.user_id\nWHERE ur.revenue_cents >= 100000\nORDER BY ur.revenue_cents DESC;\n",[86,2299,2300,2311,2319,2326,2337,2341,2350,2365,2371,2378,2382,2402,2409,2432,2448],{"__ignoreMap":138},[175,2301,2302,2305,2307,2309],{"class":177,"line":178},[175,2303,2304],{"class":181},"WITH",[175,2306,1487],{"class":192},[175,2308,1428],{"class":181},[175,2310,193],{"class":192},[175,2312,2313,2316],{"class":177,"line":196},[175,2314,2315],{"class":181},"    SELECT",[175,2317,2318],{"class":192}," id, user_id, total_cents\n",[175,2320,2321,2324],{"class":177,"line":223},[175,2322,2323],{"class":181},"    FROM",[175,2325,1439],{"class":192},[175,2327,2328,2331,2333,2335],{"class":177,"line":239},[175,2329,2330],{"class":181},"    WHERE",[175,2332,1447],{"class":181},[175,2334,995],{"class":181},[175,2336,1452],{"class":919},[175,2338,2339],{"class":177,"line":252},[175,2340,368],{"class":192},[175,2342,2343,2346,2348],{"class":177,"line":272},[175,2344,2345],{"class":192},"user_revenue ",[175,2347,1428],{"class":181},[175,2349,193],{"class":192},[175,2351,2352,2354,2356,2359,2361,2363],{"class":177,"line":278},[175,2353,2315],{"class":181},[175,2355,1413],{"class":192},[175,2357,2358],{"class":364},"sum",[175,2360,2090],{"class":192},[175,2362,1428],{"class":181},[175,2364,2095],{"class":192},[175,2366,2367,2369],{"class":177,"line":285},[175,2368,2323],{"class":181},[175,2370,1431],{"class":192},[175,2372,2373,2376],{"class":177,"line":297},[175,2374,2375],{"class":181},"    GROUP BY",[175,2377,1460],{"class":192},[175,2379,2380],{"class":177,"line":316},[175,2381,1010],{"class":192},[175,2383,2384,2386,2388,2390,2392,2394,2397,2399],{"class":177,"line":332},[175,2385,1336],{"class":181},[175,2387,1664],{"class":364},[175,2389,512],{"class":192},[175,2391,621],{"class":364},[175,2393,89],{"class":192},[175,2395,2396],{"class":364},"ur",[175,2398,512],{"class":192},[175,2400,2401],{"class":364},"revenue_cents\n",[175,2403,2404,2406],{"class":177,"line":345},[175,2405,1436],{"class":181},[175,2407,2408],{"class":192}," user_revenue ur\n",[175,2410,2411,2413,2415,2417,2419,2421,2423,2425,2428,2430],{"class":177,"line":371},[175,2412,1517],{"class":181},[175,2414,1658],{"class":192},[175,2416,1661],{"class":181},[175,2418,1664],{"class":364},[175,2420,512],{"class":192},[175,2422,540],{"class":364},[175,2424,995],{"class":181},[175,2426,2427],{"class":364}," ur",[175,2429,512],{"class":192},[175,2431,1678],{"class":364},[175,2433,2434,2436,2438,2440,2443,2446],{"class":177,"line":386},[175,2435,1444],{"class":181},[175,2437,2427],{"class":364},[175,2439,512],{"class":192},[175,2441,2442],{"class":364},"revenue_cents",[175,2444,2445],{"class":181}," >=",[175,2447,2132],{"class":364},[175,2449,2451,2453,2455,2457,2459,2462],{"class":177,"line":2450},15,[175,2452,1484],{"class":181},[175,2454,2427],{"class":364},[175,2456,512],{"class":192},[175,2458,2442],{"class":364},[175,2460,2461],{"class":181}," DESC",[175,2463,1501],{"class":192},[15,2465,2466],{},"CTE - не \"временная таблица\" в общем смысле. В современных версиях PostgreSQL оптимизатор может встроить CTE в основной план, если это выгодно и семантически допустимо. Но CTE все равно стоит использовать прежде всего для ясности: этапы запроса получают имена, а бизнес-логика становится проверяемой.",[15,2468,2469],{},"Рекурсивные CTE решают задачи с деревьями:",[133,2471,2473],{"className":169,"code":2472,"language":171,"meta":138,"style":138},"WITH RECURSIVE category_tree AS (\n    SELECT id, parent_id, name, 1 AS depth\n    FROM categories\n    WHERE id = $1\n\n    UNION ALL\n\n    SELECT c.id, c.parent_id, c.name, ct.depth + 1\n    FROM categories c\n    JOIN category_tree ct ON c.parent_id = ct.id\n)\nSELECT *\nFROM category_tree\nORDER BY depth, name;\n",[86,2474,2475,2489,2509,2516,2532,2536,2541,2545,2590,2597,2622,2626,2633,2640],{"__ignoreMap":138},[175,2476,2477,2479,2482,2485,2487],{"class":177,"line":178},[175,2478,2304],{"class":181},[175,2480,2481],{"class":181}," RECURSIVE",[175,2483,2484],{"class":192}," category_tree ",[175,2486,1428],{"class":181},[175,2488,193],{"class":192},[175,2490,2491,2493,2496,2499,2501,2504,2506],{"class":177,"line":196},[175,2492,2315],{"class":181},[175,2494,2495],{"class":192}," id, parent_id, ",[175,2497,2498],{"class":181},"name",[175,2500,89],{"class":192},[175,2502,2503],{"class":364},"1",[175,2505,211],{"class":181},[175,2507,2508],{"class":192}," depth\n",[175,2510,2511,2513],{"class":177,"line":223},[175,2512,2323],{"class":181},[175,2514,2515],{"class":192}," categories\n",[175,2517,2518,2520,2523,2526,2529],{"class":177,"line":239},[175,2519,2330],{"class":181},[175,2521,2522],{"class":192}," id ",[175,2524,2525],{"class":181},"=",[175,2527,2528],{"class":192}," $",[175,2530,2531],{"class":364},"1\n",[175,2533,2534],{"class":177,"line":252},[175,2535,282],{"emptyLinePlaceholder":281},[175,2537,2538],{"class":177,"line":272},[175,2539,2540],{"class":181},"    UNION ALL\n",[175,2542,2543],{"class":177,"line":278},[175,2544,282],{"emptyLinePlaceholder":281},[175,2546,2547,2549,2552,2554,2556,2558,2561,2563,2566,2568,2570,2572,2574,2576,2579,2581,2584,2587],{"class":177,"line":285},[175,2548,2315],{"class":181},[175,2550,2551],{"class":364}," c",[175,2553,512],{"class":192},[175,2555,540],{"class":364},[175,2557,89],{"class":192},[175,2559,2560],{"class":364},"c",[175,2562,512],{"class":192},[175,2564,2565],{"class":364},"parent_id",[175,2567,89],{"class":192},[175,2569,2560],{"class":364},[175,2571,512],{"class":192},[175,2573,2498],{"class":364},[175,2575,89],{"class":192},[175,2577,2578],{"class":364},"ct",[175,2580,512],{"class":192},[175,2582,2583],{"class":364},"depth",[175,2585,2586],{"class":181}," +",[175,2588,2589],{"class":364}," 1\n",[175,2591,2592,2594],{"class":177,"line":297},[175,2593,2323],{"class":181},[175,2595,2596],{"class":192}," categories c\n",[175,2598,2599,2602,2605,2607,2609,2611,2613,2615,2618,2620],{"class":177,"line":316},[175,2600,2601],{"class":181},"    JOIN",[175,2603,2604],{"class":192}," category_tree ct ",[175,2606,1661],{"class":181},[175,2608,2551],{"class":364},[175,2610,512],{"class":192},[175,2612,2565],{"class":364},[175,2614,995],{"class":181},[175,2616,2617],{"class":364}," ct",[175,2619,512],{"class":192},[175,2621,1827],{"class":364},[175,2623,2624],{"class":177,"line":332},[175,2625,1010],{"class":192},[175,2627,2628,2630],{"class":177,"line":345},[175,2629,1336],{"class":181},[175,2631,2632],{"class":181}," *\n",[175,2634,2635,2637],{"class":177,"line":371},[175,2636,1436],{"class":181},[175,2638,2639],{"class":192}," category_tree\n",[175,2641,2642,2644,2647,2649],{"class":177,"line":386},[175,2643,1484],{"class":181},[175,2645,2646],{"class":192}," depth, ",[175,2648,2498],{"class":181},[175,2650,1501],{"class":192},[145,2652],{},[531,2654,2656],{"id":2655},"views","Views",[15,2658,2659],{},"View - сохраненный запрос, к которому можно обращаться как к таблице.",[133,2661,2663],{"className":169,"code":2662,"language":171,"meta":138,"style":138},"CREATE VIEW paid_order_summary AS\nSELECT\n    o.id,\n    o.user_id,\n    u.email,\n    o.total_cents,\n    o.created_at\nFROM orders o\nJOIN users u ON u.id = o.user_id\nWHERE o.status = 'paid';\n",[86,2664,2665,2678,2682,2692,2702,2712,2722,2731,2737,2759],{"__ignoreMap":138},[175,2666,2667,2669,2672,2675],{"class":177,"line":178},[175,2668,182],{"class":181},[175,2670,2671],{"class":181}," VIEW",[175,2673,2674],{"class":188}," paid_order_summary",[175,2676,2677],{"class":181}," AS\n",[175,2679,2680],{"class":177,"line":196},[175,2681,1613],{"class":181},[175,2683,2684,2686,2688,2690],{"class":177,"line":223},[175,2685,1618],{"class":364},[175,2687,512],{"class":192},[175,2689,540],{"class":364},[175,2691,220],{"class":192},[175,2693,2694,2696,2698,2700],{"class":177,"line":239},[175,2695,1618],{"class":364},[175,2697,512],{"class":192},[175,2699,1818],{"class":364},[175,2701,220],{"class":192},[175,2703,2704,2706,2708,2710],{"class":177,"line":252},[175,2705,1639],{"class":364},[175,2707,512],{"class":192},[175,2709,621],{"class":364},[175,2711,220],{"class":192},[175,2713,2714,2716,2718,2720],{"class":177,"line":272},[175,2715,1618],{"class":364},[175,2717,512],{"class":192},[175,2719,431],{"class":364},[175,2721,220],{"class":192},[175,2723,2724,2726,2728],{"class":177,"line":278},[175,2725,1618],{"class":364},[175,2727,512],{"class":192},[175,2729,2730],{"class":364},"created_at\n",[175,2732,2733,2735],{"class":177,"line":285},[175,2734,1436],{"class":181},[175,2736,1651],{"class":192},[175,2738,2739,2741,2743,2745,2747,2749,2751,2753,2755,2757],{"class":177,"line":297},[175,2740,1517],{"class":181},[175,2742,1658],{"class":192},[175,2744,1661],{"class":181},[175,2746,1664],{"class":364},[175,2748,512],{"class":192},[175,2750,540],{"class":364},[175,2752,995],{"class":181},[175,2754,1673],{"class":364},[175,2756,512],{"class":192},[175,2758,1678],{"class":364},[175,2760,2761,2763,2765,2767,2769,2771,2773],{"class":177,"line":316},[175,2762,1444],{"class":181},[175,2764,1673],{"class":364},[175,2766,512],{"class":192},[175,2768,422],{"class":364},[175,2770,995],{"class":181},[175,2772,998],{"class":919},[175,2774,1501],{"class":192},[15,2776,2777],{},"Теперь:",[133,2779,2781],{"className":169,"code":2780,"language":171,"meta":138,"style":138},"SELECT *\nFROM paid_order_summary\nWHERE user_id = $1;\n",[86,2782,2783,2789,2796],{"__ignoreMap":138},[175,2784,2785,2787],{"class":177,"line":178},[175,2786,1336],{"class":181},[175,2788,2632],{"class":181},[175,2790,2791,2793],{"class":177,"line":196},[175,2792,1436],{"class":181},[175,2794,2795],{"class":192}," paid_order_summary\n",[175,2797,2798,2800,2803,2805,2807,2809],{"class":177,"line":223},[175,2799,1444],{"class":181},[175,2801,2802],{"class":192}," user_id ",[175,2804,2525],{"class":181},[175,2806,2528],{"class":192},[175,2808,2503],{"class":364},[175,2810,1501],{"class":192},[15,2812,2813],{},"View полезен, когда:",[393,2815,2816,2819,2822,2825],{},[396,2817,2818],{},"несколько сервисов или отчетов используют один и тот же SQL;",[396,2820,2821],{},"нужно скрыть сложность join'ов;",[396,2823,2824],{},"нужно дать read-only доступ к части данных;",[396,2826,2827],{},"нужна стабильная \"read model\" поверх нормализованных таблиц.",[15,2829,2830],{},"Обычный view не хранит результат. Materialized view хранит результат физически и требует обновления:",[133,2832,2834],{"className":169,"code":2833,"language":171,"meta":138,"style":138},"REFRESH MATERIALIZED VIEW course_stats;\n",[86,2835,2836],{"__ignoreMap":138},[175,2837,2838],{"class":177,"line":178},[175,2839,2833],{"class":192},[15,2841,2842],{},"Materialized view ускоряет тяжелые отчеты, но добавляет вопрос свежести данных.",[145,2844],{},[531,2846,2848],{"id":2847},"window-functions","Window functions",[15,2850,2851,2852,2854],{},"Агрегат с ",[86,2853,1457],{}," схлопывает несколько строк в одну. Window function считает значение по окну, но сохраняет строки.",[133,2856,2858],{"className":169,"code":2857,"language":171,"meta":138,"style":138},"SELECT\n    id,\n    user_id,\n    total_cents,\n    created_at,\n    row_number() OVER (\n        PARTITION BY user_id\n        ORDER BY created_at DESC\n    ) AS order_no_for_user\nFROM orders;\n",[86,2859,2860,2864,2869,2873,2878,2883,2896,2906,2916,2926],{"__ignoreMap":138},[175,2861,2862],{"class":177,"line":178},[175,2863,1613],{"class":181},[175,2865,2866],{"class":177,"line":196},[175,2867,2868],{"class":192},"    id,\n",[175,2870,2871],{"class":177,"line":223},[175,2872,2066],{"class":192},[175,2874,2875],{"class":177,"line":239},[175,2876,2877],{"class":192},"    total_cents,\n",[175,2879,2880],{"class":177,"line":252},[175,2881,2882],{"class":192},"    created_at,\n",[175,2884,2885,2888,2891,2894],{"class":177,"line":272},[175,2886,2887],{"class":364},"    row_number",[175,2889,2890],{"class":192},"() ",[175,2892,2893],{"class":181},"OVER",[175,2895,193],{"class":192},[175,2897,2898,2901,2904],{"class":177,"line":278},[175,2899,2900],{"class":181},"        PARTITION",[175,2902,2903],{"class":181}," BY",[175,2905,1460],{"class":192},[175,2907,2908,2911,2914],{"class":177,"line":285},[175,2909,2910],{"class":181},"        ORDER BY",[175,2912,2913],{"class":192}," created_at ",[175,2915,1490],{"class":181},[175,2917,2918,2921,2923],{"class":177,"line":297},[175,2919,2920],{"class":192},"    ) ",[175,2922,1428],{"class":181},[175,2924,2925],{"class":192}," order_no_for_user\n",[175,2927,2928,2930],{"class":177,"line":316},[175,2929,1436],{"class":181},[175,2931,2932],{"class":192}," orders;\n",[15,2934,2935],{},"Что происходит:",[393,2937,2938,2944,2950],{},[396,2939,2940,2943],{},[86,2941,2942],{},"PARTITION BY user_id"," делит строки на окна по пользователю;",[396,2945,2946,2949],{},[86,2947,2948],{},"ORDER BY created_at DESC"," задает порядок внутри окна;",[396,2951,2952,2955],{},[86,2953,2954],{},"row_number()"," нумерует строки внутри каждого окна.",[15,2957,2958],{},"Найти последний заказ каждого пользователя:",[133,2960,2962],{"className":169,"code":2961,"language":171,"meta":138,"style":138},"WITH ranked AS (\n    SELECT\n        o.*,\n        row_number() OVER (\n            PARTITION BY user_id\n            ORDER BY created_at DESC, id DESC\n        ) AS rn\n    FROM orders o\n)\nSELECT *\nFROM ranked\nWHERE rn = 1;\n",[86,2963,2964,2975,2980,2989,3000,3009,3023,3033,3039,3043,3049,3056],{"__ignoreMap":138},[175,2965,2966,2968,2971,2973],{"class":177,"line":178},[175,2967,2304],{"class":181},[175,2969,2970],{"class":192}," ranked ",[175,2972,1428],{"class":181},[175,2974,193],{"class":192},[175,2976,2977],{"class":177,"line":196},[175,2978,2979],{"class":181},"    SELECT\n",[175,2981,2982,2985,2987],{"class":177,"line":223},[175,2983,2984],{"class":192},"        o.",[175,2986,1422],{"class":181},[175,2988,220],{"class":192},[175,2990,2991,2994,2996,2998],{"class":177,"line":239},[175,2992,2993],{"class":364},"        row_number",[175,2995,2890],{"class":192},[175,2997,2893],{"class":181},[175,2999,193],{"class":192},[175,3001,3002,3005,3007],{"class":177,"line":252},[175,3003,3004],{"class":181},"            PARTITION",[175,3006,2903],{"class":181},[175,3008,1460],{"class":192},[175,3010,3011,3014,3016,3018,3021],{"class":177,"line":272},[175,3012,3013],{"class":181},"            ORDER BY",[175,3015,2913],{"class":192},[175,3017,2142],{"class":181},[175,3019,3020],{"class":192},", id ",[175,3022,1490],{"class":181},[175,3024,3025,3028,3030],{"class":177,"line":278},[175,3026,3027],{"class":192},"        ) ",[175,3029,1428],{"class":181},[175,3031,3032],{"class":192}," rn\n",[175,3034,3035,3037],{"class":177,"line":285},[175,3036,2323],{"class":181},[175,3038,1651],{"class":192},[175,3040,3041],{"class":177,"line":297},[175,3042,1010],{"class":192},[175,3044,3045,3047],{"class":177,"line":316},[175,3046,1336],{"class":181},[175,3048,2632],{"class":181},[175,3050,3051,3053],{"class":177,"line":332},[175,3052,1436],{"class":181},[175,3054,3055],{"class":192}," ranked\n",[175,3057,3058,3060,3063,3065,3068],{"class":177,"line":345},[175,3059,1444],{"class":181},[175,3061,3062],{"class":192}," rn ",[175,3064,2525],{"class":181},[175,3066,3067],{"class":364}," 1",[175,3069,1501],{"class":192},[15,3071,3072],{},"Полезные функции:",[18,3074,3075,3085],{},[21,3076,3077],{},[24,3078,3079,3082],{},[27,3080,3081],{"align":29},"Функция",[27,3083,3084],{"align":29},"Для чего",[38,3086,3087,3096,3106,3116,3126,3136],{},[24,3088,3089,3093],{},[43,3090,3091],{"align":29},[86,3092,2954],{},[43,3094,3095],{"align":29},"Уникальный номер строки внутри окна",[24,3097,3098,3103],{},[43,3099,3100],{"align":29},[86,3101,3102],{},"rank()",[43,3104,3105],{"align":29},"Ранг с пропусками при равенстве",[24,3107,3108,3113],{},[43,3109,3110],{"align":29},[86,3111,3112],{},"dense_rank()",[43,3114,3115],{"align":29},"Ранг без пропусков",[24,3117,3118,3123],{},[43,3119,3120],{"align":29},[86,3121,3122],{},"lag()",[43,3124,3125],{"align":29},"Значение из предыдущей строки",[24,3127,3128,3133],{},[43,3129,3130],{"align":29},[86,3131,3132],{},"lead()",[43,3134,3135],{"align":29},"Значение из следующей строки",[24,3137,3138,3143],{},[43,3139,3140],{"align":29},[86,3141,3142],{},"sum(...) OVER (...)",[43,3144,3145],{"align":29},"Накопительная сумма или сумма по окну",[15,3147,3148,3149,3152],{},"Пример read skew в аналитике часто проще увидеть через ",[86,3150,3151],{},"lag",":",[133,3154,3156],{"className":169,"code":3155,"language":171,"meta":138,"style":138},"SELECT\n    user_id,\n    created_at,\n    total_cents,\n    total_cents - lag(total_cents) OVER (\n        PARTITION BY user_id ORDER BY created_at\n    ) AS diff_from_previous\nFROM orders\nWHERE status = 'paid';\n",[86,3157,3158,3162,3166,3170,3174,3190,3203,3212,3218],{"__ignoreMap":138},[175,3159,3160],{"class":177,"line":178},[175,3161,1613],{"class":181},[175,3163,3164],{"class":177,"line":196},[175,3165,2066],{"class":192},[175,3167,3168],{"class":177,"line":223},[175,3169,2882],{"class":192},[175,3171,3172],{"class":177,"line":239},[175,3173,2877],{"class":192},[175,3175,3176,3178,3181,3184,3186,3188],{"class":177,"line":252},[175,3177,348],{"class":192},[175,3179,3180],{"class":181},"-",[175,3182,3183],{"class":364}," lag",[175,3185,2090],{"class":192},[175,3187,2893],{"class":181},[175,3189,193],{"class":192},[175,3191,3192,3194,3196,3198,3200],{"class":177,"line":272},[175,3193,2900],{"class":181},[175,3195,2903],{"class":181},[175,3197,2802],{"class":192},[175,3199,1484],{"class":181},[175,3201,3202],{"class":192}," created_at\n",[175,3204,3205,3207,3209],{"class":177,"line":278},[175,3206,2920],{"class":192},[175,3208,1428],{"class":181},[175,3210,3211],{"class":192}," diff_from_previous\n",[175,3213,3214,3216],{"class":177,"line":285},[175,3215,1436],{"class":181},[175,3217,1439],{"class":192},[175,3219,3220,3222,3224,3226,3228],{"class":177,"line":297},[175,3221,1444],{"class":181},[175,3223,1447],{"class":181},[175,3225,995],{"class":181},[175,3227,998],{"class":919},[175,3229,1501],{"class":192},[145,3231],{},[531,3233,3235],{"id":3234},"null-и-three-valued-logic","NULL и three-valued logic",[15,3237,3238,3240,3241,512],{},[86,3239,1736],{}," означает \"значение отсутствует или неизвестно\". Это не пустая строка, не ноль и не ",[86,3242,3243],{},"false",[15,3245,3246],{},"SQL использует трехзначную логику:",[18,3248,3249,3259],{},[21,3250,3251],{},[24,3252,3253,3256],{},[27,3254,3255],{"align":29},"Выражение",[27,3257,3258],{"align":29},"Результат",[38,3260,3261,3273,3284,3296,3308,3321],{},[24,3262,3263,3268],{},[43,3264,3265],{"align":29},[86,3266,3267],{},"NULL = NULL",[43,3269,3270],{"align":29},[86,3271,3272],{},"UNKNOWN",[24,3274,3275,3280],{},[43,3276,3277],{"align":29},[86,3278,3279],{},"NULL \u003C> 1",[43,3281,3282],{"align":29},[86,3283,3272],{},[24,3285,3286,3291],{},[43,3287,3288],{"align":29},[86,3289,3290],{},"1 = 1",[43,3292,3293],{"align":29},[86,3294,3295],{},"TRUE",[24,3297,3298,3303],{},[43,3299,3300],{"align":29},[86,3301,3302],{},"1 = 2",[43,3304,3305],{"align":29},[86,3306,3307],{},"FALSE",[24,3309,3310,3315],{},[43,3311,3312],{"align":29},[86,3313,3314],{},"email IS NULL",[43,3316,3317,1718,3319],{"align":29},[86,3318,3295],{},[86,3320,3307],{},[24,3322,3323,3328],{},[43,3324,3325],{"align":29},[86,3326,3327],{},"email IS NOT NULL",[43,3329,3330,1718,3332],{"align":29},[86,3331,3295],{},[86,3333,3307],{},[15,3335,3336,3338,3339,3341,3342,401,3344,3346],{},[86,3337,1444],{}," оставляет только строки, где условие ",[86,3340,3295],{},". ",[86,3343,3307],{},[86,3345,3272],{}," отбрасываются.",[133,3348,3350],{"className":169,"code":3349,"language":171,"meta":138,"style":138},"-- Не найдет строки с NULL.\nSELECT *\nFROM users\nWHERE deleted_at = NULL;\n\n-- Правильно.\nSELECT *\nFROM users\nWHERE deleted_at IS NULL;\n",[86,3351,3352,3357,3363,3370,3383,3387,3392,3398,3404],{"__ignoreMap":138},[175,3353,3354],{"class":177,"line":178},[175,3355,3356],{"class":1865},"-- Не найдет строки с NULL.\n",[175,3358,3359,3361],{"class":177,"line":196},[175,3360,1336],{"class":181},[175,3362,2632],{"class":181},[175,3364,3365,3367],{"class":177,"line":223},[175,3366,1436],{"class":181},[175,3368,3369],{"class":192}," users\n",[175,3371,3372,3374,3377,3379,3381],{"class":177,"line":239},[175,3373,1444],{"class":181},[175,3375,3376],{"class":192}," deleted_at ",[175,3378,2525],{"class":181},[175,3380,1036],{"class":181},[175,3382,1501],{"class":192},[175,3384,3385],{"class":177,"line":252},[175,3386,282],{"emptyLinePlaceholder":281},[175,3388,3389],{"class":177,"line":272},[175,3390,3391],{"class":1865},"-- Правильно.\n",[175,3393,3394,3396],{"class":177,"line":278},[175,3395,1336],{"class":181},[175,3397,2632],{"class":181},[175,3399,3400,3402],{"class":177,"line":285},[175,3401,1436],{"class":181},[175,3403,3369],{"class":192},[175,3405,3406,3408,3410,3412,3414],{"class":177,"line":297},[175,3407,1444],{"class":181},[175,3409,3376],{"class":192},[175,3411,1033],{"class":181},[175,3413,1036],{"class":181},[175,3415,1501],{"class":192},[15,3417,3418],{},"Опасный пример:",[133,3420,3422],{"className":169,"code":3421,"language":171,"meta":138,"style":138},"SELECT *\nFROM users\nWHERE id NOT IN (SELECT banned_user_id FROM bans);\n",[86,3423,3424,3430,3436],{"__ignoreMap":138},[175,3425,3426,3428],{"class":177,"line":178},[175,3427,1336],{"class":181},[175,3429,2632],{"class":181},[175,3431,3432,3434],{"class":177,"line":196},[175,3433,1436],{"class":181},[175,3435,3369],{"class":192},[175,3437,3438,3440,3442,3445,3447,3449,3451,3454,3456],{"class":177,"line":223},[175,3439,1444],{"class":181},[175,3441,2522],{"class":192},[175,3443,3444],{"class":181},"NOT",[175,3446,914],{"class":181},[175,3448,909],{"class":192},[175,3450,1336],{"class":181},[175,3452,3453],{"class":192}," banned_user_id ",[175,3455,1436],{"class":181},[175,3457,3458],{"class":192}," bans);\n",[15,3460,3461,3462,3464,3465,3468,3469,3152],{},"Если подзапрос вернет хотя бы один ",[86,3463,1736],{},", выражение ",[86,3466,3467],{},"NOT IN"," может дать неожиданный результат. Часто надежнее писать ",[86,3470,3471],{},"NOT EXISTS",[133,3473,3475],{"className":169,"code":3474,"language":171,"meta":138,"style":138},"SELECT *\nFROM users u\nWHERE NOT EXISTS (\n    SELECT 1\n    FROM bans b\n    WHERE b.banned_user_id = u.id\n);\n",[86,3476,3477,3483,3489,3501,3507,3514,3534],{"__ignoreMap":138},[175,3478,3479,3481],{"class":177,"line":178},[175,3480,1336],{"class":181},[175,3482,2632],{"class":181},[175,3484,3485,3487],{"class":177,"line":196},[175,3486,1436],{"class":181},[175,3488,1802],{"class":192},[175,3490,3491,3493,3496,3499],{"class":177,"line":223},[175,3492,1444],{"class":181},[175,3494,3495],{"class":181}," NOT",[175,3497,3498],{"class":181}," EXISTS",[175,3500,193],{"class":192},[175,3502,3503,3505],{"class":177,"line":239},[175,3504,2315],{"class":181},[175,3506,2589],{"class":364},[175,3508,3509,3511],{"class":177,"line":252},[175,3510,2323],{"class":181},[175,3512,3513],{"class":192}," bans b\n",[175,3515,3516,3518,3521,3523,3526,3528,3530,3532],{"class":177,"line":272},[175,3517,2330],{"class":181},[175,3519,3520],{"class":364}," b",[175,3522,512],{"class":192},[175,3524,3525],{"class":364},"banned_user_id",[175,3527,995],{"class":181},[175,3529,1664],{"class":364},[175,3531,512],{"class":192},[175,3533,1827],{"class":364},[175,3535,3536],{"class":177,"line":278},[175,3537,275],{"class":192},[15,3539,3540,3541,3543,3544,89,3547,3550,3551,3553],{},"В Go ",[86,3542,1736],{}," нужно моделировать явно: ",[86,3545,3546],{},"sql.NullString",[86,3548,3549],{},"sql.NullTime",", pointer-типы или типы из драйвера. Не превращайте ",[86,3552,1736],{}," в пустую строку без осознанного решения, иначе потеряете различие между \"нет значения\" и \"значение пустое\".",[145,3555],{},[531,3557,3559],{"id":3558},"insert-update-delete-returning","INSERT, UPDATE, DELETE, RETURNING",[15,3561,3562,3563,3566],{},"PostgreSQL умеет возвращать измененные строки через ",[86,3564,3565],{},"RETURNING",", что очень удобно для Go backend.",[133,3568,3570],{"className":169,"code":3569,"language":171,"meta":138,"style":138},"INSERT INTO users (email, name)\nVALUES ($1, $2)\nRETURNING id, email, name, created_at;\n",[86,3571,3572,3584,3602],{"__ignoreMap":138},[175,3573,3574,3577,3580,3582],{"class":177,"line":178},[175,3575,3576],{"class":181},"INSERT INTO",[175,3578,3579],{"class":192}," users (email, ",[175,3581,2498],{"class":181},[175,3583,1010],{"class":192},[175,3585,3586,3589,3592,3594,3597,3600],{"class":177,"line":196},[175,3587,3588],{"class":181},"VALUES",[175,3590,3591],{"class":192}," ($",[175,3593,2503],{"class":364},[175,3595,3596],{"class":192},", $",[175,3598,3599],{"class":364},"2",[175,3601,1010],{"class":192},[175,3603,3604,3607,3609],{"class":177,"line":223},[175,3605,3606],{"class":192},"RETURNING id, email, ",[175,3608,2498],{"class":181},[175,3610,3611],{"class":192},", created_at;\n",[15,3613,3614],{},"Обновление с проверкой владельца:",[133,3616,3618],{"className":169,"code":3617,"language":171,"meta":138,"style":138},"UPDATE orders\nSET status = 'cancelled'\nWHERE id = $1\n  AND user_id = $2\n  AND status = 'draft'\nRETURNING id, status;\n",[86,3619,3620,3626,3638,3650,3664,3675],{"__ignoreMap":138},[175,3621,3622,3624],{"class":177,"line":178},[175,3623,1342],{"class":181},[175,3625,1439],{"class":192},[175,3627,3628,3631,3633,3635],{"class":177,"line":196},[175,3629,3630],{"class":181},"SET",[175,3632,1447],{"class":181},[175,3634,995],{"class":181},[175,3636,3637],{"class":919}," 'cancelled'\n",[175,3639,3640,3642,3644,3646,3648],{"class":177,"line":223},[175,3641,1444],{"class":181},[175,3643,2522],{"class":192},[175,3645,2525],{"class":181},[175,3647,2528],{"class":192},[175,3649,2531],{"class":364},[175,3651,3652,3655,3657,3659,3661],{"class":177,"line":239},[175,3653,3654],{"class":181},"  AND",[175,3656,2802],{"class":192},[175,3658,2525],{"class":181},[175,3660,2528],{"class":192},[175,3662,3663],{"class":364},"2\n",[175,3665,3666,3668,3670,3672],{"class":177,"line":252},[175,3667,3654],{"class":181},[175,3669,1447],{"class":181},[175,3671,995],{"class":181},[175,3673,3674],{"class":919}," 'draft'\n",[175,3676,3677,3680,3682],{"class":177,"line":272},[175,3678,3679],{"class":192},"RETURNING id, ",[175,3681,422],{"class":181},[175,3683,1501],{"class":192},[15,3685,3686,3687,3689,3690,3693,3694,512],{},"Если ",[86,3688,3565],{}," не вернул строку, возможны разные причины: заказа нет, он чужой или статус уже не ",[86,3691,3692],{},"draft",". В API это лучше маппить аккуратно, а не всегда отвечать ",[86,3695,3696],{},"500",[15,3698,3699],{},"Upsert:",[133,3701,3703],{"className":169,"code":3702,"language":171,"meta":138,"style":138},"INSERT INTO user_settings (user_id, theme, updated_at)\nVALUES ($1, $2, now())\nON CONFLICT (user_id)\nDO UPDATE SET\n    theme = EXCLUDED.theme,\n    updated_at = now()\nRETURNING *;\n",[86,3704,3705,3712,3732,3739,3749,3766,3777],{"__ignoreMap":138},[175,3706,3707,3709],{"class":177,"line":178},[175,3708,3576],{"class":181},[175,3710,3711],{"class":192}," user_settings (user_id, theme, updated_at)\n",[175,3713,3714,3716,3718,3720,3722,3724,3726,3729],{"class":177,"line":196},[175,3715,3588],{"class":181},[175,3717,3591],{"class":192},[175,3719,2503],{"class":364},[175,3721,3596],{"class":192},[175,3723,3599],{"class":364},[175,3725,89],{"class":192},[175,3727,3728],{"class":181},"now",[175,3730,3731],{"class":192},"())\n",[175,3733,3734,3736],{"class":177,"line":223},[175,3735,1661],{"class":181},[175,3737,3738],{"class":192}," CONFLICT (user_id)\n",[175,3740,3741,3744,3746],{"class":177,"line":239},[175,3742,3743],{"class":192},"DO ",[175,3745,1342],{"class":181},[175,3747,3748],{"class":181}," SET\n",[175,3750,3751,3754,3756,3759,3761,3764],{"class":177,"line":252},[175,3752,3753],{"class":192},"    theme ",[175,3755,2525],{"class":181},[175,3757,3758],{"class":364}," EXCLUDED",[175,3760,512],{"class":192},[175,3762,3763],{"class":364},"theme",[175,3765,220],{"class":192},[175,3767,3768,3771,3773,3775],{"class":177,"line":272},[175,3769,3770],{"class":192},"    updated_at ",[175,3772,2525],{"class":181},[175,3774,266],{"class":181},[175,3776,269],{"class":192},[175,3778,3779,3782,3784],{"class":177,"line":278},[175,3780,3781],{"class":192},"RETURNING ",[175,3783,1422],{"class":181},[175,3785,1501],{"class":192},[15,3787,3788,3791],{},[86,3789,3790],{},"ON CONFLICT"," должен опираться на unique или primary key constraint.",[145,3793],{},[148,3795,3797],{"id":3796},"sql-как-контракт-сервиса","SQL как контракт сервиса",[15,3799,3800],{},"SQL-запрос в backend - это часть контракта между приложением и базой. Он фиксирует не только \"откуда взять данные\", но и:",[393,3802,3803,3806,3811,3814,3817],{},[396,3804,3805],{},"какие поля приложение действительно читает;",[396,3807,3808,3809,436],{},"какие инварианты проверяются в ",[86,3810,1444],{},[396,3812,3813],{},"какие ошибки базы считаются ожидаемыми;",[396,3815,3816],{},"какие строки пользователь вообще имеет право увидеть или изменить;",[396,3818,3819],{},"какие условия гонки должны приводить к повтору, конфликту или отказу.",[15,3821,3822,3823,3826],{},"Поэтому ",[86,3824,3825],{},"SELECT *"," опасен даже без SQL injection: он делает API зависимым от всей формы таблицы. Добавили колонку, изменили порядок scan'а, протащили лишние персональные данные в лог - и запрос внезапно стал частью проблемы. В production-коде лучше явно перечислять поля, которые нужны конкретному use case.",[15,3828,3829],{},"Пример контракта чтения:",[133,3831,3833],{"className":169,"code":3832,"language":171,"meta":138,"style":138},"SELECT id, status, total_cents, created_at\nFROM orders\nWHERE id = $1\n  AND user_id = $2;\n",[86,3834,3835,3847,3853,3865],{"__ignoreMap":138},[175,3836,3837,3839,3842,3844],{"class":177,"line":178},[175,3838,1336],{"class":181},[175,3840,3841],{"class":192}," id, ",[175,3843,422],{"class":181},[175,3845,3846],{"class":192},", total_cents, created_at\n",[175,3848,3849,3851],{"class":177,"line":196},[175,3850,1436],{"class":181},[175,3852,1439],{"class":192},[175,3854,3855,3857,3859,3861,3863],{"class":177,"line":223},[175,3856,1444],{"class":181},[175,3858,2522],{"class":192},[175,3860,2525],{"class":181},[175,3862,2528],{"class":192},[175,3864,2531],{"class":364},[175,3866,3867,3869,3871,3873,3875,3877],{"class":177,"line":239},[175,3868,3654],{"class":181},[175,3870,2802],{"class":192},[175,3872,2525],{"class":181},[175,3874,2528],{"class":192},[175,3876,3599],{"class":364},[175,3878,1501],{"class":192},[15,3880,3881,3882,3885,3886,3889],{},"Здесь ",[86,3883,3884],{},"user_id = $2"," - не косметика, а часть access control. Если запрос вернул ноль строк, API может намеренно ответить ",[86,3887,3888],{},"404",", не раскрывая, существует ли чужой заказ. Для админского сценария контракт будет другим: другой repository-метод, другая проверка прав и обычно другой audit trail.",[15,3891,3892],{},"Типичная маппинг-таблица для Go API:",[18,3894,3895,3908],{},[21,3896,3897],{},[24,3898,3899,3902,3905],{},[27,3900,3901],{"align":29},"Ситуация",[27,3903,3904],{"align":29},"Признак",[27,3906,3907],{"align":29},"Возможный ответ API",[38,3909,3910,3932,3949,3967,3984,3999],{},[24,3911,3912,3915,3923],{},[43,3913,3914],{"align":29},"Строка не найдена",[43,3916,3917,89,3920],{"align":29},[86,3918,3919],{},"sql.ErrNoRows",[86,3921,3922],{},"pgx.ErrNoRows",[43,3924,3925,3927,3928,3931],{"align":29},[86,3926,3888],{},", иногда ",[86,3929,3930],{},"403\u002F404"," без раскрытия существования",[24,3933,3934,3937,3943],{},[43,3935,3936],{"align":29},"Нарушена уникальность",[43,3938,3939,3940],{"align":29},"PostgreSQL ",[86,3941,3942],{},"23505",[43,3944,3945,3948],{"align":29},[86,3946,3947],{},"409 Conflict"," или ошибка валидации поля",[24,3950,3951,3954,3959],{},[43,3952,3953],{"align":29},"Нарушен foreign key",[43,3955,3939,3956],{"align":29},[86,3957,3958],{},"23503",[43,3960,3961,1718,3964,3966],{"align":29},[86,3962,3963],{},"400 Bad Request",[86,3965,3947],{},", зависит от сценария",[24,3968,3969,3974,3979],{},[43,3970,3971,3972],{"align":29},"Нарушен ",[86,3973,825],{},[43,3975,3939,3976],{"align":29},[86,3977,3978],{},"23514",[43,3980,3981,3983],{"align":29},[86,3982,3963],{},", если это ошибка входных данных",[24,3985,3986,3989,3995],{},[43,3987,3988],{"align":29},"Конфликт optimistic lock",[43,3990,3991,3994],{"align":29},[86,3992,3993],{},"UPDATE ... WHERE id = $1 AND version = $2"," вернул 0 строк",[43,3996,3997],{"align":29},[86,3998,3947],{},[24,4000,4001,4004,4009],{},[43,4002,4003],{"align":29},"Serialization failure",[43,4005,3939,4006],{"align":29},[86,4007,4008],{},"40001",[43,4010,4011],{"align":29},"безопасный retry на уровне транзакции",[15,4013,4014,4015,4018],{},"Важно не ловить все ошибки базы одной веткой ",[86,4016,4017],{},"internal error",". Некоторые ошибки - нормальная часть бизнес-потока: дубликат email, устаревшая версия записи, попытка изменить чужой ресурс, ссылка на несуществующий справочник.",[531,4020,4022],{"id":4021},"access-control-nuance","Access-control nuance",[15,4024,4025],{},"Access control лучше не оставлять только на уровне handler. Для операций с пользовательскими или tenant-данными predicate доступа должен быть рядом с изменением данных:",[133,4027,4028],{"className":169,"code":3617,"language":171,"meta":138,"style":138},[86,4029,4030,4036,4046,4058,4070,4080],{"__ignoreMap":138},[175,4031,4032,4034],{"class":177,"line":178},[175,4033,1342],{"class":181},[175,4035,1439],{"class":192},[175,4037,4038,4040,4042,4044],{"class":177,"line":196},[175,4039,3630],{"class":181},[175,4041,1447],{"class":181},[175,4043,995],{"class":181},[175,4045,3637],{"class":919},[175,4047,4048,4050,4052,4054,4056],{"class":177,"line":223},[175,4049,1444],{"class":181},[175,4051,2522],{"class":192},[175,4053,2525],{"class":181},[175,4055,2528],{"class":192},[175,4057,2531],{"class":364},[175,4059,4060,4062,4064,4066,4068],{"class":177,"line":239},[175,4061,3654],{"class":181},[175,4063,2802],{"class":192},[175,4065,2525],{"class":181},[175,4067,2528],{"class":192},[175,4069,3663],{"class":364},[175,4071,4072,4074,4076,4078],{"class":177,"line":252},[175,4073,3654],{"class":181},[175,4075,1447],{"class":181},[175,4077,995],{"class":181},[175,4079,3674],{"class":919},[175,4081,4082,4084,4086],{"class":177,"line":272},[175,4083,3679],{"class":192},[175,4085,422],{"class":181},[175,4087,1501],{"class":192},[15,4089,4090,4091,4093,4094,4096],{},"Такой запрос атомарно проверяет владение и допустимое состояние. Паттерн \"сначала ",[86,4092,1336],{},", потом отдельно ",[86,4095,1342],{},"\" часто создает окно гонки и требует дополнительной транзакционной защиты.",[15,4098,4099],{},"Для внутренних сервисов с широкими правами это особенно важно. Service account может технически читать всё, но application query всё равно должен явно ограничивать tenant, project, user или другой ownership scope. В PostgreSQL дополнительно можно использовать отдельные роли, view для read-only доступа и Row-Level Security, но они не отменяют обязанность приложения формулировать корректный доменный контракт.",[145,4101],{},[148,4103,4105],{"id":4104},"практические-правила-для-go-backend","Практические правила для Go backend",[18,4107,4108,4117],{},[21,4109,4110],{},[24,4111,4112,4114],{},[27,4113,3901],{"align":29},[27,4115,4116],{"align":29},"Хорошее решение",[38,4118,4119,4131,4146,4156,4170,4178,4186],{},[24,4120,4121,4126],{},[43,4122,4123,4124],{"align":29},"Нужно создать запись и получить ",[86,4125,540],{},[43,4127,4128],{"align":29},[86,4129,4130],{},"INSERT ... RETURNING id",[24,4132,4133,4136],{},[43,4134,4135],{"align":29},"Нужно запретить дубли",[43,4137,4138,4140,4141,4143,4144],{"align":29},[86,4139,778],{},", а не только проверка ",[86,4142,1336],{}," перед ",[86,4145,1339],{},[24,4147,4148,4151],{},[43,4149,4150],{"align":29},"Нужно обновить только текущую версию",[43,4152,4153],{"align":29},[86,4154,4155],{},"WHERE id = $1 AND version = $2",[24,4157,4158,4161],{},[43,4159,4160],{"align":29},"Нужно проверить владельца ресурса",[43,4162,4163,4164,1853,4167],{"align":29},"Добавить ",[86,4165,4166],{},"AND user_id = $currentUserID",[86,4168,4169],{},"UPDATE\u002FDELETE\u002FSELECT",[24,4171,4172,4175],{},[43,4173,4174],{"align":29},"Нужно отличить пустое значение от отсутствующего",[43,4176,4177],{"align":29},"Явно использовать nullable-тип",[24,4179,4180,4183],{},[43,4181,4182],{"align":29},"Нужно собрать отчет",[43,4184,4185],{"align":29},"Сначала SQL-агрегация, потом Go-форматирование",[24,4187,4188,4191],{},[43,4189,4190],{"align":29},"Нужно передать список ids",[43,4192,4193],{"align":29},"Параметризованный массив или временная таблица, не string concatenation",[15,4195,4196],{},"Пример repository-метода:",[133,4198,4200],{"className":451,"code":4199,"language":453,"meta":454,"style":138},"const createUserQuery = `\nINSERT INTO users (email, name)\nVALUES ($1, $2)\nRETURNING id, email, name, created_at\n`\n\nfunc (r *UserRepo) Create(ctx context.Context, email, name string) (User, error) {\n    var u User\n    err := r.db.QueryRowContext(ctx, createUserQuery, email, name).Scan(\n        &u.ID,\n        &u.Email,\n        &u.Name,\n        &u.CreatedAt,\n    )\n    if err != nil {\n        return User{}, err\n    }\n    return u, nil\n}\n",[86,4201,4202,4215,4220,4225,4230,4235,4239,4298,4309,4332,4340,4347,4354,4361,4365,4381,4393,4399,4411],{"__ignoreMap":138},[175,4203,4204,4207,4210,4212],{"class":177,"line":178},[175,4205,4206],{"class":181},"const",[175,4208,4209],{"class":364}," createUserQuery",[175,4211,995],{"class":181},[175,4213,4214],{"class":919}," `\n",[175,4216,4217],{"class":177,"line":196},[175,4218,4219],{"class":919},"INSERT INTO users (email, name)\n",[175,4221,4222],{"class":177,"line":223},[175,4223,4224],{"class":919},"VALUES ($1, $2)\n",[175,4226,4227],{"class":177,"line":239},[175,4228,4229],{"class":919},"RETURNING id, email, name, created_at\n",[175,4231,4232],{"class":177,"line":252},[175,4233,4234],{"class":919},"`\n",[175,4236,4237],{"class":177,"line":272},[175,4238,282],{"emptyLinePlaceholder":281},[175,4240,4241,4244,4246,4250,4252,4255,4257,4260,4262,4265,4268,4270,4273,4275,4277,4279,4281,4284,4287,4290,4292,4295],{"class":177,"line":278},[175,4242,4243],{"class":181},"func",[175,4245,909],{"class":192},[175,4247,4249],{"class":4248},"s9osk","r ",[175,4251,1422],{"class":181},[175,4253,4254],{"class":188},"UserRepo",[175,4256,1425],{"class":192},[175,4258,4259],{"class":188},"Create",[175,4261,1419],{"class":192},[175,4263,4264],{"class":4248},"ctx",[175,4266,4267],{"class":188}," context",[175,4269,512],{"class":192},[175,4271,4272],{"class":188},"Context",[175,4274,89],{"class":192},[175,4276,621],{"class":4248},[175,4278,89],{"class":192},[175,4280,2498],{"class":4248},[175,4282,4283],{"class":181}," string",[175,4285,4286],{"class":192},") (",[175,4288,4289],{"class":188},"User",[175,4291,89],{"class":192},[175,4293,4294],{"class":181},"error",[175,4296,4297],{"class":192},") {\n",[175,4299,4300,4303,4306],{"class":177,"line":285},[175,4301,4302],{"class":181},"    var",[175,4304,4305],{"class":192}," u ",[175,4307,4308],{"class":188},"User\n",[175,4310,4311,4314,4317,4320,4323,4326,4329],{"class":177,"line":297},[175,4312,4313],{"class":192},"    err ",[175,4315,4316],{"class":181},":=",[175,4318,4319],{"class":192}," r.db.",[175,4321,4322],{"class":188},"QueryRowContext",[175,4324,4325],{"class":192},"(ctx, createUserQuery, email, name).",[175,4327,4328],{"class":188},"Scan",[175,4330,4331],{"class":192},"(\n",[175,4333,4334,4337],{"class":177,"line":316},[175,4335,4336],{"class":181},"        &",[175,4338,4339],{"class":192},"u.ID,\n",[175,4341,4342,4344],{"class":177,"line":332},[175,4343,4336],{"class":181},[175,4345,4346],{"class":192},"u.Email,\n",[175,4348,4349,4351],{"class":177,"line":345},[175,4350,4336],{"class":181},[175,4352,4353],{"class":192},"u.Name,\n",[175,4355,4356,4358],{"class":177,"line":371},[175,4357,4336],{"class":181},[175,4359,4360],{"class":192},"u.CreatedAt,\n",[175,4362,4363],{"class":177,"line":386},[175,4364,1043],{"class":192},[175,4366,4367,4370,4373,4376,4379],{"class":177,"line":2450},[175,4368,4369],{"class":181},"    if",[175,4371,4372],{"class":192}," err ",[175,4374,4375],{"class":181},"!=",[175,4377,4378],{"class":364}," nil",[175,4380,470],{"class":192},[175,4382,4384,4387,4390],{"class":177,"line":4383},16,[175,4385,4386],{"class":181},"        return",[175,4388,4389],{"class":188}," User",[175,4391,4392],{"class":192},"{}, err\n",[175,4394,4396],{"class":177,"line":4395},17,[175,4397,4398],{"class":192},"    }\n",[175,4400,4402,4405,4408],{"class":177,"line":4401},18,[175,4403,4404],{"class":181},"    return",[175,4406,4407],{"class":192}," u, ",[175,4409,4410],{"class":364},"nil\n",[175,4412,4414],{"class":177,"line":4413},19,[175,4415,520],{"class":192},[15,4417,4418],{},"SQL-инъекции предотвращаются параметрами, а не ручным экранированием:",[133,4420,4422],{"className":451,"code":4421,"language":453,"meta":454,"style":138},"\u002F\u002F Плохо: пользовательский ввод попадает в SQL-текст.\nquery := \"SELECT id, email, name FROM users WHERE email = '\" + email + \"'\"\n\n\u002F\u002F Хорошо: значение передается отдельно от SQL-текста.\nrow := db.QueryRowContext(ctx,\n    \"SELECT id, email, name FROM users WHERE email = $1\",\n    email,\n)\n",[86,4423,4424,4429,4450,4454,4459,4474,4481,4486],{"__ignoreMap":138},[175,4425,4426],{"class":177,"line":178},[175,4427,4428],{"class":1865},"\u002F\u002F Плохо: пользовательский ввод попадает в SQL-текст.\n",[175,4430,4431,4434,4436,4439,4441,4444,4447],{"class":177,"line":196},[175,4432,4433],{"class":192},"query ",[175,4435,4316],{"class":181},[175,4437,4438],{"class":919}," \"SELECT id, email, name FROM users WHERE email = '\"",[175,4440,2586],{"class":181},[175,4442,4443],{"class":192}," email ",[175,4445,4446],{"class":181},"+",[175,4448,4449],{"class":919}," \"'\"\n",[175,4451,4452],{"class":177,"line":223},[175,4453,282],{"emptyLinePlaceholder":281},[175,4455,4456],{"class":177,"line":239},[175,4457,4458],{"class":1865},"\u002F\u002F Хорошо: значение передается отдельно от SQL-текста.\n",[175,4460,4461,4464,4466,4469,4471],{"class":177,"line":252},[175,4462,4463],{"class":192},"row ",[175,4465,4316],{"class":181},[175,4467,4468],{"class":192}," db.",[175,4470,4322],{"class":188},[175,4472,4473],{"class":192},"(ctx,\n",[175,4475,4476,4479],{"class":177,"line":272},[175,4477,4478],{"class":919},"    \"SELECT id, email, name FROM users WHERE email = $1\"",[175,4480,220],{"class":192},[175,4482,4483],{"class":177,"line":278},[175,4484,4485],{"class":192},"    email,\n",[175,4487,4488],{"class":177,"line":285},[175,4489,1010],{"class":192},[15,4491,4492],{},"Параметры защищают значения, но не имена колонок, направление сортировки и куски SQL-синтаксиса. Если пользователь выбирает сортировку, делайте whitelist:",[133,4494,4496],{"className":451,"code":4495,"language":453,"meta":454,"style":138},"sortColumn := map[string]string{\n    \"created_at\": \"created_at\",\n    \"email\":      \"email\",\n}[input.Sort]\nif sortColumn == \"\" {\n    return nil, ErrInvalidSort\n}\n",[86,4497,4498,4522,4535,4548,4553,4569,4578],{"__ignoreMap":138},[175,4499,4500,4503,4505,4508,4511,4514,4517,4519],{"class":177,"line":178},[175,4501,4502],{"class":192},"sortColumn ",[175,4504,4316],{"class":181},[175,4506,4507],{"class":181}," map",[175,4509,4510],{"class":192},"[",[175,4512,4513],{"class":181},"string",[175,4515,4516],{"class":192},"]",[175,4518,4513],{"class":181},[175,4520,4521],{"class":192},"{\n",[175,4523,4524,4527,4530,4533],{"class":177,"line":196},[175,4525,4526],{"class":919},"    \"created_at\"",[175,4528,4529],{"class":192},": ",[175,4531,4532],{"class":919},"\"created_at\"",[175,4534,220],{"class":192},[175,4536,4537,4540,4543,4546],{"class":177,"line":223},[175,4538,4539],{"class":919},"    \"email\"",[175,4541,4542],{"class":192},":      ",[175,4544,4545],{"class":919},"\"email\"",[175,4547,220],{"class":192},[175,4549,4550],{"class":177,"line":239},[175,4551,4552],{"class":192},"}[input.Sort]\n",[175,4554,4555,4558,4561,4564,4567],{"class":177,"line":252},[175,4556,4557],{"class":181},"if",[175,4559,4560],{"class":192}," sortColumn ",[175,4562,4563],{"class":181},"==",[175,4565,4566],{"class":919}," \"\"",[175,4568,470],{"class":192},[175,4570,4571,4573,4575],{"class":177,"line":272},[175,4572,4404],{"class":181},[175,4574,4378],{"class":364},[175,4576,4577],{"class":192},", ErrInvalidSort\n",[175,4579,4580],{"class":177,"line":278},[175,4581,520],{"class":192},[145,4583],{},[148,4585,4587],{"id":4586},"типичные-вопросы-на-собеседованиях","Типичные вопросы на собеседованиях",[531,4589,4591,4592,4594,4595,4597],{"id":4590},"чем-where-отличается-от-having","Чем ",[86,4593,1444],{}," отличается от ",[86,4596,1465],{},"?",[15,4599,4600,2149,4602,4604,4605,512],{},[86,4601,1444],{},[86,4603,1465],{}," фильтрует группы после ",[86,4606,1457],{},[133,4608,4610],{"className":169,"code":4609,"language":171,"meta":138,"style":138},"SELECT user_id, count(*)\nFROM orders\nWHERE status = 'paid'\nGROUP BY user_id\nHAVING count(*) > 3;\n",[86,4611,4612,4626,4632,4642,4648],{"__ignoreMap":138},[175,4613,4614,4616,4618,4620,4622,4624],{"class":177,"line":178},[175,4615,1336],{"class":181},[175,4617,1413],{"class":192},[175,4619,1416],{"class":364},[175,4621,1419],{"class":192},[175,4623,1422],{"class":181},[175,4625,1010],{"class":192},[175,4627,4628,4630],{"class":177,"line":196},[175,4629,1436],{"class":181},[175,4631,1439],{"class":192},[175,4633,4634,4636,4638,4640],{"class":177,"line":223},[175,4635,1444],{"class":181},[175,4637,1447],{"class":181},[175,4639,995],{"class":181},[175,4641,1452],{"class":919},[175,4643,4644,4646],{"class":177,"line":239},[175,4645,1457],{"class":181},[175,4647,1460],{"class":192},[175,4649,4650,4652,4654,4656,4658,4660,4662,4664],{"class":177,"line":252},[175,4651,1465],{"class":181},[175,4653,1468],{"class":364},[175,4655,1419],{"class":192},[175,4657,1422],{"class":181},[175,4659,1425],{"class":192},[175,4661,2129],{"class":181},[175,4663,2283],{"class":364},[175,4665,1501],{"class":192},[531,4667,4591,4669,4594,4671,4597],{"id":4668},"чем-inner-join-отличается-от-left-join",[86,4670,1717],{},[86,4672,1730],{},[15,4674,4675,4677,4678,4680,4681,512],{},[86,4676,1717],{}," возвращает только совпавшие строки. ",[86,4679,1730],{}," сохраняет все строки слева, а недостающие значения справа заполняет ",[86,4682,1736],{},[531,4684,4686,4687,401,4689,4691],{"id":4685},"почему-count-и-countcolumn-могут-отличаться","Почему ",[86,4688,1587],{},[86,4690,1591],{}," могут отличаться?",[15,4693,4694,4696,4697,1592,4699,512],{},[86,4695,1587],{}," считает строки. ",[86,4698,1591],{},[86,4700,1595],{},[531,4702,4686,4704,4706,4707,4597],{"id":4703},"почему-null-null-не-true",[86,4705,3267],{}," не ",[86,4708,3295],{},[15,4710,4711,4712,4714,4715,4717,4718,512],{},"Потому что ",[86,4713,1736],{}," означает неизвестное значение. Сравнение неизвестного с неизвестным дает ",[86,4716,3272],{},". Для проверки используется ",[86,4719,4720],{},"IS NULL",[531,4722,4724],{"id":4723},"что-быстрее-один-сложный-sql-или-много-простых-запросов-из-go","Что быстрее: один сложный SQL или много простых запросов из Go?",[15,4726,4727],{},"Зависит от плана и объема данных, но часто один осмысленный SQL лучше: меньше round-trip'ов, меньше передачи данных, база использует индексы и join-алгоритмы. Но огромный нечитаемый запрос без тестов и explain-проверки тоже опасен.",[531,4729,4731],{"id":4730},"зачем-foreign-key-если-приложение-и-так-проверяет-связи","Зачем foreign key, если приложение и так проверяет связи?",[15,4733,4734],{},"Потому что данные меняют не только один handler: есть фоновые задачи, админские скрипты, миграции, импорты, несколько версий сервиса. Foreign key защищает инвариант на уровне источника истины.",[531,4736,4738],{"id":4737},"что-такое-cte","Что такое CTE?",[15,4740,4741,4742,4744],{},"Именованный подзапрос в ",[86,4743,2304],{},". Он помогает структурировать SQL, переиспользовать промежуточный результат внутри запроса и писать рекурсивные запросы.",[531,4746,4748],{"id":4747},"что-такое-window-function","Что такое window function?",[15,4750,4751,4752,4754,4755,512],{},"Функция, которая считает значение по набору связанных строк, но не схлопывает результат как ",[86,4753,1457],{},". Например, ",[86,4756,4757],{},"row_number() OVER (PARTITION BY user_id ORDER BY created_at DESC)",[145,4759],{},[148,4761,4763],{"id":4762},"мини-чеклист-sql-запроса","Мини-чеклист SQL-запроса",[15,4765,4766],{},"Перед тем как отправить запрос в production, спросите себя:",[393,4768,4769,4772,4780,4790,4795,4798,4801,4808,4815,4820,4823],{},[396,4770,4771],{},"запрос параметризован?",[396,4773,4774,4775,4777,4778,4597],{},"есть ли стабильный ",[86,4776,1484],{},", если используется ",[86,4779,1495],{},[396,4781,4782,4783,4785,4786,1853,4788,4597],{},"не превращает ли ",[86,4784,1444],{}," ваш ",[86,4787,1730],{},[86,4789,1717],{},[396,4791,4792,4793,4597],{},"правильно ли обработан ",[86,4794,1736],{},[396,4796,4797],{},"не делает ли запрос N+1 round-trip'ов?",[396,4799,4800],{},"есть ли constraint, который защищает бизнес-уникальность?",[396,4802,4803,4804,4807],{},"проверен ли план через ",[86,4805,4806],{},"EXPLAIN"," для реального объема данных?",[396,4809,4810,4811,1718,4813,4597],{},"понятна ли ошибка пользователю, если сработает ",[86,4812,778],{},[86,4814,810],{},[396,4816,4817,4818,4597],{},"является ли список колонок явным контрактом, а не ",[86,4819,3825],{},[396,4821,4822],{},"проверяет ли запрос ownership\u002Ftenant scope там, где это нужно?",[396,4824,4825],{},"есть ли понятный retry\u002Fresponse path для ожидаемых ошибок PostgreSQL?",[145,4827],{},[148,4829,4831],{"id":4830},"что-запомнить","Что запомнить",[393,4833,4834,4837,4842,4852,4860,4866],{},[396,4835,4836],{},"Таблица хранит факты; constraint защищает правила этих фактов.",[396,4838,4839,4841],{},[86,4840,1517],{}," описывает связь наборов, а не \"цикл по строкам\".",[396,4843,4844,4846,4847,4849,4850,512],{},[86,4845,1444],{}," работает до группировки, ",[86,4848,1465],{}," - после ",[86,4851,1457],{},[396,4853,4854,4856,4857,4859],{},[86,4855,1736],{}," - неизвестность, поэтому нужны ",[86,4858,4720],{}," и аккуратная обработка в Go.",[396,4861,4862,4863,4865],{},"Параметры защищают значения от SQL injection; имена колонок и ",[86,4864,1484],{}," требуют whitelist.",[396,4867,4868,4870,4871,4873],{},[86,4869,1484],{}," обязателен, если результат с ",[86,4872,1495],{}," должен быть стабильным.",[145,4875],{},[148,4877,4879],{"id":4878},"практика","Практика",[1506,4881,4882,4897,4904,4907],{},[396,4883,4884,4885,89,4888,89,4891,89,4893,4896],{},"Спроектируйте таблицы ",[86,4886,4887],{},"courses",[86,4889,4890],{},"lessons",[86,4892,157],{},[86,4894,4895],{},"enrollments"," с primary key, foreign key и уникальностью slug\u002Femail.",[396,4898,4899,4900,401,4902,512],{},"Напишите запрос списка курсов с количеством уроков через ",[86,4901,1730],{},[86,4903,1457],{},[396,4905,4906],{},"Добавьте запрос \"последний просмотренный урок пользователя\" через window function.",[396,4908,4909,4910,4912],{},"Найдите в своих запросах места, где ",[86,4911,1736],{}," может изменить результат.",[145,4914],{},[148,4916,4918],{"id":4917},"интерактивная-практика","Интерактивная практика",[4920,4921,4924,4931,4963],"quiz",{"answer":3599,"id":4922,"xp":4923},"db-sql-q1","10",[15,4925,4686,4926,4928,4929,4597],{},[86,4927,1591],{}," может вернуть меньше, чем ",[86,4930,1587],{},[4932,4933,4934],"template",{"v-slot:options":138},[393,4935,4936,4941,4948,4955],{},[396,4937,4938,4940],{},[86,4939,1591],{}," считает только уникальные значения",[396,4942,4943,4945,4946],{},[86,4944,1591],{}," не считает строки, где column равен ",[86,4947,1736],{},[396,4949,4950,4952,4953],{},[86,4951,1591],{}," работает только после ",[86,4954,1457],{},[396,4956,4957,4959,4960,4962],{},[86,4958,1587],{}," всегда использует индекс, а ",[86,4961,1591],{}," нет",[4932,4964,4965],{"v-slot:explanation":138},[15,4966,4967,4969,4970,4972],{},[86,4968,1587],{}," считает строки результата, а ",[86,4971,1591],{}," считает только non-null значения выбранной колонки.",[4974,4975,4979,4985,5087],"predict",{"answer":4976,"id":4977,"xp":4978},"1\\n0","db-sql-p1","15",[15,4980,4981,4982,4597],{},"Какие значения вернёт колонка ",[86,4983,4984],{},"published_count",[4932,4986,4987],{"v-slot:code":138},[133,4988,4990],{"className":169,"code":4989,"language":171,"meta":138,"style":138},"WITH lessons(course_id, published) AS (\n    VALUES\n        (1, true),\n        (1, false),\n        (2, false)\n)\nSELECT count(*) FILTER (WHERE published) AS published_count\nFROM lessons\nGROUP BY course_id\nORDER BY course_id;\n",[86,4991,4992,5003,5008,5017,5026,5035,5039,5066,5073,5080],{"__ignoreMap":138},[175,4993,4994,4996,4999,5001],{"class":177,"line":178},[175,4995,2304],{"class":181},[175,4997,4998],{"class":192}," lessons(course_id, published) ",[175,5000,1428],{"class":181},[175,5002,193],{"class":192},[175,5004,5005],{"class":177,"line":196},[175,5006,5007],{"class":181},"    VALUES\n",[175,5009,5010,5012,5014],{"class":177,"line":223},[175,5011,990],{"class":192},[175,5013,2503],{"class":364},[175,5015,5016],{"class":192},", true),\n",[175,5018,5019,5021,5023],{"class":177,"line":239},[175,5020,990],{"class":192},[175,5022,2503],{"class":364},[175,5024,5025],{"class":192},", false),\n",[175,5027,5028,5030,5032],{"class":177,"line":252},[175,5029,990],{"class":192},[175,5031,3599],{"class":364},[175,5033,5034],{"class":192},", false)\n",[175,5036,5037],{"class":177,"line":272},[175,5038,1010],{"class":192},[175,5040,5041,5043,5045,5047,5049,5051,5054,5056,5058,5061,5063],{"class":177,"line":278},[175,5042,1336],{"class":181},[175,5044,1468],{"class":364},[175,5046,1419],{"class":192},[175,5048,1422],{"class":181},[175,5050,1425],{"class":192},[175,5052,5053],{"class":181},"FILTER",[175,5055,909],{"class":192},[175,5057,1444],{"class":181},[175,5059,5060],{"class":192}," published) ",[175,5062,1428],{"class":181},[175,5064,5065],{"class":192}," published_count\n",[175,5067,5068,5070],{"class":177,"line":285},[175,5069,1436],{"class":181},[175,5071,5072],{"class":192}," lessons\n",[175,5074,5075,5077],{"class":177,"line":297},[175,5076,1457],{"class":181},[175,5078,5079],{"class":192}," course_id\n",[175,5081,5082,5084],{"class":177,"line":316},[175,5083,1484],{"class":181},[175,5085,5086],{"class":192}," course_id;\n",[4932,5088,5089],{"v-slot:hint":138},[15,5090,5091,5093,5094,5096,5097,512],{},[86,5092,5053],{}," считает только строки, попавшие в условие, а ",[86,5095,1457],{}," оставит отдельный результат для каждого ",[86,5098,5099],{},"course_id",[145,5101],{},[148,5103,5105],{"id":5104},"полезные-источники","Полезные источники",[393,5107,5108,5118,5125,5132],{},[396,5109,5110,5111],{},"PostgreSQL Documentation: ",[5112,5113,5117],"a",{"href":5114,"rel":5115},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fsql.html",[5116],"nofollow","The SQL Language",[396,5119,5110,5120],{},[5112,5121,5124],{"href":5122,"rel":5123},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fddl-constraints.html",[5116],"Constraints",[396,5126,5110,5127],{},[5112,5128,5131],{"href":5129,"rel":5130},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fqueries-with.html",[5116],"WITH Queries",[396,5133,5110,5134],{},[5112,5135,5138],{"href":5136,"rel":5137},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Ftutorial-window.html",[5116],"Window Functions",[5140,5141,5142],"style",{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":138,"searchDepth":196,"depth":196,"links":5144},[5145,5146,5154,5165,5168,5169,5183,5184,5185,5186,5187],{"id":150,"depth":196,"text":151},{"id":528,"depth":196,"text":529,"children":5147},[5148,5149,5150,5151,5152,5153],{"id":533,"depth":223,"text":534},{"id":736,"depth":223,"text":737},{"id":1055,"depth":223,"text":1056},{"id":1059,"depth":223,"text":1060},{"id":1121,"depth":223,"text":1122},{"id":1213,"depth":223,"text":1214},{"id":1279,"depth":196,"text":1280,"children":5155},[5156,5157,5158,5159,5160,5161,5162,5163,5164],{"id":1283,"depth":223,"text":1284},{"id":1397,"depth":223,"text":1398},{"id":1600,"depth":223,"text":1517},{"id":2024,"depth":223,"text":2025},{"id":2290,"depth":223,"text":2291},{"id":2655,"depth":223,"text":2656},{"id":2847,"depth":223,"text":2848},{"id":3234,"depth":223,"text":3235},{"id":3558,"depth":223,"text":3559},{"id":3796,"depth":196,"text":3797,"children":5166},[5167],{"id":4021,"depth":223,"text":4022},{"id":4104,"depth":196,"text":4105},{"id":4586,"depth":196,"text":4587,"children":5170},[5171,5173,5175,5177,5179,5180,5181,5182],{"id":4590,"depth":223,"text":5172},"Чем WHERE отличается от HAVING?",{"id":4668,"depth":223,"text":5174},"Чем INNER JOIN отличается от LEFT JOIN?",{"id":4685,"depth":223,"text":5176},"Почему COUNT(*) и COUNT(column) могут отличаться?",{"id":4703,"depth":223,"text":5178},"Почему NULL = NULL не TRUE?",{"id":4723,"depth":223,"text":4724},{"id":4730,"depth":223,"text":4731},{"id":4737,"depth":223,"text":4738},{"id":4747,"depth":223,"text":4748},{"id":4762,"depth":196,"text":4763},{"id":4830,"depth":196,"text":4831},{"id":4878,"depth":196,"text":4879},{"id":4917,"depth":196,"text":4918},{"id":5104,"depth":196,"text":5105},1781022065408]