Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixed some problems #29

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ val sttp = "3.5.0"
val anorm = "2.7.0"
val scalaTestPlusPlay = "6.0.0-M6"
val scalaTestPlusMockito = "3.2.15.0"
val reactAdmin = "4.14.3"
val reactAdmin = "4.14.4"

val consoleDisabledOptions = Seq("-Xfatal-warnings", "-Ywarn-unused", "-Ywarn-unused-import")

Expand Down
10 changes: 10 additions & 0 deletions spra-play-server/src/main/resources/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ dataExplorer {
}
referenceDisplayField = "email"
}

images {
tableName = "images"
primaryKeyField = "image_id"
nonEditableColumns = ["image_id", "created_at"]
canBeDeleted = false
createFilter {
requiredColumns = ["name", "data"]
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package net.wiringbits.spra.admin.models

trait FieldValue[T] extends Serializable {
val value: T
}

case class StringValue(value: String) extends FieldValue[String]
case class ByteArrayValue(value: Array[Byte]) extends FieldValue[Array[Byte]]
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package net.wiringbits.spra.admin.repositories

import net.wiringbits.spra.admin.config.{DataExplorerConfig, TableSettings}
import net.wiringbits.spra.admin.executors.DatabaseExecutionContext
import net.wiringbits.spra.admin.models.{ByteArrayValue, StringValue}
import net.wiringbits.spra.admin.repositories.daos.DatabaseTablesDAO
import net.wiringbits.spra.admin.repositories.models.{DatabaseTable, ForeignKey, TableColumn, TableData}
import net.wiringbits.spra.admin.utils.StringParse
import net.wiringbits.spra.admin.utils.models.QueryParameters
import play.api.db.Database

Expand Down Expand Up @@ -75,7 +77,10 @@ class DatabaseTablesRepository @Inject() (database: Database)(implicit
val fieldsAndValues = body.map { case (key, value) =>
val field =
columns.find(_.name == key).getOrElse(throw new RuntimeException(s"Invalid property in body request: $key"))
(field, value)
if (field.`type` == "bytea")
Antonio171003 marked this conversation as resolved.
Show resolved Hide resolved
val byteaValue = StringParse.stringToByteArray(value)
(field, ByteArrayValue(byteaValue))
else (field, StringValue(value))
}
DatabaseTablesDAO.create(
tableName = tableName,
Expand All @@ -100,7 +105,10 @@ class DatabaseTablesRepository @Inject() (database: Database)(implicit
val fieldsAndValues = bodyWithoutNonEditableColumns.map { case (key, value) =>
val field =
columns.find(_.name == key).getOrElse(throw new RuntimeException(s"Invalid property in body request: $key"))
(field, value)
if (field.`type` == "bytea")
val byteaValue = StringParse.stringToByteArray(value)
(field, ByteArrayValue(byteaValue))
else (field, StringValue(value))
}
val primaryKeyType = settings.primaryKeyDataType
DatabaseTablesDAO.update(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package net.wiringbits.spra.admin.repositories.daos

import anorm.{SqlParser, SqlStringInterpolation}
import net.wiringbits.spra.admin.config.{CustomDataType, PrimaryKeyDataType, TableSettings}
import net.wiringbits.spra.admin.models.{ByteArrayValue, FieldValue, StringValue}
import net.wiringbits.spra.admin.repositories.models.*
import net.wiringbits.spra.admin.utils.models.{FilterParameter, QueryParameters}
import net.wiringbits.spra.admin.utils.{QueryBuilder, StringRegex}
Expand All @@ -10,7 +11,7 @@ import java.sql.{Connection, Date, PreparedStatement, ResultSet}
import java.time.LocalDate
import java.util.UUID
import scala.collection.mutable.ListBuffer
import scala.util.Try
import scala.util.{Failure, Success, Try}

object DatabaseTablesDAO {

Expand Down Expand Up @@ -73,6 +74,37 @@ object DatabaseTablesDAO {
""".as(foreignKeyParser.*)
}

private def columnTypeIsDouble(columnType: String): Boolean = {
// 'contains' is used because PostgreSQL types may include additional details like precision or scale
// https://www.postgresql.org/docs/8.1/datatype.html
List("float", "decimal").exists(columnType.contains)
}

private def columnTypeIsInt(columnType: String): Boolean = {
List("int", "serial").exists(columnType.contains)
}

private def isUUID(value: String, columnType: String): Boolean = {
Try(UUID.fromString(value)) match {
case Success(_) => columnType == "uuid"
case Failure(_) => false
}
}

private def isInt(value: String, columnType: String): Boolean = {
value.toIntOption.isDefined && columnTypeIsInt(columnType)
}

private def isDecimal(value: String, columnType: String): Boolean = {
value.toDoubleOption.isDefined && columnTypeIsDouble(columnType)
}

private def isNumberOrUUID(value: String, columnType: String): Boolean = {
isInt(value, columnType) ||
isDecimal(value, columnType) ||
isUUID(value, columnType)
}

def getTableData(
settings: TableSettings,
columns: List[TableColumn],
Expand All @@ -88,12 +120,15 @@ object DatabaseTablesDAO {

val conditionsSql = queryParameters.filters
.map { case FilterParameter(filterField, filterValue) =>
val columnType = columns.find(_.name == filterField) match {
case Some(column) => column.`type`
case None => throw Exception(s"Column with name '$filterField' not found.")
}
filterValue match {
case dateRegex(_, _, _) =>
case dateRegex(_, _, _) if columnType == "date" =>
s"DATE($filterField) = ?"

case _ =>
if (filterValue.toIntOption.isDefined || filterValue.toDoubleOption.isDefined)
if (isNumberOrUUID(filterValue, columnType))
s"$filterField = ?"
else
s"$filterField LIKE ?"
Expand All @@ -111,20 +146,25 @@ object DatabaseTablesDAO {
val preparedStatement = conn.prepareStatement(sql)

queryParameters.filters.zipWithIndex
.foreach { case (FilterParameter(_, filterValue), index) =>
.foreach { case (FilterParameter(filterField, filterValue), index) =>
// We have to increment index by 1 because SQL parameterIndex starts in 1
val sqlIndex = index + 1

val columnType = columns.find(_.name == filterField) match {
case Some(column) => column.`type`
case None => throw Exception(s"Column with name '$filterField' not found.")
}
filterValue match {
case dateRegex(year, month, day) =>
case dateRegex(year, month, day) if columnType == "date" =>
val parsedDate = LocalDate.of(year.toInt, month.toInt, day.toInt)
preparedStatement.setDate(sqlIndex, Date.valueOf(parsedDate))

case _ =>
if (filterValue.toIntOption.isDefined)
if (isInt(filterValue, columnType))
preparedStatement.setInt(sqlIndex, filterValue.toInt)
else if (filterValue.toDoubleOption.isDefined)
else if (isDecimal(filterValue, columnType))
preparedStatement.setDouble(sqlIndex, filterValue.toDouble)
else if (isUUID(filterValue, columnType))
preparedStatement.setObject(sqlIndex, UUID.fromString(filterValue))
else
preparedStatement.setString(sqlIndex, s"%$filterValue%")
}
Expand Down Expand Up @@ -230,7 +270,7 @@ object DatabaseTablesDAO {
}
def create(
tableName: String,
fieldsAndValues: Map[TableColumn, String],
fieldsAndValues: Map[TableColumn, FieldValue[_]],
primaryKeyField: String,
primaryKeyType: PrimaryKeyDataType = PrimaryKeyDataType.UUID
)(implicit
Expand All @@ -250,7 +290,7 @@ object DatabaseTablesDAO {
// Postgres: INSERT INTO test_serial (id) VALUES(DEFAULT); MySQL: INSERT INTO table (id) VALUES(NULL)

for (j <- i + 1 to fieldsAndValues.size + i) {
val value = fieldsAndValues(fieldsAndValues.keys.toList(j - i - 1))
val value = fieldsAndValues(fieldsAndValues.keys.toList(j - i - 1)).value
preparedStatement.setObject(j, value)
}
val result = preparedStatement.executeQuery()
Expand All @@ -260,17 +300,17 @@ object DatabaseTablesDAO {

def update(
tableName: String,
fieldsAndValues: Map[TableColumn, String],
fieldsAndValues: Map[TableColumn, FieldValue[_]],
primaryKeyField: String,
primaryKeyValue: String,
primaryKeyType: PrimaryKeyDataType = PrimaryKeyDataType.UUID
)(implicit conn: Connection): Unit = {
val sql = QueryBuilder.update(tableName, fieldsAndValues, primaryKeyField)
val preparedStatement = conn.prepareStatement(sql)

val notNullData = fieldsAndValues.filterNot { case (_, value) => value == "null" }
val notNullData = fieldsAndValues.filterNot { case (_, value) => value.value == "null" }
notNullData.zipWithIndex.foreach { case ((_, value), i) =>
preparedStatement.setObject(i + 1, value)
preparedStatement.setObject(i + 1, value.value)
}
// where ... = ?
setPreparedStatementKey(preparedStatement, primaryKeyValue, primaryKeyType, notNullData.size + 1)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package net.wiringbits.spra.admin.utils

import net.wiringbits.spra.admin.config.PrimaryKeyDataType
import net.wiringbits.spra.admin.models.FieldValue
import net.wiringbits.spra.admin.repositories.models.TableColumn

import scala.collection.mutable

object QueryBuilder {
def create(
tableName: String,
fieldsAndValues: Map[TableColumn, String],
fieldsAndValues: Map[TableColumn, FieldValue[_]],
primaryKeyField: String,
primaryKeyType: PrimaryKeyDataType = PrimaryKeyDataType.UUID
): String = {
Expand All @@ -33,10 +34,10 @@ object QueryBuilder {
|""".stripMargin
}

def update(tableName: String, body: Map[TableColumn, String], primaryKeyField: String): String = {
def update(tableName: String, body: Map[TableColumn, FieldValue[_]], primaryKeyField: String): String = {
val updateStatement = new mutable.StringBuilder("SET")
for ((tableField, value) <- body) {
val resultStatement = if (value == "null") "NULL" else s"?::${tableField.`type`}"
val resultStatement = if (value.value == "null") "NULL" else s"?::${tableField.`type`}"
val statement = s" ${tableField.name} = $resultStatement,"
updateStatement.append(statement)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package net.wiringbits.spra.admin.utils

import scala.util.{Failure, Success, Try}

object StringParse {

def stringToByteArray(value: String): Array[Byte] = {
// Removes whitespace characters (\\s) and brackets ([, ]) to prepare the string for byte array conversion
Try(value.replaceAll("[\\[\\]\\s]", "").split(",").map(_.toByte)) match
case Success(value) => value
case Failure(_) => Array.emptyByteArray
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package net.wiringbits.spra.ui.web
import net.wiringbits.spra.api.models.AdminGetTables
import net.wiringbits.spra.ui.web.components.{CreateGuesser, EditGuesser, ListGuesser}
import net.wiringbits.spra.ui.web.facades.reactadmin.{Admin, Resource}
import net.wiringbits.spra.ui.web.facades.simpleRestProvider
import net.wiringbits.spra.ui.web.facades.createDataProvider
import net.wiringbits.spra.ui.web.models.DataExplorerSettings
import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits.global
import slinky.core.facade.{Hooks, ReactElement}
Expand Down Expand Up @@ -52,7 +52,7 @@ object AdminView {
}

div()(
Admin(simpleRestProvider(tablesUrl))(buildResources),
Admin(createDataProvider(tablesUrl))(buildResources),
error.map(h1(_))
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ object CreateGuesser {
case ColumnType.Email =>
TextInput(source = field.name, isRequired = isRequired, validate = required)
case ColumnType.Image =>
ImageField(source = field.name, isRequired = isRequired, validate = required)
ImageInput(source = field.name, isRequired = isRequired, validate = required)(ImageField(source = "src"))
case ColumnType.Number =>
NumberInput(source = field.name, isRequired = isRequired, validate = required)
case ColumnType.Reference(reference, source) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ object EditGuesser {
case ColumnType.Date => DateTimeInput(source = field.name, disabled = field.disabled)
case ColumnType.Text => TextInput(source = field.name, disabled = field.disabled)
case ColumnType.Email => TextInput(source = field.name, disabled = field.disabled)
case ColumnType.Image => ImageField(source = field.name)
case ColumnType.Image => ImageInput(source = field.name)(ImageField(source = "src"))
case ColumnType.Number => NumberInput(source = field.name, disabled = field.disabled)
case ColumnType.Reference(reference, source) =>
ReferenceInput(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ object ListGuesser {
val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props =>
val fields = ResponseGuesser.getTypesFromResponse(props.response)

def defaultField(reference: String, source: String)(children: ReactElement*): ReactElement =
def defaultField(reference: String, source: String)(children: ReactElement): ReactElement =
ReferenceField(reference = reference, source = source)(children)

val widgetFields: Seq[ReactElement] = fields.map { field =>
Expand Down Expand Up @@ -49,7 +49,14 @@ object ListGuesser {
case ColumnType.Image => Fragment()
case ColumnType.Number => NumberInput(source = field.name)
case ColumnType.Reference(reference, source) =>
defaultField(reference, field.name)(TextField(source = source))
ReferenceInput(
source = field.name,
reference = reference
)(
SelectInput(
optionText = props.response.referenceDisplayField.getOrElse(source)
)
)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
package net.wiringbits.spra.ui.web.facades

import scala.scalajs.js
import scala.scalajs.js.annotation.JSImport

@js.native
trait DataProvider extends js.Object

@js.native
@JSImport("ra-data-simple-rest", JSImport.Default)
Antonio171003 marked this conversation as resolved.
Show resolved Hide resolved
// https://www.npmjs.com/package/ra-data-simple-rest
def simpleRestProvider(url: String): DataProvider = js.native

@js.native
@JSImport("react-admin", "withLifecycleCallbacks")
Antonio171003 marked this conversation as resolved.
Show resolved Hide resolved
// https://marmelab.com/react-admin/withLifecycleCallbacks.html
object WithLifecycleCallbacks extends js.Object {
def apply(dataProvider: DataProvider, callbacks: js.Array[js.Object]): DataProvider = js.native
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
package net.wiringbits.spra.ui.web

import scala.scalajs.js
import scala.scalajs.js.annotation.JSImport
import net.wiringbits.spra.ui.web.utils.Images.*
import org.scalajs.dom.File

package object facades {
@js.native
@JSImport("ra-data-simple-rest", JSImport.Default)
def simpleRestProvider(url: String): DataProvider = js.native

def createDataProvider(url: String): DataProvider = {
val baseDataProvider = simpleRestProvider(url)
WithLifecycleCallbacks(
baseDataProvider,
js.Array(
js.Dynamic.literal(
resource = "images",
afterRead = (record: js.Dynamic, dataProvider: js.Any) => {
val hexImage = record.data.asInstanceOf[String]
val urlImage = convertHexToImage(hexImage)
record.updateDynamic("data")(urlImage)
record
},
beforeSave = (data: js.Dynamic, dataProvider: js.Any) => {
val rawFile = data.data.rawFile.asInstanceOf[File]
convertImageToByteArray(rawFile).`then` { value =>
data.updateDynamic("data")(value.asInstanceOf[js.Any])
data
}
}
)
)
)
}
}
Loading