| 1 | package com.renomad.minum.database; | |
| 2 | ||
| 3 | import com.renomad.minum.logging.ILogger; | |
| 4 | import com.renomad.minum.state.Context; | |
| 5 | import com.renomad.minum.utils.FileUtils; | |
| 6 | ||
| 7 | import java.io.IOException; | |
| 8 | import java.nio.file.Path; | |
| 9 | import java.util.*; | |
| 10 | import java.util.concurrent.Callable; | |
| 11 | import java.util.concurrent.ConcurrentHashMap; | |
| 12 | import java.util.concurrent.atomic.AtomicLong; | |
| 13 | import java.util.function.Function; | |
| 14 | ||
| 15 | /** | |
| 16 | * The abstract database class is a representation of the essential capabilities of | |
| 17 | * a Minum database. | |
| 18 | * <p> | |
| 19 | * There are two kinds of database provided, which only differ in how they | |
| 20 | * store data on disk. The "classic" kind, {@link Db}, stores each piece of | |
| 21 | * data in its own file. This is the simplest approach. | |
| 22 | * </p> | |
| 23 | * <p> | |
| 24 | * However, for significant speed gains, the new {@link DbEngine2} will | |
| 25 | * store each change as an append to a file, and will consolidate the on-disk | |
| 26 | * data occasionally, and on start. That way is thousands of times faster | |
| 27 | * to write to disk and to read from disk at startup. | |
| 28 | * </p> | |
| 29 | * @param <T> This is the type of data, which is always an implementation of | |
| 30 | * the {@link DbData} class. See the code of {@link com.renomad.minum.security.Inmate} | |
| 31 | * for an example of how this should look. | |
| 32 | */ | |
| 33 | public abstract class AbstractDb<T extends DbData<?>> { | |
| 34 | ||
| 35 | /** | |
| 36 | * The directory of the database on disk | |
| 37 | */ | |
| 38 | protected final Path dbDirectory; | |
| 39 | ||
| 40 | /** | |
| 41 | * An empty instance of the type of data stored by this | |
| 42 | * database, used for better handling of generics. | |
| 43 | */ | |
| 44 | protected final T emptyInstance; | |
| 45 | ||
| 46 | /** | |
| 47 | * Used for handling some file utilities in the database like creating directories | |
| 48 | */ | |
| 49 | protected final FileUtils fileUtils; | |
| 50 | ||
| 51 | /** | |
| 52 | * Holds some system-wide information that is beneficial for components of the database | |
| 53 | */ | |
| 54 | protected final Context context; | |
| 55 | ||
| 56 | /** | |
| 57 | * Used for providing logging throughout the database | |
| 58 | */ | |
| 59 | protected final ILogger logger; | |
| 60 | ||
| 61 | /** | |
| 62 | * The internal data structure of the database that resides in memory. The beating heart | |
| 63 | * of the database while it runs. | |
| 64 | */ | |
| 65 | protected final Map<Long, T> data; | |
| 66 | ||
| 67 | /** | |
| 68 | * The current index, used when creating new data items. Each item has its own | |
| 69 | * index value, this is where it is tracked. | |
| 70 | */ | |
| 71 | protected AtomicLong index; | |
| 72 | ||
| 73 | // components for registered indexes (for faster read performance) | |
| 74 | ||
| 75 | /** | |
| 76 | * This data structure is a nested map used for providing indexed data search. | |
| 77 | * <br> | |
| 78 | * The outer map is between the name of the index and the inner map. | |
| 79 | * <br> | |
| 80 | * The inner map is between strings and sets of items related to that string. | |
| 81 | */ | |
| 82 | protected final Map<String, Map<String, Set<T>>> registeredIndexes; | |
| 83 | ||
| 84 | /** | |
| 85 | * This map holds the functions that are registered to indexes, which are used | |
| 86 | * to construct the mappings between string values and items in the database. | |
| 87 | */ | |
| 88 | protected final Map<String, Function<T, String>> partitioningMap; | |
| 89 | ||
| 90 | protected AbstractDb(Path dbDirectory, Context context, T instance) { | |
| 91 | this.dbDirectory = dbDirectory; | |
| 92 | this.context = context; | |
| 93 | this.emptyInstance = instance; | |
| 94 | this.data = new ConcurrentHashMap<>(); | |
| 95 | this.logger = context.getLogger(); | |
| 96 | this.registeredIndexes = new HashMap<>(); | |
| 97 | this.partitioningMap = new HashMap<>(); | |
| 98 | this.fileUtils = new FileUtils(logger, context.getConstants()); | |
| 99 | } | |
| 100 | ||
| 101 | /** | |
| 102 | * Used to cleanly stop the database. | |
| 103 | * <br> | |
| 104 | * In the case of {@link Db} this will interrupt its internal queue and tell it | |
| 105 | * to finish up processing. | |
| 106 | * <br> | |
| 107 | * In the case of {@link DbEngine2} this will flush data to disk. | |
| 108 | */ | |
| 109 | public abstract void stop(); | |
| 110 | ||
| 111 | /** | |
| 112 | * Used to cleanly stop the database, with extra allowance of time | |
| 113 | * for cleanup. | |
| 114 | * <br> | |
| 115 | * Note that this method mostly applies to {@link Db}, and not as much | |
| 116 | * to {@link DbEngine2}. Only Db uses a processing queue on a thread which | |
| 117 | * is what requires a longer shutdown time for interruption. | |
| 118 | * @param count number of loops before we are done waiting for a clean close | |
| 119 | * and instead crash the instance closed. | |
| 120 | * @param sleepTime how long to wait, in milliseconds, for each iteration of the waiting loop. | |
| 121 | */ | |
| 122 | public abstract void stop(int count, int sleepTime); | |
| 123 | ||
| 124 | ||
| 125 | /** | |
| 126 | * Write data to the database. Use an index of 0 to store new data, and a positive | |
| 127 | * non-zero value to update data. | |
| 128 | * <p><em> | |
| 129 | * Example of adding new data to the database: | |
| 130 | * </p></em> | |
| 131 | * {@snippet : | |
| 132 | * final var newSalt = StringUtils.generateSecureRandomString(10); | |
| 133 | * final var hashedPassword = CryptoUtils.createPasswordHash(newPassword, newSalt); | |
| 134 | * final var newUser = new User(0L, newUsername, hashedPassword, newSalt); | |
| 135 | * userDb.write(newUser); | |
| 136 | * } | |
| 137 | * <p><em> | |
| 138 | * Example of updating data: | |
| 139 | * </p></em> | |
| 140 | * {@snippet : | |
| 141 | * // write the updated salted password to the database | |
| 142 | * final var updatedUser = new User( | |
| 143 | * user().getIndex(), | |
| 144 | * user().getUsername(), | |
| 145 | * hashedPassword, | |
| 146 | * newSalt); | |
| 147 | * userDb.write(updatedUser); | |
| 148 | * } | |
| 149 | * | |
| 150 | * @param newData the data we are writing | |
| 151 | * @return the data with its new index assigned. | |
| 152 | */ | |
| 153 | public abstract T write(T newData); | |
| 154 | ||
| 155 | /** | |
| 156 | * Write database data into memory | |
| 157 | * @param newData the new data may be totally new or an update | |
| 158 | * @param newElementCreated if true, this is a create. If false, an update. | |
| 159 | */ | |
| 160 | protected void writeToMemory(T newData, boolean newElementCreated) { | |
| 161 | // if we got here, we are safe to proceed with putting the data into memory and disk | |
| 162 | logger.logTrace(() -> String.format("in thread %s, writing data %s", Thread.currentThread().getName(), newData)); | |
| 163 | T oldData = data.put(newData.getIndex(), newData); | |
| 164 | ||
| 165 | // handle the indexes differently depending on whether this is a create or delete | |
| 166 |
1
1. writeToMemory : negated conditional → KILLED |
if (newElementCreated) { |
| 167 |
1
1. writeToMemory : removed call to com/renomad/minum/database/AbstractDb::addToIndexes → KILLED |
addToIndexes(newData); |
| 168 | } else { | |
| 169 |
1
1. writeToMemory : removed call to com/renomad/minum/database/AbstractDb::removeFromIndexes → KILLED |
removeFromIndexes(oldData); |
| 170 |
1
1. writeToMemory : removed call to com/renomad/minum/database/AbstractDb::addToIndexes → KILLED |
addToIndexes(newData); |
| 171 | } | |
| 172 | } | |
| 173 | ||
| 174 | /** | |
| 175 | * When new data comes in, we look at its "index" value. If | |
| 176 | * it is zero, it's a create, and we assign it a new value. If it is | |
| 177 | * positive, it is an update, and we had better find it in the database | |
| 178 | * already, or else throw an exception. | |
| 179 | * @return true if a create, false if an update | |
| 180 | */ | |
| 181 | protected boolean processDataIndex(T newData) { | |
| 182 | // *** deal with the in-memory portion *** | |
| 183 | boolean newElementCreated = false; | |
| 184 | // create a new index for the data, if needed | |
| 185 |
1
1. processDataIndex : negated conditional → KILLED |
if (newData.getIndex() == 0) { |
| 186 |
1
1. processDataIndex : removed call to com/renomad/minum/database/DbData::setIndex → KILLED |
newData.setIndex(index.getAndIncrement()); |
| 187 | newElementCreated = true; | |
| 188 | } else { | |
| 189 | // if the data does not exist, and a positive non-zero | |
| 190 | // index was provided, throw an exception. | |
| 191 |
2
1. lambda$processDataIndex$1 : replaced boolean return with true for com/renomad/minum/database/AbstractDb::lambda$processDataIndex$1 → TIMED_OUT 2. lambda$processDataIndex$1 : negated conditional → KILLED |
boolean dataEntryExists = data.values().stream().anyMatch(x -> x.getIndex() == newData.getIndex()); |
| 192 |
1
1. processDataIndex : negated conditional → KILLED |
if (!dataEntryExists) { |
| 193 | throw new DbException( | |
| 194 | String.format("Positive indexes are only allowed when updating existing data. Index: %d", | |
| 195 | newData.getIndex())); | |
| 196 | } | |
| 197 | } | |
| 198 |
2
1. processDataIndex : replaced boolean return with false for com/renomad/minum/database/AbstractDb::processDataIndex → KILLED 2. processDataIndex : replaced boolean return with true for com/renomad/minum/database/AbstractDb::processDataIndex → KILLED |
return newElementCreated; |
| 199 | } | |
| 200 | ||
| 201 | /** | |
| 202 | * Delete data | |
| 203 | * <p><em>Example:</p></em> | |
| 204 | * {@snippet : | |
| 205 | * userDb.delete(user); | |
| 206 | * } | |
| 207 | * | |
| 208 | * @param dataToDelete the data we are serializing and writing | |
| 209 | */ | |
| 210 | public abstract void delete(T dataToDelete); | |
| 211 | ||
| 212 | ||
| 213 | /** | |
| 214 | * Remove a particular item from the internal data structure in memory | |
| 215 | */ | |
| 216 | protected void deleteFromMemory(T dataToDelete) { | |
| 217 | long dataIndex; | |
| 218 |
1
1. deleteFromMemory : negated conditional → TIMED_OUT |
if (dataToDelete == null) { |
| 219 | throw new DbException("Invalid to be given a null value to delete"); | |
| 220 | } | |
| 221 | dataIndex = dataToDelete.getIndex(); | |
| 222 |
1
1. deleteFromMemory : negated conditional → KILLED |
if (!data.containsKey(dataIndex)) { |
| 223 | throw new DbException("no data was found with index of " + dataIndex); | |
| 224 | } | |
| 225 | long finalDataIndex = dataIndex; | |
| 226 | logger.logTrace(() -> String.format("in thread %s, deleting data with index %d", Thread.currentThread().getName(), finalDataIndex)); | |
| 227 | data.remove(dataIndex); | |
| 228 |
1
1. deleteFromMemory : removed call to com/renomad/minum/database/AbstractDb::removeFromIndexes → KILLED |
removeFromIndexes(dataToDelete); |
| 229 | // if all the data was just now deleted, we need to | |
| 230 | // reset the index back to 1 | |
| 231 | ||
| 232 |
1
1. deleteFromMemory : negated conditional → TIMED_OUT |
if (data.isEmpty()) { |
| 233 |
1
1. deleteFromMemory : removed call to java/util/concurrent/atomic/AtomicLong::set → TIMED_OUT |
index.set(1); |
| 234 | } | |
| 235 | } | |
| 236 | ||
| 237 | ||
| 238 | /** | |
| 239 | * add the data to registered indexes. | |
| 240 | * <br> | |
| 241 | * For each of the registered indexes, | |
| 242 | * get the stored function to obtain a string value which helps divide | |
| 243 | * the overall data into partitions. | |
| 244 | */ | |
| 245 | protected void addToIndexes(T dbData) { | |
| 246 | ||
| 247 | for (var entry : partitioningMap.entrySet()) { | |
| 248 | // a function provided by the user to obtain an index-key: a unique or semi-unique | |
| 249 | // value to help partition / index the data | |
| 250 | Function<T, String> indexStringFunction = entry.getValue(); | |
| 251 | String propertyAsString = indexStringFunction.apply(dbData); | |
| 252 | Map<String, Set<T>> stringIndexMap = registeredIndexes.get(entry.getKey()); | |
| 253 | synchronized (this) { | |
| 254 |
1
1. lambda$addToIndexes$3 : replaced return value with Collections.emptySet for com/renomad/minum/database/AbstractDb::lambda$addToIndexes$3 → KILLED |
stringIndexMap.computeIfAbsent(propertyAsString, k -> new HashSet<>()); |
| 255 | } | |
| 256 | // if the index-key provides a 1-to-1 mapping to items, like UUIDs, then | |
| 257 | // each value will have only one item in the collection. In other cases, | |
| 258 | // like when partitioning the data into multiple groups, there could easily | |
| 259 | // be many items per index value. | |
| 260 | Set<T> dataSet = stringIndexMap.get(propertyAsString); | |
| 261 | dataSet.add(dbData); | |
| 262 | } | |
| 263 | } | |
| 264 | ||
| 265 | /** | |
| 266 | * Run when an item is deleted from the database | |
| 267 | */ | |
| 268 | private void removeFromIndexes(T dbData) { | |
| 269 | for (var entry : partitioningMap.entrySet()) { | |
| 270 | // a function provided by the user to obtain an index-key: a unique or semi-unique | |
| 271 | // value to help partition / index the data | |
| 272 | Function<T, String> indexStringFunction = entry.getValue(); | |
| 273 | String propertyAsString = indexStringFunction.apply(dbData); | |
| 274 | Map<String, Set<T>> stringIndexMap = registeredIndexes.get(entry.getKey()); | |
| 275 | synchronized (this) { | |
| 276 |
2
1. lambda$removeFromIndexes$4 : replaced boolean return with true for com/renomad/minum/database/AbstractDb::lambda$removeFromIndexes$4 → KILLED 2. lambda$removeFromIndexes$4 : negated conditional → KILLED |
stringIndexMap.get(propertyAsString).removeIf(x -> x.getIndex() == dbData.getIndex()); |
| 277 | ||
| 278 | // in certain cases, we're removing one of the items that is indexed but | |
| 279 | // there are more left. If there's nothing left though, we'll remove the mapping. | |
| 280 |
1
1. removeFromIndexes : negated conditional → KILLED |
if (stringIndexMap.get(propertyAsString).isEmpty()) { |
| 281 | stringIndexMap.remove(propertyAsString); | |
| 282 | } | |
| 283 | } | |
| 284 | } | |
| 285 | } | |
| 286 | ||
| 287 | ||
| 288 | /** | |
| 289 | * Grabs all the data from disk and returns it as a list. This | |
| 290 | * method is run by various programs when the system first loads. | |
| 291 | */ | |
| 292 | public abstract void loadData() throws IOException; | |
| 293 | ||
| 294 | /** | |
| 295 | * This method provides read capability for the values of a database. | |
| 296 | * <br> | |
| 297 | * The returned collection is a read-only view over the data, through {@link Collections#unmodifiableCollection(Collection)} | |
| 298 | * | |
| 299 | * <p><em>Example:</em></p> | |
| 300 | * {@snippet : | |
| 301 | * boolean doesUserAlreadyExist(String username) { | |
| 302 | * return userDb.values().stream().anyMatch(x -> x.getUsername().equals(username)); | |
| 303 | * } | |
| 304 | * } | |
| 305 | */ | |
| 306 | public abstract Collection<T> values(); | |
| 307 | ||
| 308 | /** | |
| 309 | * Register an index in the database for higher performance data access. | |
| 310 | * <p> | |
| 311 | * This command should be run immediately after database declaration, | |
| 312 | * or more specifically, before any data is loaded from disk. Otherwise, | |
| 313 | * it would be possible to skip indexing that data. | |
| 314 | * </p> | |
| 315 | * <br> | |
| 316 | * Example: | |
| 317 | * {@snippet : | |
| 318 | * final var myDatabase = context.getDb("photos", Photograph.EMPTY); | |
| 319 | * myDatabase.registerIndex("url", photo -> photo.getUrl()); | |
| 320 | * } | |
| 321 | * @param indexName a string used to distinguish this index. This string will be used again | |
| 322 | * when requesting data in a method like {@link #getIndexedData} or {@link #findExactlyOne} | |
| 323 | * @param keyObtainingFunction a function which obtains data from the data in this database, used | |
| 324 | * to partition the data into groups (potentially up to a 1-to-1 correspondence | |
| 325 | * between id and object) | |
| 326 | * @return true if the registration succeeded | |
| 327 | * @throws DbException if the parameters are not entered properly, if the index has already | |
| 328 | * been registered, or if the data has already been loaded. It is necessary that | |
| 329 | * this is run immediately after declaring the database. To explain further: the data is not | |
| 330 | * actually loaded until the first time it is needed, such as running a write or delete, or | |
| 331 | * if the {@link #loadData()} ()} method is run. Creating an index map for the data that | |
| 332 | * is read from disk only occurs once, at data load time. Thus, it is crucial that the | |
| 333 | * registerIndex command is run before any data is loaded. | |
| 334 | */ | |
| 335 | public boolean registerIndex(String indexName, Function<T, String> keyObtainingFunction) { | |
| 336 |
1
1. registerIndex : negated conditional → KILLED |
if (keyObtainingFunction == null) { |
| 337 | throw new DbException("When registering an index, the partitioning algorithm must not be null"); | |
| 338 | } | |
| 339 |
2
1. registerIndex : negated conditional → KILLED 2. registerIndex : negated conditional → KILLED |
if (indexName == null || indexName.isBlank()) { |
| 340 | throw new DbException("When registering an index, value must be a non-empty string"); | |
| 341 | } | |
| 342 |
1
1. registerIndex : negated conditional → KILLED |
if (registeredIndexes.containsKey(indexName)) { |
| 343 | throw new DbException("It is forbidden to register the same index more than once. Duplicate index: \""+indexName+"\""); | |
| 344 | } | |
| 345 | HashMap<String, Set<T>> stringCollectionHashMap = new HashMap<>(); | |
| 346 | registeredIndexes.put(indexName, stringCollectionHashMap); | |
| 347 | partitioningMap.put(indexName, keyObtainingFunction); | |
| 348 |
1
1. registerIndex : replaced boolean return with false for com/renomad/minum/database/AbstractDb::registerIndex → KILLED |
return true; |
| 349 | } | |
| 350 | ||
| 351 | /** | |
| 352 | * Given the name of a registered index (see {@link #registerIndex(String, Function)}), | |
| 353 | * use the key to find the collection of data that matches it. | |
| 354 | * @param indexName the name of an index | |
| 355 | * @param key a string value that matches a partition calculated from the partition | |
| 356 | * function provided to {@link #registerIndex(String, Function)} | |
| 357 | * @return a collection of data, an empty collection if nothing found | |
| 358 | */ | |
| 359 | public Collection<T> getIndexedData(String indexName, String key) { | |
| 360 |
1
1. getIndexedData : negated conditional → KILLED |
if (!registeredIndexes.containsKey(indexName)) { |
| 361 | throw new DbException("There is no index registered on the database Db<"+this.emptyInstance.getClass().getSimpleName()+"> with a name of \""+indexName+"\""); | |
| 362 | } | |
| 363 | Set<T> values = registeredIndexes.get(indexName).get(key); | |
| 364 | // return an empty set rather than null | |
| 365 |
1
1. getIndexedData : replaced return value with Collections.emptyList for com/renomad/minum/database/AbstractDb::getIndexedData → KILLED |
return Objects.requireNonNullElseGet(values, Set::of); |
| 366 | } | |
| 367 | ||
| 368 | /** | |
| 369 | * Get a set of the currently-registered indexes on this database, useful | |
| 370 | * for debugging. | |
| 371 | */ | |
| 372 | public Set<String> getSetOfIndexes() { | |
| 373 |
1
1. getSetOfIndexes : replaced return value with Collections.emptySet for com/renomad/minum/database/AbstractDb::getSetOfIndexes → KILLED |
return partitioningMap.keySet(); |
| 374 | } | |
| 375 | ||
| 376 | /** | |
| 377 | * A utility to find exactly one item from the database. | |
| 378 | * <br> | |
| 379 | * This utility will search the indexes for a particular data by | |
| 380 | * indexName and indexKey. If not found, it will return null. If | |
| 381 | * found, it will be returned. If more than one are found, an exception | |
| 382 | * will be thrown. Use this tool when the data has been uniquely | |
| 383 | * indexed, like for example when setting a unique identifier into | |
| 384 | * each data. | |
| 385 | * @param indexName the name of the index, an arbitrary value set by the | |
| 386 | * user to help distinguish among potentially many indexes | |
| 387 | * set on this data | |
| 388 | * @param indexKey the key for this particular value, such as a UUID or a name | |
| 389 | * or any other way to partition the data | |
| 390 | * @see #findExactlyOne(String, String, Callable) | |
| 391 | */ | |
| 392 | public T findExactlyOne(String indexName, String indexKey) { | |
| 393 |
1
1. findExactlyOne : replaced return value with null for com/renomad/minum/database/AbstractDb::findExactlyOne → KILLED |
return findExactlyOne(indexName, indexKey, () -> null); |
| 394 | } | |
| 395 | ||
| 396 | /** | |
| 397 | * Find one item, with an alternate value if null | |
| 398 | * <br> | |
| 399 | * This utility will search the indexes for a particular data by | |
| 400 | * indexName and indexKey. If not found, it will return null. If | |
| 401 | * found, it will be returned. If more than one are found, an exception | |
| 402 | * will be thrown. Use this tool when the data has been uniquely | |
| 403 | * indexed, like for example when setting a unique identifier into | |
| 404 | * each data. | |
| 405 | * @param indexName the name of the index, an arbitrary value set by the | |
| 406 | * user to help distinguish among potentially many indexes | |
| 407 | * set on this data | |
| 408 | * @param indexKey the key for this particular value, such as a UUID or a name | |
| 409 | * or any other way to partition the data | |
| 410 | * @param alternate a functional interface that will be run if the result would | |
| 411 | * have been null, useful for situations where you don't want | |
| 412 | * the output to be null when nothing is found. | |
| 413 | * @see #findExactlyOne(String, String) | |
| 414 | */ | |
| 415 | public T findExactlyOne(String indexName, String indexKey, Callable<T> alternate) { | |
| 416 | Collection<T> indexedData = getIndexedData(indexName, indexKey); | |
| 417 |
1
1. findExactlyOne : negated conditional → KILLED |
if (indexedData.isEmpty()) { |
| 418 | try { | |
| 419 |
1
1. findExactlyOne : replaced return value with null for com/renomad/minum/database/AbstractDb::findExactlyOne → KILLED |
return alternate.call(); |
| 420 | } catch (Exception ex) { | |
| 421 | throw new DbException(ex); | |
| 422 | } | |
| 423 |
1
1. findExactlyOne : negated conditional → KILLED |
} else if (indexedData.size() == 1) { |
| 424 |
1
1. findExactlyOne : replaced return value with null for com/renomad/minum/database/AbstractDb::findExactlyOne → KILLED |
return indexedData.stream().findFirst().orElseThrow(); |
| 425 | } else { | |
| 426 | throw new DbException("More than one item found when searching database Db<%s> on index \"%s\" with key %s" | |
| 427 | .formatted(emptyInstance.getClass().getSimpleName(), indexName, indexKey)); | |
| 428 | } | |
| 429 | } | |
| 430 | ||
| 431 | } | |
Mutations | ||
| 166 |
1.1 |
|
| 167 |
1.1 |
|
| 169 |
1.1 |
|
| 170 |
1.1 |
|
| 185 |
1.1 |
|
| 186 |
1.1 |
|
| 191 |
1.1 2.2 |
|
| 192 |
1.1 |
|
| 198 |
1.1 2.2 |
|
| 218 |
1.1 |
|
| 222 |
1.1 |
|
| 228 |
1.1 |
|
| 232 |
1.1 |
|
| 233 |
1.1 |
|
| 254 |
1.1 |
|
| 276 |
1.1 2.2 |
|
| 280 |
1.1 |
|
| 336 |
1.1 |
|
| 339 |
1.1 2.2 |
|
| 342 |
1.1 |
|
| 348 |
1.1 |
|
| 360 |
1.1 |
|
| 365 |
1.1 |
|
| 373 |
1.1 |
|
| 393 |
1.1 |
|
| 417 |
1.1 |
|
| 419 |
1.1 |
|
| 423 |
1.1 |
|
| 424 |
1.1 |