Informačné technológieInformation Technologies / Týždeň 3Week 3

Kostra projektu a modelyProject skeleton and models

Dnes z dátového modelu vzniknú skutočné tabuľky v databáze a prvý kód projektu. Dostaneš hotovú kostru: Composer, jedno pripojenie k databáze, rodičovskú triedu pre číselníky a vzor pre hlavnú entitu. Ty doplníš SQL pre svoje tabuľky, testovacie dáta a modely pre svoju tému. Na konci cvičenia máš vo svojom webovom priestore na sigma.tuke.sk stránku, ktorá vypíše záznamy z každej tvojej tabuľky. Pojmy sú v tabuľke pojmov.

Today the data model turns into real tables in the database and the first code of the project. You get a ready-made skeleton: Composer, one database connection, a parent class for lookup tables and an example for the main entity. You add the SQL for your tables, test data and the models for your topic. By the end of the lab you have a page in your web space on sigma.tuke.sk that lists the records of every table. The terms are in the table of terms.

TeóriaTheory

Prečo kostra vyzerá práve takto

Štruktúra, ktorú dostávaš, nie je vymyslená pre tento predmet. Je to tvar, ktorý má každý moderný PHP projekt: Laravel, Symfony, aj každý balík, ktorý si stiahneš z Packagist (verejný sklad PHP knižníc). Všade nájdeš to isté:

  • composer.json v koreni – popis projektu a pravidlo, kde sú triedy. Framework Laravel má v ňom "App\\": "app/", Symfony "App\\": "src/"; my máme to isté ako Symfony.
  • src/ (alebo app/) – vlastný kód projektu, rozdelený do podpriečinkov podľa úlohy: Model/ pre prácu s dátami, od 5. týždňa Controller/ pre rozhodovanie a View/ pre výstup. Symfony aj Laravel majú priečinky Controller a Model doslova.
  • vendor/ – cudzí kód, nikdy sa doň nesiaha rukou, vždy ho vyrobí Composer.
  • jeden vstupný súbor index.php, ktorý načíta autoloader a spustí aplikáciu. Vo frameworkoch je to public/index.php; u nás leží v koreni, lebo webový priestor na sigma.tuke.sk nemá samostatný priečinok public, a práve preto kód chránime cez .htaccess.
  • konfigurácia mimo kódu – heslá a nastavenia servera v súbore, ktorý nie je v repository (Laravel a Symfony používajú .env, my config.php; princíp je rovnaký).
  • SQL v repository – schéma databázy je súčasť projektu, nie niečo, čo existuje len v databáze. Frameworky tomu hovoria migrácie (téma 9. týždňa); schema.sql je ich najjednoduchšia podoba.

Dôvod, prečo sa to oplatí naučiť: kto pozná tento tvar, otvorí cudzí projekt v Laravel alebo Symfony a vie, kde čo hľadať. A naopak, kto raz postavil kostru vlastnými rukami, rozumie, čo framework robí automaticky. Pri obhajobe sa budeme pýtať: prečo je vendor/ v .gitignore, prečo config.php nie je v repository, prečo trieda App\Model\X leží práve v src/Model/X.php.

Od dátového modelu k tabuľkám

Dátový model z minulého týždňa je návrh. Tabuľky vznikajú príkazom CREATE TABLE, pre každú tabuľku jeden. Na poradí záleží: tabuľka s cudzím kľúčom sa dá vytvoriť až vtedy, keď existuje tabuľka, na ktorú kľúč odkazuje. Preto sa najprv vytvárajú číselníky, potom hlavná entita a nakoniec prepojky. Pri mazaní je poradie opačné.

Každá tabuľka má ENGINE=InnoDB (jediný typ tabuliek v MariaDB, ktorý cudzie kľúče naozaj kontroluje) a kódovanie utf8mb4 (slovenčina aj emoji). Primárny kľúč je id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY: celé nezáporné číslo, ktoré databáza prideľuje sama. Cudzí kľúč sa zapisuje FOREIGN KEY (difficulty_id) REFERENCES difficulties(id) ON DELETE RESTRICT; pravidlo ON DELETE je to isté, ktoré si zdôvodňoval v README.

SQL pre tabuľky nepíšeš do Adminer a nezabudneš. Ukladá sa do repository ako súbor sql/schema.sql, a testovacie dáta ako sql/seed.sql. Keď si databázu pokazíš, spustíš oba súbory znova a máš čistý stav. Keď niekto iný otvorí tvoj projekt, z týchto dvoch súborov si vyrobí rovnakú databázu. Na začiatku schema.sql je preto DROP TABLE IF EXISTS pre každú tabuľku: súbor sa dá spustiť opakovane.

Viac: W3Schools: SQL FOREIGN KEY

JOIN: spojenie dvoch tabuliek v jednom dotaze

V tabuľke recipes je stĺpec difficulty_id s číslom. Používateľ chce vidieť „stredná”, nie „2”. Text je v tabuľke difficulties. JOIN povie databáze, aby ku každému riadku receptu pripojila riadok obtiažnosti, ktorý k nemu patrí:

SELECT r.title, r.prep_time, d.name AS difficulty_name
FROM recipes r
JOIN difficulties d ON d.id = r.difficulty_id
ORDER BY r.id DESC;

Ako to čítať:

  • FROM recipes r – začíname od receptov; r je skratka (alias), aby sme nemuseli písať recipes. pred každým stĺpcom.
  • JOIN difficulties d ON d.id = r.difficulty_id – ku každému receptu pripoj tú obtiažnosť, ktorej id sa rovná difficulty_id receptu. Podmienka za ON je vždy „primárny kľúč jednej tabuľky = cudzí kľúč druhej”.
  • d.name AS difficulty_name – stĺpec name z obtiažnosti premenujeme, aby sa v PHP nepomýlil s iným name; v $row['difficulty_name'] je potom text.
  • Výsledok má toľko riadkov, koľko je receptov; každý má navyše stĺpce z obtiažnosti.

Je to ten istý JOIN, ktorý poznáš: JOIN (presne INNER JOIN) vráti len recepty, ktoré obtiažnosť majú – u nás vždy všetky, lebo difficulty_id je NOT NULL. LEFT JOIN by vrátil aj recepty bez obtiažnosti s NULL v difficulty_name; použijeme ho v 9. týždni pri štatistikách, kde chceme aj číselníkové hodnoty s nulovým počtom. Pri prepojkách (M:N) sa spájajú tri tabuľky za sebou: recipes → recipe_ingredients → ingredients; to príde v 5. týždni pri detaile receptu.

Viac: W3Schools: SQL JOIN

Composer a priečinok vendor

Composer je program pre príkazový riadok, ktorý sa stará o cudzí kód v PHP projekte. Je to pre PHP to, čo je npm pre JavaScript alebo pip pre Python. V Kubeflow je už nainštalovaný; na sigma.tuke.sk ho nespúšťame, tam sa len kopíruje výsledok.

Composer robí dve veci:

  1. Sťahuje knižnice. Keď v 6. týždni budeme potrebovať Twig, nenapíšeme ho sami ani nestiahneme ručne; do composer.json pribudne riadok "twig/twig": "^3.0" a Composer ho stiahne aj so všetkým, čo Twig sám potrebuje.
  2. Vyrába autoloader. Súbor vendor/autoload.php, ktorý PHP naučí, kde hľadať triedy (vysvetlené nižšie pri PSR-4). Tento týždeň používame len túto druhú vec.

Všetko, čo Composer potrebuje vedieť, je v súbore composer.json v koreni projektu. V našom sú štyri časti:

Časť Čo znamená
name, description, type meno a popis projektu; pre nás len informatívne
require čo projekt potrebuje; zatiaľ len "php": ">=8.1" – od 6. týždňa sem pribudnú knižnice
autoload → psr-4 pravidlo "App\\": "src/": triedy s adresou App\… sú v priečinku src/
config → platform "php": "8.1.2": Composer v Kubeflow beží na PHP 8.3, ale sigma.tuke.sk má 8.1; týmto riadkom vyberá len balíky, ktoré fungujú na 8.1

Príkazy, ktoré budeš používať:

Príkaz Čo urobí Kedy
composer install podľa composer.json vyrobí priečinok vendor/ a vendor/autoload.php po každom clone a vždy, keď sa zmení composer.json
composer require twig/twig pridá knižnicu do composer.json a stiahne ju 6. týždeň
composer dump-autoload len znovu vyrobí autoloader keď pridáš nový priečinok do src/ a PHP triedu nenájde

Priečinok vendor/ je výsledok, nie zdroj. Do repository sa nedáva (je v .gitignore), lebo sa dá kedykoľvek vyrobiť znova z composer.json – rovnako ako sa do repository nedáva skompilovaný program, len jeho zdrojový kód. Dôsledok pre náš postup: po každom clone treba spustiť composer install, a keďže vendor/ nevzniká v editore, na sigma.tuke.sk ho dostaneš cez Upload Folder. Po composer install vznikne aj composer.lock – záznam presných verzií, ktoré sa nainštalovali; ten do repository patrí, aby mal každý rovnaké verzie.

Viac: getcomposer.org: Basic usage

Namespace: trieda má adresu

Každá trieda má meno, napríklad RecipeModel. V malom projekte to stačí. Vo väčšom projekte, kde sú aj cudzie knižnice, sa môžu dve triedy volať rovnako (každá knižnica môže mať svoju triedu User) a PHP by nevedelo, ktorú použiť. Preto má trieda okrem mena aj adresu, ktorej sa hovorí namespace. Píše sa na začiatok súboru:

namespace App\Model;

class RecipeModel
{
    // ...
}

