Пета задача - DataModel

Предадени решения

Краен срок:
01.12.2016 23:59
Точки:
6

Срокът за предаване на решения е отминал

DataModel

В практиката постоянно ни се налага да работим с бази от данни. За улеснение, използваме библиотеки, които абстрахират работата с подобни системи и ни предоставят удобен интерфейс за заявки. Тези библиотеки се наричат ORM-и. Най-известният ORM за Ruby e ActiveRecord.

В тази задача ще си направим нещо наподобяващо ActiveRecord, но квадрилиони пъти по-просто.

Модели

В нашата импровизирана база от данни можем да съхраняваме различни видове обекти. Класът на всеки от тези обекти се нарича модел. Всеки модел може да има много инстанции (записи в базата ни от данни).

Ето пример за модел:

class User < DataModel
  attributes :name, :email
  data_store ArrayStore.new
end
  • attributes задава имената на атрибутите (колоните, полетата), които ще има всеки запис от типа User.
  • data_store задава хранилището, в което ще се съхраняват записите (вижте секцията за Store-ове).

Всеки клас, наследяващ от DataModel трябва да съдържа следните методи:

  • Конструктор, опционално приемащ хеш. Чрез него може да се зададат стойности на всеки от атрибутите за конкретната инстанция. Ключове, които не са зададени като атрибути се игнорират, а незададените атрибути са със стойност nil.
  • Метод #save, който записва или обновява записа в хранилището за данни (data_store-а). Ако в data_store-а вече съществува запис за обекта - обновяваме него, а не записваме дубликат.
  • По един getter и setter за всеки един атрибут.
  • Метод #delete - изтрива записа за текущата инстанция от хранилището (data store-а). Ако инстанцията не е записана - хвърля ексепшън DataModel::DeleteUnsavedRecordError.

Уникални идентификатори за записи

Добра практика е всеки запис в една база от данни да има уникален идентификатор. Това обикновено е полето id (съкратено от identifier), което присъства във всеки запис. Този атрибут съдържа число, уникално за всеки запис от конкретен тип.

Всеки модел трябва да съдържа атрибут id. Той не се задава експлицитно с attributes, но го има винаги.

ID-тата работят по следния начин:

  • При създаване на нов обект, id-то му е nil. User.new.id #=> nil
  • При записване на нов обект, id-то му се сетва на най-малкото положително цяло число (започва се от 1), което не е било използвано досега в хранилището на модела. Пример:
User.new.save.id #=> 1
User.new.save.id #=> 2
User.new.save.id #=> 3
  • id-то не се променя при повторно записване (обновяване) на един и същ обект.

Сравнения

Две инстанции на един и същ модел трябва да могат да се сравняват (чрез ==) по следния алгоритъм:

  • Необходимо (но не достатъчно) условие, за да са равни, е двата записа да са инстанции на един и същ модел.
  • Ако и двете инстанции са записани в data_store-а, считаме, че са равни точно тогава, когато ID-тата им са равни.
  • В противен случай две инстанции са равни само ако са един и същ обект в паметта.

Класови методи и търсене

Наследявайки от DataModel, трябва да получим следните класови методи за всеки модел:

  • .data_store - използва се за две цели:

    • За сетване на използваното хранилище за данни (вижте първия пример)
    • За достъп до вече зададено такова: User.data_store #=> #<ArrayStore ...>

    Подробно описание за data store ще намерите в секцията за хранилища по-надолу.

  • .attributes

    • Записва нов масив с атрибути за модела (отново първия пример)
    • Дава списък от символи, съдържащ имената на всички атрибути: Product.attributes #=> [:name, :price]
  • .where - позволява търсене на записи по определени полета:

User.new(name: 'Georgi').save
User.new(name: 'Georgi').save

User.where(name: 'Georgi') #=> [#<User ...>, #<User ...>]

Забележка: В горното върнатите обекти не са същите инстанции на класа, но имат същите стойности за атрибутите си. Новите и старите инстанции представят един и същ запис от хранилището.

Очакваме да можем да подадем повече от едно поле за търсене:

# ...create a few users...

User.where(name: 'Ivan', age: 34) #=> [#<User ...>]

В този случай трябва да намерим всички записи, които отговарят едновременно и на двете условия.