Plné meno tejto triedy je App\Model\RecipeModel: trieda RecipeModel v namespace App\Model. Čítaj to ako cestu: projekt App, časť Model, trieda RecipeModel. Dve triedy User v rôznych namespace si už neprekážajú, lebo majú rôzne plné mená.

Keď chceš triedu použiť v inom súbore, napíšeš hore use s plným menom, a ďalej už stačí krátke:

use App\Model\RecipeModel;

$model = new RecipeModel();

Bez use by si musel písať new \App\Model\RecipeModel() vždy celé. Oboje funguje; use je kratšie a používa sa všade.

PSR-4: adresa je zároveň cesta k súboru

Namespace rieši mená. Zostáva otázka, ako PHP nájde súbor, v ktorom je trieda. Doteraz si to riešil ručne: require_once 'RecipeModel.php' v každom súbore, ktorý triedu potreboval. Pri desiatich triedach je to desať riadkov, pri stovke neudržateľné.

PSR-4 je dohoda, že namespace zodpovedá priečinku a meno triedy názvu súboru. V composer.json je jedno pravidlo:

"autoload": {
    "psr-4": { "App\\": "src/" }
}

Hovorí: všetko, čo začína App\, hľadaj v priečinku src/. Zvyšok adresy je cesta. Takže:

Plné meno triedy Súbor
App\Database src/Database.php
App\Model\RecipeModel src/Model/RecipeModel.php
App\Model\CategoryModel src/Model/CategoryModel.php

Composer z tohto pravidla vyrobí vendor/autoload.php. Ten súbor zaregistruje v PHP funkciu, ktorá sa spustí vždy, keď kód použije triedu, ktorú PHP ešte nepozná: podľa plného mena zloží cestu k súboru a súbor načíta. Tebe ostáva jediný require v celej aplikácii, v index.php:

require __DIR__ . '/vendor/autoload.php';

Od tej chvíle každá trieda „nájde sama seba”. Všetky require_once zmiznú. Dve pravidlá, ktoré musíš dodržať, inak autoloader súbor nenájde: jedna trieda = jeden súbor, a názov súboru sa presne zhoduje s menom triedy vrátane veľkých písmen (CategoryModel.php, nie categorymodel.php).

Viac: PHP-FIG: PSR-4

Kde sú prihlasovacie údaje a ako vzniká pripojenie

Prihlasovacie údaje k databáze – server, názov databázy, používateľ, heslo – sú na jednom jedinom mieste: v súbore config.php v koreni projektu. Vyzerá takto (s tvojimi údajmi):

config.php
<?php
return [
    'host'   => 'localhost',        // databáza beží na tom istom serveri ako PHP
    'dbname' => 'jana_novakova',    // tvoja databáza – rovnaká ako login do Adminer
    'user'   => 'jana_novakova',    // login do databázy
    'pass'   => 'heslo-z-cvicenia',
    'debug'  => true,               // true = chyby vidieť v prehliadači (počas vývoja)
];

Súbor nerobí nič iné, než že vráti pole (return [...]). Kto údaje potrebuje, napíše $c = require 'config.php'; a má ich v $c['host'], $c['user'] atď. V celej aplikácii ich potrebuje jediná trieda: Database.

Prečo samostatný súbor: heslo nesmie byť v kóde, ktorý ide do repository. config.php je preto v .gitignore. V repository je len config.example.php – rovnaký súbor s prázdnym heslom, aby každý videl, aké položky treba vyplniť. Po clone si config.php vyrobíš kópiou (cp config.example.php config.php), doplníš údaje a uložíš; uložením v editore sa dostane aj na sigma.tuke.sk. Na GitHub nikdy nepôjde.

Pripojenie k databáze vzniká v triede Database v src/Database.php. Je to singleton: trieda, ktorá dovolí vytvoriť jediný objekt. Konštruktor je private, takže new Database() z iného miesta nejde; jediná cesta je Database::getInstance(). Tá pri prvom volaní prečíta config.php, zloží z neho DSN a vytvorí PDO; pri každom ďalšom volaní vráti to isté PDO, ktoré si drží v statickej vlastnosti $instance. Celá aplikácia tak zdieľa jedno pripojenie, nech sa getInstance() zavolá kdekoľvek a koľkokoľvek – každý model si ho vypýta vo svojom konštruktore.

DSN (data source name) je reťazec, ktorý PDO povie, kam sa pripojiť: mysql:host=localhost;dbname=jana_novakova;charset=utf8mb4. Skladá sa z hodnôt v config.php; používateľ a heslo idú do PDO ako samostatné parametre, nie do DSN. Na sigma.tuke.sk je databáza na tom istom serveri ako PHP, preto host je localhost.

Modely: opakovanie objektov a čo sa deje v kostre

Model je trieda, ktorá pracuje s jednou tabuľkou. Všetko ostatné v aplikácii (stránky, formuláre, neskôr API) sa k dátam dostáva len cez modely, nikdy priamym SQL. Pripomeňme si, z čoho sa trieda skladá, lebo v kostre je to všetko použité:

Pojem V kóde Čo to je
trieda class RecipeModel { } predpis: aké vlastnosti a metódy bude mať objekt
objekt $m = new RecipeModel(); konkrétna vec vytvorená podľa predpisu
vlastnosť protected string $table; premenná, ktorú objekt nesie v sebe
metóda public function getAll(): array funkcia patriaca objektu; : array je typ, čo vracia
$this $this->pdo odkaz na „tento objekt”, vnútri jeho metód
konštruktor public function __construct() metóda, ktorá sa spustí pri new
viditeľnosť public, protected, private kto smie k vlastnosti/metóde: ktokoľvek / trieda a potomkovia / len trieda
dedenie class CategoryModel extends BaseModel potomok dostane všetko public a protected od rodiča
abstraktná trieda abstract class BaseModel trieda len na dedenie, new BaseModel() nejde; abstraktná metóda nemá telo a potomok ju musí napísať
interface interface ModelInterface / implements zoznam metód bez kódu; trieda, ktorá ho implementuje, musí všetky napísať
statická vlastnosť private static ?PDO $instance patrí triede, nie objektu; všetky objekty ju zdieľajú
typ s otáznikom ?array, ?PDO hodnota daného typu alebo null

Interface ModelInterface hovorí, čo musí vedieť každý model: getAll(), getById(), getCount(), delete(), describe(). Nemá žiadny kód, len podpisy. Načo je dobrý: index.php prejde v jednom cykle všetky modely a na každom zavolá describe() a getAll() bez toho, aby vedel, ktorý je ktorý. PHP garantuje, že každá trieda s implements ModelInterface tie metódy má – inak by sa súbor ani nenačítal.

Číselníky dedia z BaseModel. Všetky číselníky sú rovnaké: tabuľka s id a name, vždy tie isté štyri dotazy. Preto sú tie dotazy napísané raz, v abstraktnej triede BaseModel, a každý číselník ich zdedí. Potomok má vlastného kódu päť riadkov: nastaví $table a napíše describe(). $table je protected, nie private, práve preto, aby ju potomok mohol nastaviť a rodičove metódy ju videli. BaseModel je abstract, lebo bez $table nemá zmysel; describe() je abstraktná, lebo každý číselník sa opíše inak.

Hlavná entita nededí. Jej getAll() potrebuje JOIN, aby namiesto difficulty_id = 2 vrátila aj text „stredná”. getById() tiež. Z BaseModel by jej ostalo takmer nič, a dediť kvôli dvom metódam, ktoré aj tak prepíšeš, je horšie ako napísať si ich sám. Preto hlavná entita implementuje ModelInterface priamo. Pravidlo: dedí sa vtedy, keď sa kód naozaj zdieľa, nie preto, že sa to dá.

Prepared statements (poznáš z OOP): SQL s pomenovaným placeholderom :id ide do databázy osobitne, hodnota osobitne v execute([':id' => $id]). Databáza hodnotu nikdy nepovažuje za SQL, takže SQL injection nie je možný. Dotazy bez vstupu od používateľa (SELECT * FROM … ORDER BY id) idú cez query(). Názov tabuľky v {$this->table} nie je vstup od používateľa – nastavuje ho kód triedy – preto môže byť v reťazci.

Čo nesmie byť vidieť z webu

Celý projekt leží vo webovom priestore, takže v zásade sa každý súbor dá otvoriť cez prehliadač. src/, vendor/ a sql/ nie sú stránky a config.php obsahuje heslo. Kostra to rieši v .htaccess: RedirectMatch 404 pre priečinky s kódom a <Files "config.php"> Require all denied </Files> pre konfiguráciu. Server odpovie 404 alebo 403 skôr, než sa k súboru dostane. Overené na sigma.tuke.sk; v úlohe 3.7 si to vyskúšaš sám.

Najčastejšie chyby a čo znamenajú

Čo vidíš Príčina Riešenie
Class "App\Model\CategoryModel" not found názov súboru sa nezhoduje s menom triedy, alebo chýba namespace App\Model; v súbore, alebo nebol spustený composer install skontroluj meno súboru vrátane veľkých písmen; composer dump-autoload
Failed to open stream: vendor/autoload.php vendor/ nie je na sigma.tuke.sk composer install v Kubeflow, potom Upload Folder
SQLSTATE[HY000] [1045] Access denied zlý používateľ alebo heslo v config.php porovnaj s prihlásením do Adminer
SQLSTATE[HY000] [1049] Unknown database preklep v dbname názov databázy = tvoj login do Adminer
SQLSTATE[42S02] Base table or view not found tabuľka neexistuje – schema.sql nebol spustený alebo má iný názov tabuľky než model spusti schema.sql v Adminer; porovnaj $table s CREATE TABLE
SQLSTATE[23000] [1451] Cannot delete or update a parent row mažeš záznam, ktorý používa iná tabuľka s ON DELETE RESTRICT to je správne správanie; najprv zmaž závislé záznamy
prázdna biela stránka chyba PHP a debug je false v config.php nastav 'debug' => true

Why the skeleton looks the way it does

The structure you get was not invented for this course. It is the shape of every modern PHP project: Laravel, Symfony, and every package you download from Packagist (the public repository of PHP libraries). Everywhere you find the same things:

  • composer.json in the root – the project description and the rule where the classes are. Laravel has "App\\": "app/" in it, Symfony "App\\": "src/"; we have the same as Symfony.
  • src/ (or app/) – the project’s own code, split into subfolders by role: Model/ for data, from week 5 Controller/ for decisions and View/ for output. Symfony and Laravel have folders literally called Controller and Model.
  • vendor/ – third-party code, never touched by hand, always generated by Composer.
  • one entry file index.php, which loads the autoloader and starts the application. In frameworks it is public/index.php; ours lies in the root because the web space on sigma.tuke.sk has no separate public folder, which is exactly why we protect the code with .htaccess.
  • configuration outside the code – passwords and server settings in a file that is not in the repository (Laravel and Symfony use .env, we use config.php; the principle is the same).
  • SQL in the repository – the database schema is part of the project, not something that exists only in the database. Frameworks call these migrations (week 9); schema.sql is their simplest form.

Why it is worth learning: whoever knows this shape opens somebody else’s Laravel or Symfony project and knows where to look. And the other way round: whoever has built the skeleton with their own hands understands what a framework does automatically. At the defence we will ask: why is vendor/ in .gitignore, why is config.php not in the repository, why does the class App\Model\X live exactly in src/Model/X.php.

From the data model to tables

Last week’s data model is a design. Tables are created with CREATE TABLE, one for each table. The order matters: a table with a foreign key can only be created once the table it points to exists. That is why lookup tables come first, then the main entity, and the pivot tables last. For dropping, the order is reversed.

Every table has ENGINE=InnoDB (the only table type in MariaDB that really checks foreign keys) and the utf8mb4 encoding (Slovak and emoji). The primary key is id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY: a non-negative integer the database assigns itself. A foreign key is written as FOREIGN KEY (difficulty_id) REFERENCES difficulties(id) ON DELETE RESTRICT; the ON DELETE rule is the one you justified in the README.

You do not type the SQL for the tables into Adminer and forget it. It is stored in the repository as the file sql/schema.sql, and the test data as sql/seed.sql. If you break your database, you run both files again and have a clean state. When somebody else opens your project, they build the same database from these two files. That is why schema.sql starts with DROP TABLE IF EXISTS for every table: the file can be run repeatedly.

More: W3Schools: SQL FOREIGN KEY

JOIN: two tables in one query

The table recipes has the column difficulty_id with a number. The user wants to see “medium”, not “2”. The text is in the table difficulties. JOIN tells the database to attach to every recipe row the difficulty row that belongs to it:

SELECT r.title, r.prep_time, d.name AS difficulty_name
FROM recipes r
JOIN difficulties d ON d.id = r.difficulty_id
ORDER BY r.id DESC;

How to read it:

  • FROM recipes r – we start from the recipes; r is an alias so that we do not write recipes. before every column.
  • JOIN difficulties d ON d.id = r.difficulty_id – to every recipe attach the difficulty whose id equals the recipe’s difficulty_id. The condition after ON is always “primary key of one table = foreign key of the other”.
  • d.name AS difficulty_name – the column name from the difficulty is renamed so that it is not confused with another name in PHP; $row['difficulty_name'] then holds the text.
  • The result has as many rows as there are recipes; each has the difficulty columns in addition.

It is the same JOIN you know: JOIN (precisely INNER JOIN) returns only recipes that have a difficulty – for us always all of them, because difficulty_id is NOT NULL. LEFT JOIN would also return recipes without a difficulty with NULL in difficulty_name; we use it in week 9 for statistics, where we also want lookup values with a count of zero. With pivot tables (M:N) three tables are joined in a row: recipes → recipe_ingredients → ingredients; that comes in week 5 with the recipe detail.

More: W3Schools: SQL JOIN

Composer and the vendor folder

Composer is a command-line program that manages third-party code in a PHP project. It is for PHP what npm is for JavaScript or pip for Python. It is already installed in Kubeflow; we never run it on sigma.tuke.sk, only the result is copied there.

Composer does two things:

  1. It downloads libraries. When we need Twig in week 6, we neither write it ourselves nor download it by hand; a line "twig/twig": "^3.0" is added to composer.json and Composer downloads it together with everything Twig itself needs.
  2. It generates the autoloader. The file vendor/autoload.php, which teaches PHP where to find classes (explained below under PSR-4). This week we use only this second thing.

Everything Composer needs to know is in the file composer.json in the project root. Ours has four parts:

Part Meaning
name, description, type project name and description; informational for us
require what the project needs; for now only "php": ">=8.1" – from week 6 libraries are added here
autoload → psr-4 the rule "App\\": "src/": classes with the address App\… are in the src/ folder
config → platform "php": "8.1.2": Composer in Kubeflow runs on PHP 8.3, but sigma.tuke.sk has 8.1; with this line it picks only packages that work on 8.1

Commands you will use:

Command What it does When
composer install creates the vendor/ folder and vendor/autoload.php according to composer.json after every clone and whenever composer.json changes
composer require twig/twig adds a library to composer.json and downloads it week 6
composer dump-autoload only regenerates the autoloader when you add a new folder to src/ and PHP cannot find a class

The vendor/ folder is a result, not a source. It is not put into the repository (it is in .gitignore), because it can be regenerated from composer.json at any time – just as a compiled program is not put into a repository, only its source code. The consequence for our workflow: after every clone you run composer install, and since vendor/ is not created in the editor, you get it to sigma.tuke.sk with Upload Folder. composer install also creates composer.lock – a record of the exact versions installed; that one does belong in the repository so that everybody has the same versions.

More: getcomposer.org: Basic usage

Namespace: a class has an address

Every class has a name, for example RecipeModel. In a small project that is enough. In a bigger project with third-party libraries, two classes can have the same name (every library may have its own User class) and PHP would not know which one to use. That is why a class has, besides its name, an address, called a namespace. It is written at the top of the file:

namespace App\Model;

class RecipeModel
{
    // ...
}

The full name of this class is App\Model\RecipeModel: the class RecipeModel in the namespace App\Model. Read it like a path: project App, part Model, class RecipeModel. Two User classes in different namespaces no longer clash, because their full names differ.

When you want to use the class in another file, you write use with the full name at the top, and then the short name is enough:

use App\Model\RecipeModel;

$model = new RecipeModel();

Without use you would have to write new \App\Model\RecipeModel() in full every time. Both work; use is shorter and used everywhere.

PSR-4: the address is also the path to the file

Namespaces solve names. The remaining question is how PHP finds the file the class is in. Until now you did it by hand: require_once 'RecipeModel.php' in every file that needed the class. With ten classes that is ten lines; with a hundred it is unmaintainable.

PSR-4 is the convention that the namespace matches the folder and the class name matches the file name. In composer.json there is one rule:

"autoload": {
    "psr-4": { "App\\": "src/" }
}

It says: everything starting with App\ is in the src/ folder. The rest of the address is the path. So:

Full class name File
App\Database src/Database.php
App\Model\RecipeModel src/Model/RecipeModel.php
App\Model\CategoryModel src/Model/CategoryModel.php

From this rule Composer generates vendor/autoload.php. That file registers a function in PHP which runs whenever the code uses a class PHP does not know yet: from the full name it builds the path to the file and loads it. You are left with a single require in the whole application, in index.php:

require __DIR__ . '/vendor/autoload.php';

From then on every class “finds itself”. All require_once lines disappear. Two rules you must keep, otherwise the autoloader will not find the file: one class = one file, and the file name matches the class name exactly, including capital letters (CategoryModel.php, not categorymodel.php).

More: PHP-FIG: PSR-4

Where the login details are and how the connection is made

The database login details – server, database name, user, password – are in one single place: the file config.php in the project root. It looks like this (with your details):

config.php
<?php
return [
    'host'   => 'localhost',        // the database runs on the same server as PHP
    'dbname' => 'jana_novakova',    // your database – the same as your Adminer login
    'user'   => 'jana_novakova',    // database login
    'pass'   => 'password-from-lab',
    'debug'  => true,               // true = errors shown in the browser (during development)
];

The file does nothing except return an array (return [...]). Whoever needs the details writes $c = require 'config.php'; and has them in $c['host'], $c['user'] and so on. In the whole application only one class needs them: Database.

Why a separate file: the password must not be in code that goes into the repository. That is why config.php is in .gitignore. The repository only has config.example.php – the same file with an empty password, so that everybody sees which items to fill in. After the clone you create config.php as a copy (cp config.example.php config.php), fill in the details and save; saving in the editor also gets it to sigma.tuke.sk. It never goes to GitHub.

The database connection is made in the class Database in src/Database.php. It is a singleton: a class that allows only one object to be created. The constructor is private, so new Database() from elsewhere is not possible; the only way is Database::getInstance(). On the first call it reads config.php, builds the DSN from it and creates the PDO; on every later call it returns the same PDO, which it keeps in the static property $instance. The whole application thus shares one connection, no matter where and how many times getInstance() is called – every model asks for it in its constructor.

A DSN (data source name) is the string that tells PDO where to connect: mysql:host=localhost;dbname=jana_novakova;charset=utf8mb4. It is built from the values in config.php; the user and the password go to PDO as separate parameters, not into the DSN. On sigma.tuke.sk the database is on the same server as PHP, hence host is localhost.

Models: a recap of objects and what happens in the skeleton