Ако бъде подадено поле, които не съществува за модела, трябва да се хвърли грешка DataModel::UnknownAttributeError със съобщение Unknown attribute <attribute_name>.

Finder-и по имената на атрибутите

За удобство, искаме нашите модели да разполагат с класови методи от вида .find_by_<attribute_name>, където <attribute_name> е името на дефиниран атрибут. Методите приемат по един аргумент - търсената стойност на атрибута. Както при where, връщат се всички записи, отговарящи на условието, като инстанции на модела.

Пример:

class User
  attributes :name, :age
  # ...
end

User.new(name: 'Georgi', age: 21).save

User.find_by_name('Georgi')             #=> [#<User ...>]
User.find_by_age(42)                    #=> []
User.find_by_email('gmail@georgi.com')  #=> NoMethodError

Хранилища за данни

Сигурно вече се чудите къде съхраняваме данните. Вместо база от данни, ще пазим всичко в паметта. За да сме по-гъвкави, ще направим така, че да можем да съхраняваме записи на различни места и по различни начини. Data store наричаме обект, който отговаря за самото съхраняване на записите.

Два такива data store-а, които трябва да имплементирате са ArrayStore и HashStore. Очевидно, от имената им, те имплементират записване съответно в масив и хеш.

Масивът (или хешът) трябва да се пазят като инстанционни променливи. Ключовете на хеша трябва да бъдат id-тата на съответните записи.

Двата вида хранилища имат един и същи интерфейс, като се различават единствено по имплементациите си. Така ще са напълно взаимозаменяеми. Това ни позволява, ако решим, да имплементираме нови начини за съхранение на данните - например FileStore, а защо не и SQLStore.

Този интерфейс се състои от следните четири CRUD метода:

  • #create - Приема хеш (запис) - атрибутите и стойностите на записа - и го добавя в колекцията.
  • #find - Приема хеш (заявка) - атрибутите и стойностите, по които търсим. Връща масив от хешове, отговарящи на заявката.
  • #update - Приема ID на обекта, който искаме да обновим, и хеш с атрибутите, които искаме да презапишем.
  • #delete - Приема заявка и изтрива всички обекти, отговарящи на заявката

Тези store-ове не трябва да знаят за съществуването на модели (DataModel). Всички методи тук приемат прости хешове, не инстанции на DataModel. Задача на самия модел е да преобразува инстанции от и към хешове преди да ги подаде на/вземе от съответния store.

ArrayStore и HashStore трябва да имат по един getter с име storage, съответно за масива и хеша, в който се съхраняват данните.

Голям пример

class User < DataModel
  attributes :name, :email
  data_store HashStore.new
end

pesho = User.new(name: 'Pesho', email: 'pesho@gmail.com')
User.where(name: 'Pesho') #=> []

pesho.save
pesho.id #=> 1

pesho_again = User.where(name: 'Pesho').first
pesho_again == pesho     #=> true
pesho_again.id           #=> 1
pesho_again.equal? pesho #=> false

pesho_again.name = 'Pesho 1'
pesho_again.save

User.where(name: 'Pesho') #=> []

gosho = User.new(name: 'Gosho')
gosho.id #=> nil

Бележки

  • В тази и бъдещи задачи ще считаме споделянето на тестове за преписване. Част от задачата е да се запознаете добре с условието и да се сетите за всички гранични случаи. Вече знаете как се пишат тестове - можете да си направите собствени. За преписването.
  • Като минимум си пуснете примерните тестове. Тях можете да ги използвате и като основа, върху която да напишете свои.

Ограничения

Тази задача има следните ограничения:

  • Най-много 90 символа на ред
  • Най-много 10 реда на метод
  • Най-много 50 реда на клас
  • Най-много 50 реда на модул

Ако искате да проверите дали задачата ви спазва ограниченията, следвайте инструкциите в описанието на хранилището за домашните.

Няма да приемаме решения, които не спазват ограниченията. Изпълнявайте rubocop редовно, докато пишете кода. Ако смятате, че rubocop греши по някакъв начин, пишете ни на fmi@ruby.bg, заедно с прикачен код или линк към такъв като private gist. Ако пуснете кода си публично (например във форумите), ще смятаме това за преписване.