A model is a class that works with one table. Everything else in the application (pages, forms, later the API) reaches the data only through models, never with direct SQL. Let us recall what a class consists of, because all of it is used in the skeleton:

Term In code What it is
class class RecipeModel { } a blueprint: which properties and methods an object will have
object $m = new RecipeModel(); a concrete thing created from the blueprint
property protected string $table; a variable the object carries inside
method public function getAll(): array a function belonging to the object; : array is the type it returns
$this $this->pdo a reference to “this object”, inside its methods
constructor public function __construct() the method that runs on new
visibility public, protected, private who may access a property/method: anybody / the class and its children / the class only
inheritance class CategoryModel extends BaseModel the child gets everything public and protected from the parent
abstract class abstract class BaseModel a class meant only to be inherited, new BaseModel() is not possible; an abstract method has no body and the child must write it
interface interface ModelInterface / implements a list of methods without code; a class implementing it must write them all
static property private static ?PDO $instance belongs to the class, not to an object; all objects share it
nullable type ?array, ?PDO a value of that type or null

The interface ModelInterface says what every model must be able to do: getAll(), getById(), getCount(), delete(), describe(). It has no code, only signatures. What it is good for: index.php goes through all models in one loop and calls describe() and getAll() on each, without knowing which is which. PHP guarantees that every class with implements ModelInterface has those methods – otherwise the file would not even load.

Lookup models inherit from BaseModel. All lookup tables are alike: a table with id and name, always the same four queries. That is why those queries are written once, in the abstract class BaseModel, and every lookup model inherits them. The child has five lines of its own code: it sets $table and writes describe(). $table is protected, not private, precisely so that the child can set it and the parent’s methods can see it. BaseModel is abstract because without $table it makes no sense; describe() is abstract because every lookup table describes itself differently.

The main entity does not inherit. Its getAll() needs a JOIN so that instead of difficulty_id = 2 it also returns the text “medium”. So does getById(). Almost nothing of BaseModel would remain, and inheriting for the sake of two methods you override anyway is worse than writing them yourself. That is why the main entity implements ModelInterface directly. The rule: inherit when code is really shared, not just because it is possible.

Prepared statements (you know them from OOP): the SQL with a named placeholder :id goes to the database separately, the value separately in execute([':id' => $id]). The database never treats the value as SQL, so SQL injection is not possible. Queries without user input (SELECT * FROM … ORDER BY id) go through query(). The table name in {$this->table} is not user input – the class code sets it – so it may be in the string.

What must not be visible from the web

The whole project lies in your web space, so in principle every file can be opened in the browser. src/, vendor/ and sql/ are not pages, and config.php contains the password. The skeleton handles this in .htaccess: RedirectMatch 404 for the code folders and <Files "config.php"> Require all denied </Files> for the configuration. The server answers 404 or 403 before it reaches the file. Verified on sigma.tuke.sk; in task 3.7 you check it yourself.

The most common errors and what they mean

What you see Cause Fix
Class "App\Model\CategoryModel" not found the file name does not match the class name, or namespace App\Model; is missing in the file, or composer install was not run check the file name including capital letters; composer dump-autoload
Failed to open stream: vendor/autoload.php vendor/ is not on sigma.tuke.sk composer install in Kubeflow, then Upload Folder
SQLSTATE[HY000] [1045] Access denied wrong user or password in config.php compare with your Adminer login
SQLSTATE[HY000] [1049] Unknown database typo in dbname the database name = your Adminer login
SQLSTATE[42S02] Base table or view not found the table does not exist – schema.sql was not run, or the model has a different table name run schema.sql in Adminer; compare $table with CREATE TABLE
SQLSTATE[23000] [1451] Cannot delete or update a parent row you are deleting a record another table uses with ON DELETE RESTRICT that is correct behaviour; delete the dependent records first
a blank white page a PHP error and debug is false set 'debug' => true in config.php

UkážkyExamples

Ukážka 1: štruktúra kostry

Kostra je na stiahnutie ako kostra.zip; rozbalená do priečinka projektu vyzerá takto:

cv02/
├── .htaccess              src/, vendor/, sql/ a config.php nie sú z webu prístupné
├── .gitignore             config.php, vendor/, .vscode/ nejdú do repository
├── composer.json          autoload PSR-4: App\ -> src/
├── config.example.php     vzor konfigurácie bez hesla (v repository)
├── config.php             tvoja konfigurácia s heslom (len v Kubeflow a na sigma.tuke.sk)
├── index.php              kontrolná stránka
├── sql/
│   ├── schema.sql         CREATE TABLE pre všetky tabuľky
│   └── seed.sql           testovacie dáta
├── src/
│   ├── Database.php       App\Database – jedno PDO pripojenie
│   └── Model/
│       ├── ModelInterface.php
│       ├── BaseModel.php  rodič číselníkov
│       ├── LookupModel.php  vzor číselníka (zmažeš, keď máš svoje)
│       └── MainModel.php    vzor hlavnej entity (zmažeš, keď máš svoje)
└── vendor/                vyrobí composer install (nie je v repository)
    └── autoload.php

Ukážka 2: schema.sql vzorového projektu

Tabuľky receptov z minulého týždňa, v poradí, v akom sa dajú vytvoriť. Všimni si, že DROP TABLE ide v opačnom poradí ako CREATE TABLE.

sql/schema.sql
-- Mazanie v opačnom poradí: najprv prepojky, potom hlavná entita, nakoniec číselníky.
DROP TABLE IF EXISTS recipe_ingredients;
DROP TABLE IF EXISTS recipe_categories;
DROP TABLE IF EXISTS recipes;
DROP TABLE IF EXISTS units;
DROP TABLE IF EXISTS ingredients;
DROP TABLE IF EXISTS categories;
DROP TABLE IF EXISTS difficulties;

-- Číselník: obtiažnosť receptu (ľahký, stredný, náročný).
CREATE TABLE difficulties (
    id   INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Číselník: kategória jedla (polievka, hlavné jedlo, dezert).
CREATE TABLE categories (
    id   INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Číselník: surovina.
CREATE TABLE ingredients (
    id   INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(80) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Číselník: merná jednotka (g, ml, ks).
CREATE TABLE units (
    id           INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    name         VARCHAR(30) NOT NULL,
    abbreviation VARCHAR(10) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Hlavná entita: recept.
CREATE TABLE recipes (
    id            INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    title         VARCHAR(120) NOT NULL,
    servings      TINYINT UNSIGNED NOT NULL DEFAULT 4,
    prep_time     SMALLINT UNSIGNED NOT NULL,          -- minúty
    instructions  TEXT NOT NULL,
    created_at    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    difficulty_id INT UNSIGNED NOT NULL,
    -- 1:N na číselník. RESTRICT: obtiažnosť sa nedá zmazať, kým ju používa recept.
    FOREIGN KEY (difficulty_id) REFERENCES difficulties(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Prepojka bez atribútov: recept × kategória.
CREATE TABLE recipe_categories (
    recipe_id   INT UNSIGNED NOT NULL,
    category_id INT UNSIGNED NOT NULL,
    PRIMARY KEY (recipe_id, category_id),
    -- CASCADE: so zmazaným receptom zmiznú aj jeho prepojenia.
    FOREIGN KEY (recipe_id)   REFERENCES recipes(id)    ON DELETE CASCADE,
    -- RESTRICT: kategória sa nedá zmazať, kým ju používa recept.
    FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Prepojka s atribútmi: recept × surovina, s množstvom a jednotkou.
CREATE TABLE recipe_ingredients (
    recipe_id     INT UNSIGNED NOT NULL,
    ingredient_id INT UNSIGNED NOT NULL,
    amount        DECIMAL(8,2) NOT NULL,
    note          VARCHAR(100) NULL,
    unit_id       INT UNSIGNED NOT NULL,
    PRIMARY KEY (recipe_id, ingredient_id),
    FOREIGN KEY (recipe_id)     REFERENCES recipes(id)     ON DELETE CASCADE,
    FOREIGN KEY (ingredient_id) REFERENCES ingredients(id) ON DELETE RESTRICT,
    FOREIGN KEY (unit_id)       REFERENCES units(id)       ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Ukážka 3: composer.json

composer.json
{
    "name": "tuke/it-projekt",
    "description": "Semestrálny projekt – Informačné technológie",
    "type": "project",
    "require": {
        "php": ">=8.1"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "config": {
        "platform": {
            "php": "8.1.2"
        }
    }
}

Jediné, čo sa v ňom počas semestra zmení, je require: keď v 6. týždni pridáme Twig, pribudne riadok a composer install ho stiahne.

Ukážka 4: Database.php – jedno pripojenie

src/Database.php
<?php
declare(strict_types=1);

namespace App;

use PDO;

final class Database
{
    private static ?PDO $instance = null;

    // Súkromný konštruktor: new Database() nie je možné.
    private function __construct()
    {
    }

    public static function getInstance(): PDO
    {
        if (self::$instance === null) {
            $c   = require __DIR__ . '/../config.php';
            $dsn = "mysql:host={$c['host']};dbname={$c['dbname']};charset=utf8mb4";

            self::$instance = new PDO($dsn, $c['user'], $c['pass'], [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES   => false,
            ]);
        }

        return self::$instance;
    }
}

Čo robí ktorý riadok v Database.php:

  • declare(strict_types=1); – PHP nebude ticho prevádzať typy (reťazec na číslo a pod.); chyba typu sa ozve hneď. Píšeme do každého súboru.
  • namespace App; – adresa triedy; plné meno je App\Database, súbor je src/Database.php.
  • use PDO; – PDO je zabudovaná trieda PHP bez namespace; keďže my sme v namespace App, musíme povedať, že myslíme „tú globálnu”.
  • final class – z triedy sa nedá dediť; pre singleton je to úmysel.
  • private static ?PDO $instance = null; – jediné miesto, kde pripojenie žije. static = patrí triede, nie objektu; ?PDO = PDO alebo null; na začiatku null, lebo pripojenie ešte neexistuje.
  • private function __construct() {} – súkromný konštruktor: new Database() zvonku skončí chybou. Toto robí zo triedy singleton.
  • if (self::$instance === null) – self:: je prístup k statickej vlastnosti vlastnej triedy. Pripojenie sa vytvorí len prvýkrát.
  • require __DIR__ . '/../config.php' – __DIR__ je priečinok tohto súboru (src/), .. ide o úroveň vyššie, kde je config.php. require tu vráti pole z return.
  • new PDO($dsn, $c['user'], $c['pass'], [...]) – samotné pripojenie. Tretí a štvrtý parameter sú používateľ a heslo z config.php.
  • ATTR_ERRMODE => ERRMODE_EXCEPTION – chyba v SQL vyhodí výnimku (PDOException), ktorú vidíš v prehliadači, keď je debug zapnutý; bez toho by chyby zostali ticho.
  • ATTR_DEFAULT_FETCH_MODE => FETCH_ASSOC – riadky z databázy prídu ako asociatívne polia ($row['title']), nie aj s číselnými kľúčmi.
  • ATTR_EMULATE_PREPARES => false – prepared statements rieši databáza, nie PHP; bezpečnejšie a typy sedia.
  • return self::$instance; – vždy to isté PDO.

Ukážka 5: ModelInterface a BaseModel

src/Model/ModelInterface.php
<?php
declare(strict_types=1);

namespace App\Model;

interface ModelInterface
{
    public function getAll(): array;
    public function getById(int $id): ?array;
    public function getCount(): int;
    public function delete(int $id): bool;
    public function describe(): string;
}
src/Model/BaseModel.php
<?php
declare(strict_types=1);

namespace App\Model;

use App\Database;
use PDO;

abstract class BaseModel implements ModelInterface
{
    protected PDO $pdo;
    protected string $table;   // nastaví každý potomok

    public function __construct()
    {
        $this->pdo = Database::getInstance();
    }

    public function getAll(): array
    {
        return $this->pdo->query("SELECT * FROM {$this->table} ORDER BY id")->fetchAll();
    }

    public function getById(int $id): ?array
    {
        $stmt = $this->pdo->prepare("SELECT * FROM {$this->table} WHERE id = :id");
        $stmt->execute([':id' => $id]);
        $row = $stmt->fetch();
        return $row === false ? null : $row;
    }

    public function getCount(): int
    {
        return (int) $this->pdo->query("SELECT COUNT(*) FROM {$this->table}")->fetchColumn();
    }

    public function delete(int $id): bool
    {
        $stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE id = :id");
        $stmt->execute([':id' => $id]);
        return $stmt->rowCount() > 0;
    }

    abstract public function describe(): string;
}

Čo robí ktorý riadok v BaseModel.php:

  • abstract class BaseModel implements ModelInterface – rodič číselníkov; zaväzuje sa k rozhraniu, ale sám sa nedá vytvoriť.
  • protected PDO $pdo; – pripojenie, ktoré budú používať všetky metódy; protected, aby ho videli aj potomkovia.
  • protected string $table; – názov tabuľky, zámerne bez hodnoty: doplní ho každý potomok.
  • $this->pdo = Database::getInstance(); – v konštruktore si model vypýta spoločné pripojenie. Potomok konštruktor nepíše, zdedí tento.
  • getAll(): query() spustí dotaz bez parametrov, fetchAll() vráti všetky riadky ako pole polí. {$this->table} sa doplní do reťazca.
  • getById(): prepare() pripraví dotaz s :id, execute([':id' => $id]) dosadí hodnotu, fetch() vráti jeden riadok alebo false, keď nič nenašiel. Preto return $row === false ? null : $row; – metóda sľubuje ?array, teda pole alebo null, nie false.
  • getCount(): fetchColumn() vráti prvý stĺpec prvého riadka – pri COUNT(*) priamo číslo; (int) ho prevedie z reťazca na celé číslo, lebo metóda sľubuje int.
  • delete(): po execute() povie rowCount(), koľko riadkov sa zmazalo; > 0 dá true/false. Ak záznam používa iná tabuľka s ON DELETE RESTRICT, databáza zmazanie odmietne a vyhodí výnimku – to je správne správanie, nie chyba kódu.
  • abstract public function describe(): string; – bez tela; každý potomok ju musí napísať.

Ukážka 6: modely vzorového projektu

Číselník má päť riadkov vlastného kódu. Hlavná entita nededí, lebo jej dotazy sú iné: JOIN doplní názov obtiažnosti.

src/Model/DifficultyModel.php
<?php
declare(strict_types=1);

namespace App\Model;

class DifficultyModel extends BaseModel
{
    protected string $table = 'difficulties';

    public function describe(): string
    {
        return 'Číselník obtiažností';
    }
}
src/Model/RecipeModel.php
<?php
declare(strict_types=1);

namespace App\Model;

use App\Database;
use PDO;

class RecipeModel implements ModelInterface
{
    private PDO $pdo;

    public function __construct()
    {
        $this->pdo = Database::getInstance();
    }

    // Recepty aj s názvom obtiažnosti, najnovšie prvé.
    public function getAll(): array
    {
        $sql = "SELECT r.*, d.name AS difficulty_name
                FROM recipes r
                JOIN difficulties d ON d.id = r.difficulty_id
                ORDER BY r.id DESC";
        return $this->pdo->query($sql)->fetchAll();
    }

    public function getById(int $id): ?array
    {
        $sql = "SELECT r.*, d.name AS difficulty_name
                FROM recipes r
                JOIN difficulties d ON d.id = r.difficulty_id
                WHERE r.id = :id";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([':id' => $id]);
        $row = $stmt->fetch();
        return $row === false ? null : $row;
    }

    public function getCount(): int
    {
        return (int) $this->pdo->query("SELECT COUNT(*) FROM recipes")->fetchColumn();
    }

    public function delete(int $id): bool
    {
        $stmt = $this->pdo->prepare("DELETE FROM recipes WHERE id = :id");
        $stmt->execute([':id' => $id]);
        return $stmt->rowCount() > 0;
    }

    public function describe(): string
    {
        return 'Recepty';
    }
}

Čo robí ktorý riadok v modeloch:

  • DifficultyModel extends BaseModel – zdedí konštruktor, $pdo a štyri metódy. protected string $table = 'difficulties'; prepíše rodičovu prázdnu vlastnosť hodnotou. describe() je jediná metóda, ktorú musí napísať, lebo v rodičovi je abstraktná. Celá trieda má päť riadkov vlastného kódu – to je zmysel dedenia.
  • RecipeModel implements ModelInterface – nededí, preto má vlastné private PDO $pdo; (tu môže byť private, nikto z nej nededí) a vlastný konštruktor, ktorý si vypýta pripojenie rovnako ako BaseModel.
  • SELECT r.*, d.name AS difficulty_name FROM recipes r JOIN difficulties d ON d.id = r.difficulty_id – r a d sú skratky (aliasy) tabuliek. r.* = všetky stĺpce receptu, d.name AS difficulty_name = k tomu názov obtiažnosti pod zrozumiteľným menom. JOIN … ON spojí riadok receptu s riadkom obtiažnosti, ktorý má rovnaké id ako difficulty_id. Vo výsledku je $row['difficulty_name'] = „stredná” namiesto čísla 2.
  • ORDER BY r.id DESC – najnovšie recepty prvé; číselníky majú ORDER BY id, lebo poradie vloženia je aj poradie zobrazenia.
  • getById() s tým istým JOIN a WHERE r.id = :id – pri aliasoch treba povedať, ktorej tabuľky id myslíme.
  • getCount() a delete() sú rovnaké ako v BaseModel, len s pevným názvom tabuľky.

V index.php sa potom modely použijú rovnako, bez ohľadu na to, či dedia alebo nie:

$models = [
    new \App\Model\DifficultyModel(),
    new \App\Model\CategoryModel(),
    new \App\Model\RecipeModel(),
];

Čo robí index.php z kostry (kontrolná stránka):

  • require __DIR__ . '/vendor/autoload.php'; – jediný require v aplikácii; od tohto riadka PHP nájde každú triedu App\… samo.
  • $config = require __DIR__ . '/config.php'; a if ($config['debug']) { ini_set('display_errors', '1'); error_reporting(E_ALL); } – sigma.tuke.sk má zobrazovanie chýb vypnuté; počas vývoja ich chceme vidieť, preto ich zapneme, keď je v konfigurácii debug na true.
  • use App\Database; a $pdo = Database::getInstance(); – prvé a jediné vytvorenie pripojenia; modely dostanú to isté.
  • $pdo->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN) – zoznam tabuliek v databáze ako jednoduché pole názvov; funguje ešte pred tým, než máš akýkoľvek model.
  • $models = [ new \App\Model\CategoryModel(), … ]; – zoznam tvojich modelov; doplníš jeden riadok na triedu. Plné meno s \ na začiatku preto, že index.php nie je v žiadnom namespace a use sme použili len pre Database.
  • foreach ($models as $model) – pre každý model describe(), getCount() a prvých päť riadkov z getAll() cez array_slice. Hlavičku tabuľky vyrobí array_keys($rows[0]) – názvy stĺpcov z prvého riadka.
  • htmlspecialchars(...) pri každom výpise – hodnoty z databázy sa nevypisujú priamo, aby prípadné < v dátach nerozbilo stránku; ochrana pred XSS, podrobnejšie v 6. týždni.

Ukážka 7: prompt na tabuľky a testovacie dáta

Nižšie je dátový model mojej aplikácie (tabuľky, stĺpce, vzťahy, pravidlá ON DELETE).
Vyrob z neho dva súbory pre MariaDB 10.6.

1. schema.sql
- Na začiatku DROP TABLE IF EXISTS pre všetky tabuľky v poradí, v akom sa dajú zmazať
  (prepojky, potom hlavná entita, potom číselníky).
- CREATE TABLE v poradí, v akom sa dajú vytvoriť (číselníky, hlavná entita, prepojky).
- Každá tabuľka: ENGINE=InnoDB, DEFAULT CHARSET=utf8mb4, COLLATE=utf8mb4_unicode_ci.
- Primárny kľúč: id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY.
- Cudzie kľúče ako FOREIGN KEY ... REFERENCES ... s pravidlom ON DELETE presne podľa modelu.
- Pri každej tabuľke a každom cudzom kľúči krátky SQL komentár (-- ...) po slovensky,
  čo tabuľka drží a prečo platí dané pravidlo ON DELETE.
- Nepíš CREATE DATABASE ani USE.

2. seed.sql
- INSERT do každej tabuľky, 3 až 5 riadkov, zmysluplné hodnoty po slovensky k mojej téme.
- Poradie vkladania: číselníky, hlavná entita, prepojky; cudzie kľúče odkazujú na existujúce id.

Dátový model:
[SEM VLOŽ DÁTOVÝ MODEL Z README]

Prečo je prompt napísaný práve takto:

  • Poradie mazania a vytvárania je vymenované, lebo AI ho pri prepojkách často preto hodí a schema.sql potom padne na cudzom kľúči.
  • ENGINE, CHARSET a tvar primárneho kľúča sú predpísané, aby všetky tabuľky v skupine vyzerali rovnako a dali sa porovnávať so vzorom.
  • Komentáre pri ON DELETE žiadame preto, že pri obhajobe vysvetľuješ každé pravidlo.
  • CREATE DATABASE a USE zakazujeme, lebo databázu už máš a cudzia by na sigma.tuke.sk nešla vytvoriť.
  • Seed s reálnymi hodnotami k tvojej téme: stránka s výpisom má o čom hovoriť a pri obhajobe vidno, že dátam rozumieš.

Ukážka 8: prompt na modely

Prikladám kostru PHP projektu (namespace App\Model, autoload PSR-4 cez Composer):
ModelInterface.php, BaseModel.php, LookupModel.php (vzor číselníka), MainModel.php
(vzor hlavnej entity) a svoj schema.sql.

Vyrob pre moje tabuľky triedy modelov, pre každú jeden súbor do src/Model/:
- pre každý číselník triedu podľa vzoru LookupModel: extends BaseModel, nastav $table,
  describe() vráti krátky popis po slovensky; nič iné nepridávaj,
- pre hlavnú entitu triedu podľa vzoru MainModel: implements ModelInterface, getAll() a
  getById() s JOIN na číselník pripojený vzťahom 1:N (vráť aj jeho name pod zrozumiteľným
  aliasom), getCount(), delete(), describe(); prepared statements s pomenovanými
  placeholdermi.
Názvy tried: anglicky, jednotné číslo, prípona Model (categories -> CategoryModel).
Každá trieda má PHP 8.1 syntax: declare(strict_types=1), typy parametrov a návratových hodnôt.
Nad každou metódou jednoriadkový komentár po slovensky, čo robí.
Na konci napíš riadky, ktoré mám vložiť do poľa $models v index.php.

[SEM VLOŽ OBSAH ŠTYROCH SÚBOROV KOSTRY A SCHEMA.SQL]

Prečo je prompt napísaný práve takto:

  • Prikladáš kostru, nie opis kostry: AI vidí presný namespace, mená metód a štýl, a výsledok do kostry zapadne bez úprav.
  • „Nič iné nepridávaj” pri číselníkoch bráni tomu, aby AI do päťriadkovej triedy dopísala vlastné getAll() a dedenie stratilo zmysel.
  • Pravidlo pre názvy tried je tu preto, že názov súboru musí sedieť s menom triedy, inak PSR-4 autoloader súbor nenájde.
  • PHP 8.1 je uvedené výslovne, lebo AI inak rado použije novšie konštrukcie, ktoré na sigma.tuke.sk nebežia.
  • Riadky pre $models na konci ušetria preklepy v plných menách tried.

Example 1: the skeleton structure

The skeleton is available as kostra.zip; unpacked into the project folder it looks like this:

cv02/
├── .htaccess              src/, vendor/, sql/ and config.php are not reachable from the web
├── .gitignore             config.php, vendor/, .vscode/ do not go into the repository
├── composer.json          PSR-4 autoload: App\ -> src/
├── config.example.php     configuration example without the password (in the repository)
├── config.php             your configuration with the password (only in Kubeflow and on sigma.tuke.sk)
├── index.php              check page
├── sql/
│   ├── schema.sql         CREATE TABLE for all tables
│   └── seed.sql           test data
├── src/
│   ├── Database.php       App\Database – one PDO connection
│   └── Model/
│       ├── ModelInterface.php
│       ├── BaseModel.php  parent of lookup models
│       ├── LookupModel.php  lookup table example (delete once you have your own)
│       └── MainModel.php    main entity example (delete once you have your own)
└── vendor/                created by composer install (not in the repository)
    └── autoload.php

Example 2: schema.sql of the sample project

The recipe tables from last week, in the order they can be created. Note that DROP TABLE goes in the reverse order of CREATE TABLE.

sql/schema.sql
-- Dropping in reverse order: pivot tables first, then the main entity, lookup tables last.
DROP TABLE IF EXISTS recipe_ingredients;
DROP TABLE IF EXISTS recipe_categories;
DROP TABLE IF EXISTS recipes;
DROP TABLE IF EXISTS units;
DROP TABLE IF EXISTS ingredients;
DROP TABLE IF EXISTS categories;
DROP TABLE IF EXISTS difficulties;

-- Lookup table: recipe difficulty (easy, medium, hard).
CREATE TABLE difficulties (
    id   INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Lookup table: meal category (soup, main course, dessert).
CREATE TABLE categories (
    id   INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Lookup table: ingredient.
CREATE TABLE ingredients (
    id   INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(80) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Lookup table: unit of measure (g, ml, pcs).
CREATE TABLE units (
    id           INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    name         VARCHAR(30) NOT NULL,
    abbreviation VARCHAR(10) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Main entity: recipe.
CREATE TABLE recipes (
    id            INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    title         VARCHAR(120) NOT NULL,
    servings      TINYINT UNSIGNED NOT NULL DEFAULT 4,
    prep_time     SMALLINT UNSIGNED NOT NULL,          -- minutes
    instructions  TEXT NOT NULL,
    created_at    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    difficulty_id INT UNSIGNED NOT NULL,
    -- 1:N to a lookup table. RESTRICT: a difficulty cannot be deleted while a recipe uses it.
    FOREIGN KEY (difficulty_id) REFERENCES difficulties(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Pivot table without attributes: recipe × category.
CREATE TABLE recipe_categories (
    recipe_id   INT UNSIGNED NOT NULL,
    category_id INT UNSIGNED NOT NULL,
    PRIMARY KEY (recipe_id, category_id),
    -- CASCADE: when a recipe is deleted, its links go with it.
    FOREIGN KEY (recipe_id)   REFERENCES recipes(id)    ON DELETE CASCADE,
    -- RESTRICT: a category cannot be deleted while a recipe uses it.
    FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Pivot table with attributes: recipe × ingredient, with amount and unit.
CREATE TABLE recipe_ingredients (
    recipe_id     INT UNSIGNED NOT NULL,
    ingredient_id INT UNSIGNED NOT NULL,
    amount        DECIMAL(8,2) NOT NULL,
    note          VARCHAR(100) NULL,
    unit_id       INT UNSIGNED NOT NULL,
    PRIMARY KEY (recipe_id, ingredient_id),
    FOREIGN KEY (recipe_id)     REFERENCES recipes(id)     ON DELETE CASCADE,
    FOREIGN KEY (ingredient_id) REFERENCES ingredients(id) ON DELETE RESTRICT,
    FOREIGN KEY (unit_id)       REFERENCES units(id)       ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Example 3: composer.json

composer.json
{
    "name": "tuke/it-projekt",
    "description": "Semester project – Information Technologies",
    "type": "project",
    "require": {
        "php": ">=8.1"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "config": {
        "platform": {
            "php": "8.1.2"
        }
    }
}

The only thing that changes in it during the semester is require: when we add Twig in week 6, a line is added and composer install downloads it.

Example 4: Database.php – one connection

src/Database.php
<?php
declare(strict_types=1);

namespace App;

use PDO;

final class Database
{
    private static ?PDO $instance = null;

    // Private constructor: new Database() is not possible.
    private function __construct()
    {
    }

    public static function getInstance(): PDO
    {
        if (self::$instance === null) {
            $c   = require __DIR__ . '/../config.php';
            $dsn = "mysql:host={$c['host']};dbname={$c['dbname']};charset=utf8mb4";

            self::$instance = new PDO($dsn, $c['user'], $c['pass'], [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES   => false,
            ]);
        }

        return self::$instance;
    }
}

What each line in Database.php does:

  • declare(strict_types=1); – PHP will not silently convert types (string to number etc.); a type error shows up immediately. We write it in every file.
  • namespace App; – the class address; the full name is App\Database, the file is src/Database.php.
  • use PDO; – PDO is a built-in PHP class without a namespace; since we are inside the namespace App, we have to say we mean “the global one”.
  • final class – the class cannot be inherited from; for a singleton that is intentional.
  • private static ?PDO $instance = null; – the only place where the connection lives. static = belongs to the class, not to an object; ?PDO = PDO or null; null at the start because the connection does not exist yet.
  • private function __construct() {} – private constructor: new Database() from outside ends with an error. This is what makes the class a singleton.
  • if (self::$instance === null) – self:: accesses a static property of the class itself. The connection is created only the first time.
  • require __DIR__ . '/../config.php' – __DIR__ is the folder of this file (src/), .. goes one level up, where config.php is. require here returns the array from return.
  • new PDO($dsn, $c['user'], $c['pass'], [...]) – the connection itself. The third and fourth parameters are the user and the password from config.php.
  • ATTR_ERRMODE => ERRMODE_EXCEPTION – an SQL error throws an exception (PDOException), which you see in the browser when debug is on; without it errors would stay silent.
  • ATTR_DEFAULT_FETCH_MODE => FETCH_ASSOC – rows from the database come as associative arrays ($row['title']), without numeric keys as well.
  • ATTR_EMULATE_PREPARES => false – prepared statements are handled by the database, not by PHP; safer and the types match.
  • return self::$instance; – always the same PDO.

Example 5: ModelInterface and BaseModel

src/Model/ModelInterface.php
<?php
declare(strict_types=1);

namespace App\Model;

interface ModelInterface
{
    public function getAll(): array;
    public function getById(int $id): ?array;
    public function getCount(): int;
    public function delete(int $id): bool;
    public function describe(): string;
}
src/Model/BaseModel.php
<?php
declare(strict_types=1);

namespace App\Model;

use App\Database;
use PDO;

abstract class BaseModel implements ModelInterface
{
    protected PDO $pdo;
    protected string $table;   // set by every child

    public function __construct()
    {
        $this->pdo = Database::getInstance();
    }

    public function getAll(): array
    {
        return $this->pdo->query("SELECT * FROM {$this->table} ORDER BY id")->fetchAll();
    }

    public function getById(int $id): ?array
    {
        $stmt = $this->pdo->prepare("SELECT * FROM {$this->table} WHERE id = :id");
        $stmt->execute([':id' => $id]);
        $row = $stmt->fetch();
        return $row === false ? null : $row;
    }

    public function getCount(): int
    {
        return (int) $this->pdo->query("SELECT COUNT(*) FROM {$this->table}")->fetchColumn();
    }

    public function delete(int $id): bool
    {
        $stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE id = :id");
        $stmt->execute([':id' => $id]);
        return $stmt->rowCount() > 0;
    }

    abstract public function describe(): string;
}

What each line in BaseModel.php does:

  • abstract class BaseModel implements ModelInterface – the parent of lookup models; it commits to the interface but cannot be created itself.
  • protected PDO $pdo; – the connection all methods will use; protected so that children see it too.
  • protected string $table; – the table name, deliberately without a value: every child fills it in.
  • $this->pdo = Database::getInstance(); – in the constructor the model asks for the shared connection. The child does not write a constructor; it inherits this one.
  • getAll(): query() runs a query without parameters, fetchAll() returns all rows as an array of arrays. {$this->table} is inserted into the string.
  • getById(): prepare() prepares the query with :id, execute([':id' => $id]) supplies the value, fetch() returns one row or false when nothing was found. Hence return $row === false ? null : $row; – the method promises ?array, i.e. an array or null, not false.
  • getCount(): fetchColumn() returns the first column of the first row – with COUNT(*) that is the number itself; (int) converts it from a string to an integer, because the method promises int.
  • delete(): after execute(), rowCount() says how many rows were deleted; > 0 gives true/false. If another table uses the record with ON DELETE RESTRICT, the database refuses the delete and throws an exception – that is correct behaviour, not a bug.
  • abstract public function describe(): string; – without a body; every child must write it.

Example 6: the sample project’s models

A lookup model has five lines of its own code. The main entity does not inherit, because its queries differ: a JOIN adds the difficulty name.

src/Model/DifficultyModel.php
<?php
declare(strict_types=1);

namespace App\Model;

class DifficultyModel extends BaseModel
{
    protected string $table = 'difficulties';

    public function describe(): string
    {
        return 'Difficulty lookup table';
    }
}
src/Model/RecipeModel.php
<?php
declare(strict_types=1);

namespace App\Model;

use App\Database;
use PDO;

class RecipeModel implements ModelInterface
{
    private PDO $pdo;

    public function __construct()
    {
        $this->pdo = Database::getInstance();
    }

    // Recipes with the difficulty name, newest first.
    public function getAll(): array
    {
        $sql = "SELECT r.*, d.name AS difficulty_name
                FROM recipes r
                JOIN difficulties d ON d.id = r.difficulty_id
                ORDER BY r.id DESC";
        return $this->pdo->query($sql)->fetchAll();
    }

    public function getById(int $id): ?array
    {
        $sql = "SELECT r.*, d.name AS difficulty_name
                FROM recipes r
                JOIN difficulties d ON d.id = r.difficulty_id
                WHERE r.id = :id";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([':id' => $id]);
        $row = $stmt->fetch();
        return $row === false ? null : $row;
    }

    public function getCount(): int
    {
        return (int) $this->pdo->query("SELECT COUNT(*) FROM recipes")->fetchColumn();
    }

    public function delete(int $id): bool
    {
        $stmt = $this->pdo->prepare("DELETE FROM recipes WHERE id = :id");
        $stmt->execute([':id' => $id]);
        return $stmt->rowCount() > 0;
    }

    public function describe(): string
    {
        return 'Recipes';
    }
}

What each line in the models does:

  • DifficultyModel extends BaseModel – inherits the constructor, $pdo and the four methods. protected string $table = 'difficulties'; overrides the parent’s empty property with a value. describe() is the only method it must write, because it is abstract in the parent. The whole class has five lines of its own code – that is the point of inheritance.
  • RecipeModel implements ModelInterface – does not inherit, so it has its own private PDO $pdo; (it may be private here, nobody inherits from it) and its own constructor, which asks for the connection just like BaseModel.
  • SELECT r.*, d.name AS difficulty_name FROM recipes r JOIN difficulties d ON d.id = r.difficulty_id – r and d are table aliases. r.* = all columns of the recipe, d.name AS difficulty_name = plus the difficulty name under a readable name. JOIN … ON pairs the recipe row with the difficulty row whose id equals difficulty_id. In the result $row['difficulty_name'] is “medium” instead of the number 2.
  • ORDER BY r.id DESC – newest recipes first; lookup tables use ORDER BY id, because insertion order is also display order.
  • getById() with the same JOIN and WHERE r.id = :id – with aliases you must say which table’s id you mean.
  • getCount() and delete() are the same as in BaseModel, only with a fixed table name.

In index.php the models are then used the same way, whether they inherit or not:

$models = [
    new \App\Model\DifficultyModel(),
    new \App\Model\CategoryModel(),
    new \App\Model\RecipeModel(),
];

What the skeleton’s index.php (the check page) does:

  • require __DIR__ . '/vendor/autoload.php'; – the only require in the application; from this line on PHP finds every App\… class itself.
  • $config = require __DIR__ . '/config.php'; and if ($config['debug']) { ini_set('display_errors', '1'); error_reporting(E_ALL); } – sigma.tuke.sk has error display switched off; during development we want to see errors, so we switch them on when debug is true in the configuration.
  • use App\Database; and $pdo = Database::getInstance(); – the first and only creation of the connection; the models get the same one.
  • $pdo->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN) – the list of tables in the database as a plain array of names; works even before you have any model.
  • $models = [ new \App\Model\CategoryModel(), … ]; – the list of your models; you add one line per class. The full name with a leading \ because index.php is in no namespace and we used use only for Database.
  • foreach ($models as $model) – for every model describe(), getCount() and the first five rows of getAll() via array_slice. The table header comes from array_keys($rows[0]) – the column names of the first row.
  • htmlspecialchars(...) at every output – values from the database are not printed directly, so that a < in the data cannot break the page; protection against XSS, in more detail in week 6.

Example 7: the prompt for tables and test data

Below is the data model of my application (tables, columns, relationships, ON DELETE rules).
Create two files from it for MariaDB 10.6.

1. schema.sql
- At the beginning DROP TABLE IF EXISTS for all tables in the order they can be dropped
  (pivot tables, then the main entity, then lookup tables).
- CREATE TABLE in the order they can be created (lookup tables, main entity, pivot tables).
- Every table: ENGINE=InnoDB, DEFAULT CHARSET=utf8mb4, COLLATE=utf8mb4_unicode_ci.
- Primary key: id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY.
- Foreign keys as FOREIGN KEY ... REFERENCES ... with the ON DELETE rule exactly as in the model.
- A short SQL comment (-- ...) at every table and every foreign key saying what the table holds
  and why the given ON DELETE rule applies.
- Do not write CREATE DATABASE or USE.

2. seed.sql
- INSERT into every table, 3 to 5 rows, meaningful values for my topic.
- Insert order: lookup tables, main entity, pivot tables; foreign keys refer to existing ids.

Data model:
[PASTE THE DATA MODEL FROM YOUR README HERE]

Why the prompt is written this way:

  • The drop and create order is spelled out, because the AI often gets it wrong with pivot tables and schema.sql then fails on a foreign key.
  • ENGINE, CHARSET and the primary key form are prescribed so that all tables in the group look the same and can be compared with the sample.
  • We ask for comments at ON DELETE because you explain every rule at the defence.
  • CREATE DATABASE and USE are forbidden because you already have a database and another one could not be created on sigma.tuke.sk.
  • Seed data with real values for your topic: the listing page has something to show and at the defence it is clear you understand the data.

Example 8: the prompt for models

Attached is the skeleton of a PHP project (namespace App\Model, PSR-4 autoload via Composer):
ModelInterface.php, BaseModel.php, LookupModel.php (lookup table example), MainModel.php
(main entity example) and my schema.sql.

Create model classes for my tables, one file each into src/Model/:
- for every lookup table a class following LookupModel: extends BaseModel, set $table,
  describe() returns a short description; add nothing else,
- for the main entity a class following MainModel: implements ModelInterface, getAll() and
  getById() with a JOIN to the lookup table connected by the 1:N relationship (return its name
  under a readable alias), getCount(), delete(), describe(); prepared statements with named
  placeholders.
Class names: English, singular, suffix Model (categories -> CategoryModel).
Every class uses PHP 8.1 syntax: declare(strict_types=1), parameter and return types.
A one-line comment above every method saying what it does.
At the end write the lines I should add to the $models array in index.php.

[PASTE THE CONTENTS OF THE FOUR SKELETON FILES AND SCHEMA.SQL HERE]

Why the prompt is written this way:

  • You attach the skeleton, not a description of it: the AI sees the exact namespace, method names and style, and the result fits the skeleton without changes.
  • “Add nothing else” for lookup tables stops the AI from writing its own getAll() into a five-line class, which would make inheritance pointless.
  • The class naming rule is there because the file name must match the class name, otherwise the PSR-4 autoloader will not find the file.
  • PHP 8.1 is stated explicitly because the AI otherwise likes newer constructs that do not run on sigma.tuke.sk.
  • The $models lines at the end save typos in the full class names.

Úlohy na cvičenieLab tasks

Úlohy dostaneš aj ako zadanie cv02 v Classroom 50, sú v súbore README.md v tvojom repository. Repository má na začiatku len README.md; kostru doň pridáš v úlohe 3.2.

Link na zadanie cv02: prijať zadanie (platí pre skupiny B. Stehlíkovej; ostatné skupiny dostanú link od svojho cvičiaceho)

Úloha 3.1: Kubeflow: príprava. Podľa návodu 04 Odovzdávanie, časť 1: otvor Kubeflow, skontroluj, že v Explorer je hore WEB, a skontroluj rozšírenia. Ak dole na stavovej lište nie je Go Live, rozšírenia sa po reštarte stratili. Vráti ich tento príkaz v termináli (jeden riadok):

code-server --install-extension Natizyskunk.sftp --install-extension yandeu.five-server --install-extension bmewburn.vscode-intelephense-client --install-extension mblode.twig-language-2

Potom F1, napíš reload, Developer: Reload Window.

Úloha 3.2: Clone, kostra a nastavenie. Prijmi zadanie cv02 (link je vyššie) a repository naklonuj do ~/web/it/cv02 podľa návodu 04 Odovzdávanie, časti 2 a 3 (posledné slovo príkazu je cv02). Repository má zatiaľ len README.md; kostru stiahneš z webu predmetu a rozbalíš do neho. V termináli:

cd ~/web/it/cv02
curl -O https://beata.stehlikova.website.tuke.sk/it/kostra.zip
unzip kostra.zip && rm kostra.zip
composer install
cp config.example.php config.php

V Explorer sa objavia súbory kostry (.htaccess a .gitignore začínajú bodkou, Explorer ich ukáže sivšie). Otvor config.php v editore a doplň dbname, user a pass (heslo z cvičenia). Ulož. Potom pravý klik na priečinok cv02, Upload Folder – tým sa na sigma.tuke.sk dostane aj vendor/, ktorý neprešiel editorom. Kontrola: https://sigma.tuke.sk/student/meno.priezvisko/it/cv02/ ukáže stránku „Kontrola projektu” s 0 tabuľkami.

Úloha 3.3: Tabuľky a testovacie dáta cez AI. Prompt z Ukážky 7 vlož do AI spolu s dátovým modelom zo svojho README z cv01. Výsledok ulož ako sql/schema.sql a sql/seed.sql. Prečítaj si každý ON DELETE, musí sedieť s tvojím modelom. V Adminer klikni vľavo SQL command, vlož obsah schema.sql, Execute; to isté so seed.sql. Kontrola: v Adminer vidíš svoje tabuľky s dátami a stránka „Kontrola projektu” ich vypíše v zozname.

Úloha 3.4: Modely cez AI. Prompt z Ukážky 8 vlož do AI spolu so súbormi src/Model/ModelInterface.php, BaseModel.php, LookupModel.php, MainModel.php a svojím sql/schema.sql. Vzniknuté triedy ulož do src/Model/ (jedna trieda = jeden súbor, názov súboru = názov triedy). Vzorové LookupModel.php a MainModel.php potom zmaž. Prečítaj každú metódu, pri obhajobe ju vysvetlíš.

Úloha 3.5: Kontrolná stránka. V index.php doplň do poľa $models jeden riadok new \App\Model\TvojModel(), pre každú svoju triedu. Ulož a otvor stránku na sigma.tuke.sk: pri každom modeli vidíš popis, počet záznamov a prvých 5 riadkov. Pri hlavnej entite musí byť aj stĺpec s názvom z číselníka (JOIN). Ak stránka hlási chybu, prečítaj ju – debug v config.php je zapnutý práve preto.

Úloha 3.6: Odovzdanie. Commit a push podľa návodu 04 Odovzdávanie, časť 5. config.php a vendor/ do repository nejdú (sú v .gitignore), to je správne. Na GitHub skontroluj, že je tam celá kostra, sql/, src/Model/ s tvojimi triedami a index.php.

Úloha 3.7: Kontrola na sigma.tuke.sk. Pravý klik na cv02, Upload Folder, potom otvor https://sigma.tuke.sk/student/meno.priezvisko/it/cv02/. Over aj, že …/it/cv02/src/ a …/it/cv02/config.php vrátia chybu 404 alebo 403 – kód a heslo nie sú z webu prístupné.

Hodnotenie: 1 bod. Vlastné tabuľky s testovacími dátami v databáze, modely v repository a kontrolná stránka vo webovom priestore na sigma.tuke.sk, ktorá vypíše záznamy z každej tabuľky. Commit a push najneskôr do nasledujúceho cvičenia.

You also get the tasks as the assignment cv02 in Classroom 50; they are in the file README.md in your repository. The repository has only README.md at first; you add the skeleton in task 3.2.

Link to the assignment cv02: accept the assignment (applies to B. Stehlíková’s groups; other groups get the link from their own lab teacher)

Task 3.1: Kubeflow: getting ready. Following guide 04 Submitting, part 1: open Kubeflow, check that Explorer shows WEB at the top, and check the extensions. If the status bar at the bottom does not show Go Live, the extensions were lost on restart. This command in the terminal (one line) brings them back:

code-server --install-extension Natizyskunk.sftp --install-extension yandeu.five-server --install-extension bmewburn.vscode-intelephense-client --install-extension mblode.twig-language-2

Then F1, type reload, Developer: Reload Window.

Task 3.2: Clone, skeleton and setup. Accept the assignment cv02 (link above) and clone the repository into ~/web/it/cv02 following guide 04 Submitting, parts 2 and 3 (the last word of the command is cv02). The repository has only README.md so far; you download the skeleton from the course website and unpack it into the repository. In the terminal:

cd ~/web/it/cv02
curl -O https://beata.stehlikova.website.tuke.sk/it/kostra.zip
unzip kostra.zip && rm kostra.zip
composer install
cp config.example.php config.php

The skeleton files appear in Explorer (.htaccess and .gitignore start with a dot; Explorer shows them greyed). Open config.php in the editor and fill in dbname, user and pass (the password from the lab). Save. Then right-click the cv02 folder, Upload Folder – this gets vendor/, which did not pass through the editor, to sigma.tuke.sk as well. Check: https://sigma.tuke.sk/student/name.surname/it/cv02/ shows the “Kontrola projektu” page with 0 tables.

Task 3.3: Tables and test data with AI. Paste the prompt from Example 7 into an AI together with the data model from your cv01 README. Save the result as sql/schema.sql and sql/seed.sql. Read every ON DELETE; it must match your model. In Adminer click SQL command on the left, paste the contents of schema.sql, Execute; the same with seed.sql. Check: Adminer shows your tables with data and the “Kontrola projektu” page lists them.

Task 3.4: Models with AI. Paste the prompt from Example 8 into an AI together with the files src/Model/ModelInterface.php, BaseModel.php, LookupModel.php, MainModel.php and your sql/schema.sql. Save the generated classes into src/Model/ (one class = one file, file name = class name). Then delete the example LookupModel.php and MainModel.php. Read every method; you will explain it at the defence.

Task 3.5: Check page. In index.php add one line new \App\Model\YourModel(), to the $models array for each of your classes. Save and open the page on sigma.tuke.sk: for every model you see the description, the record count and the first 5 rows. The main entity must also show the column with the name from the lookup table (JOIN). If the page reports an error, read it – debug in config.php is on exactly for this.

Task 3.6: Submitting. Commit and push following guide 04 Submitting, part 5. config.php and vendor/ do not go into the repository (they are in .gitignore); that is correct. On GitHub check that the whole skeleton, sql/, src/Model/ with your classes and index.php are there.

Task 3.7: Check on sigma.tuke.sk. Right-click cv02, Upload Folder, then open https://sigma.tuke.sk/student/name.surname/it/cv02/. Also check that …/it/cv02/src/ and …/it/cv02/config.php return a 404 or 403 error – the code and the password are not reachable from the web.

Grading: 1 point. Your own tables with test data in the database, models in the repository and the check page in your web space on sigma.tuke.sk listing the records of every table. Commit and push by the next lab at the latest.